Skip to content
ioob.dev
Go back

Design Patterns Part 6 — Decorator and Proxy

· 7 min read
Design Pattern Series (6/8)
  1. Design Patterns Part 1 — Why Learn Patterns
  2. Design Patterns Part 2 — Strategy and State
  3. Design Patterns Part 3 — Observer and Command
  4. Design Patterns Part 4 — Template Method and Chain of Responsibility
  5. Design Patterns Part 5 — Factory Method, Abstract Factory, Builder
  6. Design Patterns Part 6 — Decorator and Proxy
  7. Design Patterns Part 7 — Adapter, Facade, Composite: Patterns for Organizing Structure
  8. Design Patterns Part 8 — Singleton, Iterator, Prototype: Frequently Used but Often Misunderstood
Table of contents

Table of contents

What It Means to Wrap

When you want to add functionality to an object, the first approach that comes to mind is inheritance. Something like LoggingUserService extends UserService to extend the existing class. Simple and intuitive.

But problems arise as you add features through inheritance. A service that needs logging, a service that needs caching, a service that needs both — each combination requires a subclass. Three features produce up to seven combinations. Four features produce fifteen. This is the class explosion problem. As inheritance hierarchies deepen, anyone reading the code has to jump between multiple classes tracing “so what does this method actually do?”

Decorator and Proxy offer the same structural solution to this problem. Create a new object that wraps the original, but implement the same interface as the original. From the outside, the original and the wrapper look identical. The difference lies in their intent. Decorator aims to add functionality, while Proxy aims to control access.

Decorator — Wrapping to Add Features

Structure of the Pattern

The Decorator pattern wraps an object with another object, adding new behavior on top of the original’s behavior. Because the wrapping object (Decorator) and the wrapped object (Component) implement the same interface, the client cannot tell them apart.

classDiagram
    class Component {
        <<interface>>
        +operation()
    }
    class ConcreteComponent {
        +operation()
    }
    class Decorator {
        <<abstract>>
        -component: Component
        +operation()
    }
    class ConcreteDecoratorA {
        +operation()
    }
    class ConcreteDecoratorB {
        +operation()
    }

    Component <|.. ConcreteComponent
    Component <|.. Decorator
    Decorator <|-- ConcreteDecoratorA
    Decorator <|-- ConcreteDecoratorB
    Decorator o-- Component : wraps

The key is that Decorator holds a Component as a field while also implementing Component itself. This structure allows decorators to be stacked in multiple layers.

Code Example — Notification System

A scenario where the base notification is email, with SMS or Slack notifications added on as needed.

Define the interface.

public interface Notifier {
    void send(String message);
}

The base implementation is email notification.

public class EmailNotifier implements Notifier {

    private final String email;

    public EmailNotifier(String email) {
        this.email = email;
    }

    @Override
    public void send(String message) {
        System.out.println("Email sent -> " + email + ": " + message);
    }
}

Create the decorator base class. This class establishes the wrapping structure.

public abstract class NotifierDecorator implements Notifier {

    protected final Notifier wrapped;

    protected NotifierDecorator(Notifier wrapped) {
        this.wrapped = wrapped;
    }

    @Override
    public void send(String message) {
        wrapped.send(message); // Delegate to the original
    }
}

What this class does is wrap a Notifier and forward send() calls to the original as-is. Nothing is added yet.

Implement the SMS and Slack decorators.

public class SmsDecorator extends NotifierDecorator {

    private final String phoneNumber;

    public SmsDecorator(Notifier wrapped, String phoneNumber) {
        super(wrapped);
        this.phoneNumber = phoneNumber;
    }

    @Override
    public void send(String message) {
        super.send(message); // Execute existing notification
        System.out.println("SMS sent -> " + phoneNumber + ": " + message);
    }
}

public class SlackDecorator extends NotifierDecorator {

    private final String channel;

    public SlackDecorator(Notifier wrapped, String channel) {
        super(wrapped);
        this.channel = channel;
    }

    @Override
    public void send(String message) {
        super.send(message); // Execute existing notification
        System.out.println("Slack sent -> #" + channel + ": " + message);
    }
}

Stacking decorators looks like this.

Notifier notifier = new EmailNotifier("dev@example.com");
notifier = new SmsDecorator(notifier, "010-1234-5678");
notifier = new SlackDecorator(notifier, "alerts");

notifier.send("Server outage detected");

Running this code sends email, SMS, and Slack notifications in order. Each decorator sends its own notification and delegates to the inner decorator (or original) via super.send(). No need for multiple inheritance hierarchies — functionality is extended through composition alone.

Java I/O — The Textbook Decorator Example

Java’s java.io package is the most famous real-world application of the Decorator pattern.

