Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

decorator pattern

Tung Bui Thanh (TungDaDev)11 mins read

Decorator pattern.webp

Sách vỡ lòng hay lấy ví dụ thêm sữa, thêm đường vào cốc cà phê để dạy Decorator Pattern. Ngoài đời, nó là nền của thư viện I/O trong Java (BufferedInputStream(GZIPInputStream(FileInputStream))) và là họ hàng gần của cách Spring cài đặt Lập trình Hướng Khía Cạnh (Aspect-Oriented Programming - AOP) bằng proxy. Khi nào nên tự viết Decorator bằng tay, khi nào để Spring AOP Dynamic Proxy lo? Và bẫy 'Self-Invocation' nổi tiếng trong Spring thì Decorator có thoát được không?

Thêm các hành vi phụ (Cross-Cutting Concerns) như Caching, đo thời gian (Metric Timing), Retry hay log bảo mật mà không làm bẩn code nghiệp vụ là bài toán quen thuộc của Clean Architecture.

Bài này so sánh GoF Decorator truyền thống với Spring AOP Proxies (JDK Dynamic Proxy vs CGLIB), rồi xây một Resilient Caching Decorator làm ví dụ, kèm những chỗ phải gia cố thêm nếu muốn đem lên production.


# vấn đề: "vụ nổ lớp" (class explosion)

Giả sử bạn có một PaymentService cơ bản, rồi yêu cầu mới phát sinh:

  1. Thêm mã hóa dữ liệu (Encryption).
  2. Thêm log kiểm toán (Audit Logging).
  3. Thêm bộ nhớ đệm (Caching).

Dùng Kế thừa (Inheritance) thì số class nổ theo tổ hợp (Combinatorial Class Explosion): mỗi cách bật/tắt N tính năng là một class riêng, tức cỡ 2^N class. Chưa kể Java chỉ cho kế thừa đơn, nên EncryptedAndLoggedPaymentService không thể "lấy" code từ cả hai class cha mà phải chép lại một trong hai.

# nguyên lý của Decorator

  • Triển khai cùng một Interface với đối tượng gốc.
  • Giữ một tham chiếu (private final T delegate) tới đối tượng bên trong.
  • Bọc lồng nhau thoải mái lúc Runtime:
    PaymentService service = new LoggingDecorator(new EncryptionDecorator(new CorePaymentService()));

Ba tính năng giờ chỉ cần ba class Decorator, ghép kiểu gì cũng được. Cái giá phải trả là thứ tự bọc trở thành một phần của logic: log trước hay sau khi mã hoá cho ra hai kết quả khác nhau, và người lắp ráp phải biết điều đó.


# ví dụ kinh điển: package java.io

Ví dụ rõ nhất của Decorator Pattern trong Java Standard Library là gói java.io:

// Nghệ thuật lồng ghép Decorator của JDK:
InputStream is = new BufferedInputStream( // Decorator 2: Thêm bộ đệm RAM 8KB
                    new GZIPInputStream( // Decorator 1: Giải nén luồng Gzip on-the-fly
                        new FileInputStream("data.gz") // Component gốc: Đọc byte thô từ Disk
                    )
                 );

Mỗi lớp chỉ lo đúng một việc (Single Responsibility Principle - SRP):

  • FileInputStream: mở file qua hệ điều hành.
  • GZIPInputStream: giải nén.
  • BufferedInputStream: gom các lần đọc lẻ tẻ thành từng mẻ 8 KB (DEFAULT_BUFFER_SIZE = 8192), để code gọi read() từng byte không phải chui vào bộ giải nén mỗi lần.

Cái "khung" Decorator trong java.io là FilterInputStream: nó giữ một InputStream in và mặc định chuyển mọi lời gọi xuống đó. BufferedInputStream và InflaterInputStream (cha của GZIPInputStream) đều kế thừa nó.

Có một chi tiết hay bị hiểu nhầm: với thứ tự bọc ở trên, bộ đệm 8 KB nằm ngoài bộ giải nén, nên nó đệm dữ liệu đã giải nén. Phần đọc file thì do GZIPInputStream tự lo, mỗi lần đọc từ FileInputStream theo buffer riêng của nó (mặc định chỉ 512 byte), tức mỗi 512 byte là một system call. Muốn giảm system call xuống đĩa thì đặt buffer sát file (new GZIPInputStream(new BufferedInputStream(new FileInputStream(...)))) hoặc truyền size lớn hơn qua constructor GZIPInputStream(in, size). Đây cũng là minh hoạ rõ nhất cho chuyện thứ tự bọc Decorator ảnh hưởng tới hành vi.


# Decorator thủ công vs Spring AOP Proxy

Trong Spring, phần lớn việc của Decorator đã được Spring AOP làm hộ (@Transactional, @Cacheable, @Retryable). Thật ra proxy của Spring cũng là một Decorator, chỉ khác là class bọc được sinh ra lúc chạy thay vì do bạn viết. Hai cách vì vậy khác nhau chủ yếu ở cách chạy bên dưới:

JDK Dynamic Proxy chỉ bọc được qua interface; CGLIB tạo class con nên bọc được cả class không có interface, nhưng không chặn được method final, private hay static. Spring Boot từ bản 2.0 mặc định spring.aop.proxy-target-class=true, tức dùng CGLIB kể cả khi bean có interface.

# so sánh chi tiết kỹ thuật

Tiêu chíManual GoF DecoratorSpring AOP Dynamic Proxy
Cơ chế hoạt độngỦy nhiệm tường minh (Explicit Delegation)Can thiệp động bằng Bytecode (CGLIB / JDK Proxy)
Tính minh bạch (Debugging)Cao: stack trace gọn, dễ đặt breakpointThấp: stack trace chứa nhiều lớp proxy nội bộ của Spring
Cạm bẫy Self-InvocationCũng bị, nhưng dễ thấy vì lớp bọc nằm ngay trong codeBị lỗi: gọi this.internalMethod() sẽ bỏ qua toàn bộ Proxy
Boilerplate CodePhải viết class wrapper và override toàn bộ methodsRất ít code: chỉ cần gắn annotation (vd: @Cacheable)
Khuyến nghị kiến trúcHợp cho Core SDK, luồng hiệu năng cao cần kiểm soát chặtHợp cho các tác vụ Cross-cutting dùng chung trong Spring Boot

# bẫy self-invocation trong Spring AOP và cách Decorator xử lý

Một lỗi kinh điển của dev Spring Boot:

// LỖI CHÍ MẠNG TRONG SPRING AOP:
@Service
public class OrderService {
 
    public void processOrder(Long orderId) {
        // ... Logic chuẩn bị
        this.chargeCard(orderId); // GỌI NỘI BỘ (SELF-INVOCATION)!
    }
 
    @Transactional // ANNOTATION NÀY HOÀN TOÀN BỊ VÔ HIỆU HÓA!
    public void chargeCard(Long orderId) {
        // Do gọi qua con trỏ 'this', luồng đi trực tiếp trong target object,
        // HOÀN TOÀN BỎ QUA Spring CGLIB Proxy! Transaction KHÔNG ĐƯỢC MỞ!
    }
}

Decorator có thoát được không? Không hẳn. Decorator thủ công cũng chỉ chặn được lời gọi đi từ bên ngoài vào (outer.operation()). Nếu bên trong object gốc, processOrder() gọi this.chargeCard() thì lời gọi đó cũng không đi qua Decorator, y hệt proxy. Cái lợi của Decorator là lớp bọc nằm ngay trong code, nên đọc là thấy lời gọi nào được bọc, lời gọi nào không; còn với annotation thì @Transactional nằm trên method khiến người đọc tưởng nó luôn có hiệu lực.

Cách xử lý thực tế, xếp theo mức mình thấy nên ưu tiên:

  1. Tách chargeCard() sang một bean khác (vd PaymentGateway) rồi inject vào. Lời gọi đi qua proxy của bean đó nên annotation chạy đúng. Đây cũng thường là thiết kế sạch hơn.
  2. Đặt annotation ở method được gọi từ bên ngoài (processOrder()), nếu ngữ nghĩa cho phép cả luồng chạy trong một transaction.
  3. Tự inject chính mình (@Lazy + inject OrderService) hoặc dùng AopContext.currentProxy() (cần bật exposeProxy = true). Chạy được nhưng code phụ thuộc vào cơ chế proxy, nên chỉ dùng khi không tách được.
  4. Chuyển sang AspectJ weaving (compile-time hoặc load-time): aspect được dệt thẳng vào bytecode của class nên gọi qua this vẫn bị chặn, đổi lại build và cấu hình phức tạp hơn.

# ví dụ: resilient caching Decorator

Ví dụ dưới tách hẳn tầng truy vấn database (CoreCustomerAccountService) khỏi tầng Caching Redis và đo metric, bằng Decorator Pattern kết hợp Spring Bean Configuration:

# interface chính
public interface CustomerAccountService {
    CustomerAccount getAccount(Long customerId);
}
# component gốc (chỉ tập trung vào SQL database)
@Service("coreCustomerService")
public class CoreCustomerAccountService implements CustomerAccountService {
    private final JdbcTemplate jdbcTemplate;
 
    public CoreCustomerAccountService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
 
    @Override
    public CustomerAccount getAccount(Long customerId) {
        // Query thuần SQL nặng nề
        return jdbcTemplate.queryForObject("SELECT ... WHERE id = ?", mapper, customerId);
    }
}
# resilient caching Decorator (trang bị Redis cache + fallback)
@Service
@Primary // Đánh dấu là Bean ưu tiên để Controller tự động inject Decorator
public class CachingCustomerAccountDecorator implements CustomerAccountService {
 
    private static final Logger log = LoggerFactory.getLogger(CachingCustomerAccountDecorator.class);
    private final CustomerAccountService delegate; // Đối tượng được bọc
    private final RedisTemplate<String, Object> redisTemplate;
    private final MeterRegistry meterRegistry;
 
