03-structural-patterns

Decorator Pattern: Adding Behavior Without Subclassing

How to attach new behavior to individual objects dynamically, the way java.io streams do, without subclass explosion — and how to chain decorators correctly.

August 11, 2026
lldstructuraldecoratorwrapperjava-io

Decorator Pattern

Intent: attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending behavior — behavior is composed at runtime instead of being fixed at compile time by a class hierarchy.

The pattern earns its name from the real-world action: you decorate a cake with frosting, then sprinkles, then a cherry — each layer wraps the previous one, adds its own contribution, and the result is still, fundamentally, a cake you can hand to anyone expecting one. That "still fundamentally the same type, just wrapped" property is exactly what makes Decorator transparent to the client.

💡

If you've used new BufferedReader(new InputStreamReader(new FileInputStream(file))), you've already used Decorator. Each layer wraps the previous Reader/InputStream and is itself a Reader/InputStream — that's the whole pattern, in one line of standard library code you've probably written a hundred times.


1. The problem: behavior combinations cause subclass explosion

Say you're modeling coffee shop orders. A Beverage has a cost() and a description(). Customers can add milk, add a double shot of espresso, add whipped cream, in any combination.

VIOLATION: one subclass per combination

java
// VIOLATION: every combination of add-ons needs its own subclass
abstract class Beverage {
    abstract double cost();
    abstract String description();
}
 
class Espresso extends Beverage {
    double cost() { return 2.00; }
    String description() { return "Espresso"; }
}
 
class EspressoWithMilk extends Beverage {
    double cost() { return 2.00 + 0.50; }
    String description() { return "Espresso, Milk"; }
}
 
class EspressoWithMilkAndWhip extends Beverage {
    double cost() { return 2.00 + 0.50 + 0.70; }
    String description() { return "Espresso, Milk, Whip"; }
}
 
class EspressoWithDoubleShotAndMilkAndWhip extends Beverage {
    double cost() { return 2.00 + 2.00 + 0.50 + 0.70; }
    String description() { return "Espresso, Double Shot, Milk, Whip"; }
}
// N add-ons → up to 2^N subclasses. Adding a new add-on ("Caramel") means
// writing a new subclass for every EXISTING combination that could include it too.

This is a textbook Open/Closed Principle violation with a combinatorial multiplier: a new add-on doesn't just add one class, it potentially doubles the number of classes needed to represent every combination. And every price change to "Milk" means hunting down and editing every subclass whose name contains "Milk."


2. The fix: wrap, don't subclass

Each add-on becomes a decorator: a Beverage that wraps another Beverage, adds its own cost/description contribution, and delegates the rest.

java
// FIXED: Beverage stays abstract; decorators wrap ANY Beverage, including each other.
abstract class Beverage {
    abstract double cost();
    abstract String description();
}
 
class Espresso extends Beverage {
    double cost() { return 2.00; }
    String description() { return "Espresso"; }
}
 
// The decorator IS a Beverage (so it's transparent to callers) and HAS a Beverage (so it can wrap one)
abstract class BeverageDecorator extends Beverage {
    protected final Beverage wrapped;
    BeverageDecorator(Beverage wrapped) { this.wrapped = wrapped; }
}
 
class MilkDecorator extends BeverageDecorator {
    MilkDecorator(Beverage wrapped) { super(wrapped); }
    double cost() { return wrapped.cost() + 0.50; }
    String description() { return wrapped.description() + ", Milk"; }
}
 
class WhipDecorator extends BeverageDecorator {
    WhipDecorator(Beverage wrapped) { super(wrapped); }
    double cost() { return wrapped.cost() + 0.70; }
    String description() { return wrapped.description() + ", Whip"; }
}
 
class ExtraShotDecorator extends BeverageDecorator {
    ExtraShotDecorator(Beverage wrapped) { super(wrapped); }
    double cost() { return wrapped.cost() + 2.00; }
    String description() { return wrapped.description() + ", Extra Shot"; }
}
java
// Any combination, composed at runtime — zero new classes for a new COMBINATION,
// only one new class ever needed for a genuinely new ADD-ON.
Beverage order = new WhipDecorator(
                      new MilkDecorator(
                          new ExtraShotDecorator(
                              new Espresso())));
 