// Base stream that reads bytes from a file
InputStream raw = new FileInputStream("data.txt");

// Add buffering
InputStream buffered = new BufferedInputStream(raw);

// Add GZIP decompression
InputStream decompressed = new GZIPInputStream(buffered);

FileInputStream, BufferedInputStream, and GZIPInputStream all implement InputStream. BufferedInputStream wraps FileInputStream to add buffering, and GZIPInputStream wraps BufferedInputStream to add decompression. From the outside, they are all just InputStream.

Why is this structure powerful? Because each feature is an independent decorator, you can freely mix and match combinations. You can use just buffering, just GZIP, or both. Adding new stream features (encryption, Base64 decoding, etc.) does not require modifying existing classes.

The downsides are also clear. When you first see Java I/O code like new BufferedReader(new InputStreamReader(new FileInputStream("data.txt"), "UTF-8")), the nesting can be dizzying. The wrapping order matters, but the compiler does not enforce it, so wrapping in the wrong order only reveals problems at runtime.

Decorator in Kotlin

Using Kotlin’s by keyword (delegation) reduces decorator boilerplate.

interface Notifier {
    fun send(message: String)
}

class EmailNotifier(private val email: String) : Notifier {
    override fun send(message: String) {
        println("Email sent -> $email: $message")
    }
}

class SmsDecorator(
    private val wrapped: Notifier,
    private val phone: String
) : Notifier by wrapped {  // Delegate to wrapped

    override fun send(message: String) {
        wrapped.send(message)
        println("SMS sent -> $phone: $message")
    }
}

by wrapped automatically delegates all methods of Notifier to wrapped. You only need to implement the methods you want to override. Even if the interface has ten methods, you only touch the one you care about. There is no need for a separate base decorator class.

Proxy — Wrapping to Control Access

Structure of the Pattern

The Proxy pattern also wraps the original object. Its class diagram is nearly identical to Decorator’s. But the purpose is different. Proxy controls access to the original. Rather than adding features, the key is checking or optimizing something before reaching the original.

Three commonly used types of Proxy:

Code Example — Lazy Loading Proxy

Database connections are heavy resources. Instead of connecting immediately at application startup, let us build a lazy loading proxy that connects only when the first query arrives.

sequenceDiagram
    participant Client
    participant Proxy as DatabaseProxy
    participant Real as RealDatabase

    Client->>Proxy: query("SELECT ...")
    alt Not yet connected
        Proxy->>Real: connect()
        Real-->>Proxy: Connection established
    end
    Proxy->>Real: query("SELECT ...")
    Real-->>Proxy: Results
    Proxy-->>Client: Results
    Note over Proxy: From the second query on,<br/>connect() is skipped

Define the interface.

public interface Database {
    List<Map<String, Object>> query(String sql);
    void close();
}

The real implementation connects to the DB upon creation.

public class RealDatabase implements Database {

    private final Connection connection;

    public RealDatabase(String url, String user, String password) {
        // The actual DB connection is made at this point (heavy operation)
        this.connection = DriverManager.getConnection(url, user, password);
        System.out.println("DB connection established: " + url);
    }

    @Override
    public List<Map<String, Object>> query(String sql) {
        // Actual query execution logic
        // ...
        return results;
    }

    @Override
    public void close() {
        connection.close();
    }
}

The proxy defers RealDatabase creation until first use.

public class LazyDatabaseProxy implements Database {

    private final String url;
    private final String user;
    private final String password;
    private RealDatabase realDatabase; // Starts as null

    public LazyDatabaseProxy(String url, String user, String password) {
        this.url = url;
        this.user = user;
        this.password = password;
        // No connection is made here!
    }

    private RealDatabase getRealDatabase() {
        if (realDatabase == null) {
            realDatabase = new RealDatabase(url, user, password);
        }
        return realDatabase;
    }

    @Override
    public List<Map<String, Object>> query(String sql) {
        return getRealDatabase().query(sql); // Connects on first call
    }

    @Override
    public void close() {
        if (realDatabase != null) {
            realDatabase.close();
        }
    }
}

What this proxy does is hide RealDatabase inside itself and create the real object only when query() is first called. From the second call onward, it reuses the already-created object.

The client cannot distinguish the proxy from the real object.

Database db = new LazyDatabaseProxy("jdbc:mysql://localhost/mydb", "root", "pass");
// No DB connection exists at this point

// ...application initialization...

List<Map<String, Object>> users = db.query("SELECT * FROM users");
// The DB connection is established at this point

Caching Proxy Example

In situations where the same query is executed repeatedly, inserting a caching proxy reduces DB load.

public class CachingDatabaseProxy implements Database {