    public CachingCustomerAccountDecorator(
            @Qualifier("coreCustomerService") CustomerAccountService delegate,
            RedisTemplate<String, Object> redisTemplate,
            MeterRegistry meterRegistry) {
        this.delegate = delegate;
        this.redisTemplate = redisTemplate;
        this.meterRegistry = meterRegistry;
    }
 
    @Override
    public CustomerAccount getAccount(Long customerId) {
        String cacheKey = "customer:account:" + customerId;
 
        // 1. Kiểm tra cache Redis
        try {
            CustomerAccount cached = (CustomerAccount) redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                meterRegistry.counter("customer.cache.hit").increment();
                return cached;
            }
        } catch (Exception ex) {
            log.warn("Redis sập! Bỏ qua cache, fallback sang Database: {}", ex.getMessage());
        }
 
        meterRegistry.counter("customer.cache.miss").increment();
 
        // 2. Đo thời gian thực thi của đối tượng gốc
        Timer.Sample sample = Timer.start(meterRegistry);
        CustomerAccount account = delegate.getAccount(customerId);
        sample.stop(meterRegistry.timer("customer.db.query.duration"));
 
        // 3. Ghi vào Redis (đồng bộ; lỗi thì bỏ qua, không làm hỏng request)
        try {
            redisTemplate.opsForValue().set(cacheKey, account, Duration.ofMinutes(15));
        } catch (Exception ex) {
            log.error("Không thể ghi cache: {}", ex.getMessage());
        }
 
        return account;
    }
}

# được gì từ cách làm này

  1. CoreCustomerAccountService sạch, không có dòng nào về Redis, Prometheus hay Fallback.
  2. Muốn thay Redis bằng Caffeine Cache hoặc thêm tầng mã hóa AES thì chỉ cần thêm một Decorator mới bọc ngoài, không sửa dòng code Database nào (đúng OCP & SRP).

Trước khi mang ra production, còn vài chỗ cần tính thêm:

  • Redis chết thì vẫn chậm. try-catch giữ cho request không lỗi, nhưng mỗi request vẫn phải chờ Redis tới hết timeout rồi mới xuống DB. Nên đặt command timeout ngắn cho client Redis (vd vài chục ms), và nếu muốn bỏ qua Redis hẳn khi nó đang chết thì bọc thêm một Circuit Breaker (vd Resilience4j). Đó lại là một Decorator nữa.
  • Serializer. Với RedisTemplate<String, Object>, phép ép kiểu (CustomerAccount) chỉ chạy khi serializer lưu kèm thông tin kiểu (vd GenericJackson2JsonRedisSerializer hoặc JDK serialization). Dùng Jackson thường thì giá trị đọc ra là LinkedHashMap, ép kiểu ném ClassCastException, bị catch nuốt và log thành "Redis sập", nên rất khó phát hiện. Dùng RedisTemplate<String, CustomerAccount> với Jackson2JsonRedisSerializer<>(CustomerAccount.class) thì an toàn hơn.
  • Cache stampede. Một key hot hết hạn đúng lúc nhiều request ập tới thì tất cả cùng miss và cùng đập xuống DB. Có thể cộng thêm TTL ngẫu nhiên vài phút, hoặc khoá theo key để chỉ một request đi lấy dữ liệu.
  • Invalidate. Decorator này chỉ lo phía đọc. Chỗ nào cập nhật CustomerAccount thì phải xoá key tương ứng, nếu không người dùng sẽ thấy dữ liệu cũ tới 15 phút.

# checklist

  • 1. Giữ Interface Contract: mọi Decorator phải implements cùng interface với component được bọc để giữ tính đa hình.
  • 2. Thứ tự lồng nhau (Decorator Ordering): xác định rõ thứ tự bọc (ví dụ: Logging ngoài cùng, Caching ở giữa, Retry bên trong, rồi tới Core Service).
  • 3. Đừng bọc quá sâu: không nên lồng quá 4 - 5 lớp Decorator, vì Method Call Stack dài hơn và truy vết lỗi khó hơn.
  • 4. Xử lý Self-Invocation: nếu các method có @Transactional hoặc @Cacheable gọi nhau trong cùng 1 class, hãy tách sang service khác. Decorator tường minh cũng không chặn được lời gọi qua this, chỉ giúp nhìn thấy nó rõ hơn.
  • 5. Graceful Degradation: trong các Decorator phụ (Caching, Audit), luôn bọc try-catch để lỗi của tầng phụ không làm sập luồng nghiệp vụ chính.

# kết luận

Decorator Pattern là ví dụ điển hình cho nguyên lý "Ưu tiên Thành phần hơn Kế thừa" (Favor Composition over Inheritance).

Dù bạn tự bọc các lớp ủy nhiệm trong Clean Architecture hay để Spring AOP Dynamic Proxy làm giúp, thì vẫn nên hiểu cơ chế bọc lớp, biết nó chạy ở đâu và rủi ro hiệu năng nằm chỗ nào.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.