System.out.println(order.description()); // "Espresso, Extra Shot, Milk, Whip"
System.out.println(order.cost());        // 2.00 + 2.00 + 0.50 + 0.70 = 5.20

Adding "Caramel" later is exactly one new class, CaramelDecorator, and it composes with every existing decorator automatically — no combinatorial blow-up.

Decorator chaining works because each decorator is transparently the same type it wraps. order above has static type Beverage at every layer — the caller calling .cost() has no idea, and doesn't need to know, how many decorators are stacked underneath. This transparency is what separates Decorator from a builder that just accumulates a total.


3. Decorator vs. subclassing: runtime vs. compile-time

Inheritance (subclassing)Decorator (composition)
When behavior is fixedCompile-time — EspressoWithMilk is permanently that combinationRuntime — wrap and unwrap combinations as needed, even per-request
Combining N behaviorsUp to 2^N subclasses for every combinationN decorator classes total, composed as needed
Changing behavior on one instance onlyImpossible — behavior is a property of the classNatural — wrap just that one instance
New behavior laterNew subclass, potentially combined with every existing oneOne new decorator class, works with all existing ones automatically
CouplingTight — subclass depends on superclass internalsLoose — decorator only depends on the abstract component interface

This table is really the OCP story from the SOLID guide, replayed for the specific "wrap an object at runtime" shape: see SOLID Principles §2 for the general form of "extension via composition, not editing."


4. Java I/O: Decorator in the standard library

java.io is the pattern's most famous real-world instance. InputStream is the abstract component; FileInputStream is a concrete component; FilterInputStream is the abstract decorator; BufferedInputStream, DataInputStream, and GZIPInputStream are concrete decorators.

java
// Each layer wraps the previous stream, adding one capability:
InputStream stream = new BufferedInputStream(          // adds buffering
                          new GZIPInputStream(          // adds decompression
                              new FileInputStream(       // reads raw bytes from disk
                                  "data.csv.gz")));
 
// Reader side mirrors it:
BufferedReader reader = new BufferedReader(              // adds line-based reading + buffering
                             new InputStreamReader(        // adapts bytes -> characters (this layer is actually an Adapter!)
                                 new FileInputStream("data.csv"),
                                 StandardCharsets.UTF_8));
💡

Notice InputStreamReader in that stack — it's doing Adapter's job (bytes → characters), not Decorator's job (same interface, added behavior). The two patterns look identical structurally (both wrap an object) but this is a good live example of the difference: InputStreamReader changes the interface from InputStream to Reader; BufferedInputStream keeps the same InputStream interface and adds buffering. See Adapter Pattern for that distinction in depth.

java.io's known wart is instructive too: FilterInputStream's default method implementations delegate naively, which has caused subtle bugs historically (e.g. read(byte[], int, int) not always overridden correctly by custom decorators, mark()/reset() support being decorator-dependent). The lesson for your own decorators: document which methods each decorator overrides, and be explicit about which capabilities pass through unmodified versus which are decorator-specific.


5. When to use vs. when it's overkill

Use Decorator whenSkip it when
You need to add behavior to some instances of a type, not all of themThe behavior applies to every instance of the type uniformly — just put it in the base class
The set of possible behavior combinations is large or grows over timeThere are only 2-3 fixed, well-known combinations — named subclasses may be clearer
You want to add/remove responsibilities at runtimeBehavior is determined entirely at compile/construction time and never changes afterward
Behavior needs to compose (order can matter, e.g. compress-then-encrypt vs encrypt-then-compress)Order never matters and there's exactly one behavior to add — a single wrapper class (no abstract decorator layer) is simpler
⚠️