    private final Database wrapped;
    private final Map<String, List<Map<String, Object>>> cache = new HashMap<>();

    public CachingDatabaseProxy(Database wrapped) {
        this.wrapped = wrapped;
    }

    @Override
    public List<Map<String, Object>> query(String sql) {
        if (cache.containsKey(sql)) {
            System.out.println("Cache hit: " + sql);
            return cache.get(sql);
        }

        List<Map<String, Object>> result = wrapped.query(sql);
        cache.put(sql, result);
        return result;
    }

    @Override
    public void close() {
        cache.clear();
        wrapped.close();
    }
}

What this proxy does is use the SQL string as a key to cache results. When the same query arrives, it returns the cached result without hitting the DB. In production you would add TTL (Time-To-Live) or cache size limits, but this is the pattern’s core structure.

Combining the caching proxy and lazy loading proxy is also possible.

Database db = new CachingDatabaseProxy(
    new LazyDatabaseProxy("jdbc:mysql://localhost/mydb", "root", "pass")
);

From the outside, it is just a Database. Internally, it checks the cache and, on a cache miss, goes through the lazy loading proxy to access the real DB.

Spring AOP and Proxy

If you are using the Spring Framework, you are already under the influence of the Proxy pattern. Spring’s AOP (Aspect-Oriented Programming) is implemented using Dynamic Proxies.

Taking @Transactional as an example, Spring creates a proxy object for that bean and registers it in the container. When a client calls a method, the proxy — not the real object — receives the call first. The proxy starts a transaction, invokes the actual method, commits if no exception occurs, and rolls back on exception.

@Service
public class OrderService {

    @Transactional
    public void placeOrder(OrderRequest request) {
        // This method is not called directly.
        // A proxy created by Spring opens a transaction,
        // calls this method,
        // and commits or rolls back based on the result.
        orderRepository.save(request.toOrder());
        paymentService.charge(request.getPaymentInfo());
    }
}

Spring uses two proxy mechanisms.

  1. JDK Dynamic Proxy: When an interface exists. Uses java.lang.reflect.Proxy to create the proxy object at runtime.
  2. CGLIB Proxy: When no interface exists. Manipulates bytecode to create a subclass of the target class at runtime.

Either way, the client has no idea whether it is a proxy or the real object. This is the essence of the Proxy pattern — transparently interposing.

Not understanding this structure leads to a common Spring trap. Calling a @Transactional method from within the same class does not apply the transaction. this.placeOrder() calls the real object directly, bypassing the proxy. Understanding the Proxy pattern’s structure makes the cause of such issues immediately obvious.

Decorator vs Proxy — Same Structure, Different Intent

Looking only at the class diagram, Decorator and Proxy are indistinguishable. Both implement the same interface as the original and hold the original object as a field. The difference lies in why they wrap.

CriterionDecoratorProxy
PurposeAdd functionalityControl access
Relationship with originalStacks new behavior on top of the originalIntercepts before reaching the original
LayeringCore usage pattern (multiple layers)Usually a single layer
Original object creationClient creates the original and passes it inProxy can manage original creation
Typical examplesJava I/O, notification decoratorsSpring AOP, lazy loading, caching
Intent question”I need to add features to this object""I need to control access to this object”

Sometimes the boundary gets blurry. Caching can look like adding functionality or controlling access. Logging is the same way. In such cases, judge by “what is the primary purpose of this wrapping?” If caching’s primary purpose is “reducing calls to the original,” it is closer to Proxy. If it is “adding cache headers to the response,” it is closer to Decorator.

Decision Criteria in Practice

When torn between Decorator and Proxy, ask this:

“Is the reason for wrapping ‘adding’ or ‘blocking/deferring’?”

If adding, it is Decorator. Adding SMS to email notification, then adding Slack on top. Features accumulate. The more you wrap, the more the object can do.

If blocking or deferring, it is Proxy. Blocking unauthorized users, deferring creation of a heavy object, returning a cache for repeated requests. The key is making a decision before reaching the original.

Both patterns stand on the principle of “wrapping with the same interface.” Once you understand this principle, clarifying the intent of the wrapping naturally determines which pattern to use. The simpler the structure of a pattern, the more important it is to precisely name the intent.


In the next part, we explore three patterns that match interfaces, hide complexity, and treat parts and wholes uniformly. Adapter transforms an existing interface to the desired shape, Facade places a simple window in front of a complex subsystem, and Composite recursively handles tree structures.

-> Part 7: Adapter, Facade, Composite


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Design Patterns Part 5 — Factory Method, Abstract Factory, Builder
Next Post
Design Patterns Part 7 — Adapter, Facade, Composite: Patterns for Organizing Structure