Over-applying Decorator: wrapping a single, fixed piece of cross-cutting behavior (e.g., "always log every repository call") in a decorator hierarchy when a simple AOP aspect, a @Around advice, or even a static utility would do is unnecessary ceremony. Reach for Decorator when combinations genuinely vary per-instance and per-call; reach for a framework's interceptor/aspect mechanism when the behavior is uniform and cross-cutting.


6. Decorator vs. Proxy

Structurally these are nearly identical — both wrap an object behind the same interface. The difference is intent:

DecoratorProxy
PurposeAdd or enhance behaviorControl access to the underlying object
Wrapped object known at construction?Yes, always passed in and always delegated toSometimes lazily created by the proxy itself (virtual proxy)
Can be stacked/chainedYes — that's the point, multiple decorators composeRare — usually exactly one proxy in front of the real object
Client awarenessClient generally doesn't need to know it's decoratedClient generally doesn't need to know it's a proxy either — but the proxy's job is gatekeeping, not feature-adding
Typical exampleBufferedInputStream adding bufferingA protection proxy denying delete() calls for non-admin users

See Proxy Pattern for the full breakdown of proxy variants (virtual, protection, remote) and where the line with Decorator gets blurry in practice (a caching wrapper, for instance, arguably has flavors of both).


7. Production example: request/response middleware

A very common real-world Decorator shape in backend systems — HTTP client wrappers that add retries, metrics, and circuit-breaking around a base HTTP client, each independently toggleable:

java
interface HttpClient {
    HttpResponse send(HttpRequest request);
}
 
class BasicHttpClient implements HttpClient {
    public HttpResponse send(HttpRequest request) { /* actual network call */ return new HttpResponse(200); }
}
 
abstract class HttpClientDecorator implements HttpClient {
    protected final HttpClient wrapped;
    HttpClientDecorator(HttpClient wrapped) { this.wrapped = wrapped; }
}
 
class RetryingHttpClient extends HttpClientDecorator {
    private final int maxAttempts;
    RetryingHttpClient(HttpClient wrapped, int maxAttempts) { super(wrapped); this.maxAttempts = maxAttempts; }
 
    public HttpResponse send(HttpRequest request) {
        RuntimeException lastFailure = null;
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return wrapped.send(request);
            } catch (RuntimeException e) {
                lastFailure = e;
            }
        }
        throw lastFailure;
    }
}
 
class MetricsHttpClient extends HttpClientDecorator {
    MetricsHttpClient(HttpClient wrapped) { super(wrapped); }
 
    public HttpResponse send(HttpRequest request) {
        long start = System.nanoTime();
        try {
            return wrapped.send(request);
        } finally {
            recordLatency(System.nanoTime() - start);
        }
    }
 
    private void recordLatency(long nanos) { /* emit to metrics backend */ }
}
 
// Composed per call site — a background job might skip metrics, a user-facing
// call might want both:
HttpClient resilientClient = new MetricsHttpClient(
                                  new RetryingHttpClient(
                                      new BasicHttpClient(), 3));

This is precisely why libraries like OkHttp (Interceptor chains) and Spring's ClientHttpRequestInterceptor are Decorator-shaped under the hood — cross-cutting HTTP concerns compose the same way coffee add-ons do.


Interview Questions

  • What problem does Decorator solve that plain subclassing can't, and why does the subclass count grow combinatorially without it?
  • Walk through why new BufferedInputStream(new FileInputStream(...)) is a Decorator pattern instance. What's the abstract component, and what's the concrete decorator?
  • Why is InputStreamReader typically called out as an Adapter rather than a Decorator, even though it also "wraps" a stream?
  • What does it mean for a decorator to be "transparent" to the client? Why does that property matter for chaining?
  • Design a decorator-based solution for adding logging, caching, and retry behavior to a service call, and explain why order of wrapping matters.
  • Compare Decorator and Proxy. If both wrap an object behind the same interface, what's the deciding question you'd ask to classify a given wrapper as one or the other?
  • When would you prefer a framework's AOP/interceptor mechanism over hand-rolling a Decorator hierarchy?
  • What's a known pitfall with java.io's FilterInputStream-based decorators, and how would you avoid it in your own decorator hierarchy?