The Decorator pattern adds behavior to an object by wrapping it in another object that has the same interface, instead of creating a subclass for every combination of features. You probably use it every day without noticing: new BufferedReader(new InputStreamReader(new FileInputStream(f))) is three stacked wrappers. This article dissects the Decorator pattern in java.io, builds a byte-counting decorator, and then applies the pattern to a payment service with logging, retry and idempotency. Every code sample was actually run and compiled with javac --release 17, so it uses only features and APIs available since Java 17.
This series has already covered Adapter pattern: connecting legacy APIs to new interfaces, another pattern built on wrapping an object but changing its interface. For a refresher on the design foundations, see SOLID principles in Java with practical examples, because the Decorator pattern is a textbook example of Open/Closed and Single Responsibility.
The problem: subclass explosion as features pile up
Suppose an order system has a PaymentGateway interface with one real payment gateway behind it. Today you need to log every call, and the Decorator pattern is the cleanest answer to this kind of cumulative requirement. Next week you need a retry when the gateway reports a timeout. Next month you need to prevent charging twice for the same order ID. If each need becomes a subclass of the real gateway, you end up with LoggingGateway, RetryGateway, then LoggingRetryGateway, RetryIdempotentGateway and so on. With n independent features the number of potential combinations is 2n, and every combination is rigidly inherited from one concrete implementation.
Inheritance has two more weaknesses: it is decided at compile time, and it is unusable when the base class is final or lives in a library you cannot change. The Decorator pattern solves all three weaknesses with composition: each feature is a small class that receives an object with the same interface, does its own job, and then forwards the call.
Structure of the Decorator pattern
According to refactoring.guru (where it is also called Wrapper) and the GoF book, the Decorator pattern has four roles:
| Role | Responsibility | Example in java.io | Example in this article |
|---|---|---|---|
| Component | Shared interface | InputStream | PaymentGateway |
| Concrete Component | The original object that does the real work | FileInputStream | FakeGateway (a simulated gateway) |
| Base Decorator | Holds a reference to a Component and forwards every call | FilterInputStream | GatewayDecorator |
| Concrete Decorator | Adds behavior before or after forwarding | BufferedInputStream, GZIPInputStream | LoggingGateway, RetryGateway |
The key point of the Decorator pattern: a decorator is a Component (same interface) and has a Component (holds a reference). That is why the client does not know, and does not need to know, whether it holds the original object or a stack of five wrappers. The client stacks wrappers in the combination it needs and treats the result like any other Component.
Learning the Decorator pattern from java.io: the familiar decorator stack
The Javadoc of FilterInputStream describes exactly the Base Decorator role: the class "contains some other input stream, which it uses as its basic source of data, possibly transforming the data along the way or providing additional functionality". Every method of InputStream is overridden to forward the request to the inner stream (the protected InputStream in field). Its direct subclasses include BufferedInputStream, DataInputStream, InflaterInputStream, CheckedInputStream, DigestInputStream, CipherInputStream and a few more.
Each decorator does exactly one thing: buffering, decompression, checksums, decryption. To read a gzip-compressed CSV file line by line, you stack them: FileInputStream (reads bytes from disk) wrapped by GZIPInputStream (decompresses), wrapped by InputStreamReader (bytes to characters, specifying UTF-8), wrapped by BufferedReader (reads lines). The order is not arbitrary: decompression must come before converting bytes to characters.
What is interesting is that BufferedInputStream does not change the interface, only the performance. I measured it on a machine with 16 GB of RAM, compiled with --release 17 and run on JDK 21, by reading a random 4 MB file one byte at a time, skipping two warm-up rounds and taking the third round:
try (InputStream in = new FileInputStream(big.toFile())) {
int b; while ((b = in.read()) != -1) sum += b; // no buffering
}
try (InputStream in = new BufferedInputStream(new FileInputStream(big.toFile()))) {
int b; while ((b = in.read()) != -1) sum += b; // with the buffering decorator
}Read a 4 MB file byte by byte (3rd measurement): unbuffered 1385 ms, with BufferedInputStream 20 msThe result is only illustrative (measured with System.nanoTime, not JMH, and it varies from run to run: a rerun gave 1451 ms versus 20 ms; three extra runs on JDK 21 gave 1387, 1393 and 1367 ms for the unbuffered side and still 20 ms for the buffered side; all of them are saved in code/s6-07/output-javaio.txt). But the order of magnitude is clear: every unbuffered read() is a system call, while BufferedInputStream reads a large block and then hands out bytes from memory. This is the real value of the Decorator pattern in the standard library: it adds one concern (buffering) without the code that reads the data changing a single line.
Writing your own Decorator pattern: counting bytes through a stream
To see the mechanism, extend FilterInputStream and count the bytes that have been read. This is, in broad strokes, how classes such as CheckedInputStream work.
static class CountingInputStream extends FilterInputStream {
private long count;
CountingInputStream(InputStream in) { super(in); }
@Override public int read() throws IOException {
int b = super.read();
if (b >= 0) count++;
return b;
}
@Override public int read(byte[] buf, int off, int len) throws IOException {
int n = super.read(buf, off, len);
if (n > 0) count += n;
return n;
}
long count() { return count; }
}You need to override both read() and read(byte[], int, int). The Javadoc warns that FilterInputStream.read(byte[]) calls read(b, 0, b.length) rather than in.read(b), because some subclasses depend on exactly this strategy. Forgetting to override one of the two is a common mistake when implementing the Decorator pattern on I/O streams, and it makes the counter undercount. Insert the decorator in the middle of the gzip file reading chain:
CountingInputStream counting = new CountingInputStream(new FileInputStream(gz.toFile()));
try (BufferedReader r = new BufferedReader(
new InputStreamReader(new GZIPInputStream(counting), StandardCharsets.UTF_8))) {
// read line by line ...
}
System.out.println(counting.count());This is the real output from running java JavaIoDemo.java (a simulated CSV file with 2,000 orders):
Original size: 37409 bytes, after gzip: 9726 bytes
First line: order_id,customer,total_amount
Lines read: 2001, compressed bytes read through the counting decorator: 9726
Declared static type: InputStream, actual type: BufferedInputStreamThe counter reports exactly 9726 bytes, equal to the compressed file size, because it sits below GZIPInputStream: it sees the compressed data, not the decompressed data. If you placed it above GZIPInputStream, it would count 37409 bytes. A decorator's position in the stack determines which layer it observes, a small example of why order matters.
Note that the CountingInputStream above is a minimal version of the Decorator pattern: it does not handle skip(), mark() and reset(). If the client calls skip(n), the skipped bytes are not counted; after reset(), the bytes that are read again are counted twice. Real decorators such as BufferedInputStream or CheckedInputStream must consider each of these methods.
Applying the Decorator pattern to a payment service
Back to the opening problem, to apply the Decorator pattern to business logic. The interface and a simulated gateway (the first two calls throw a timeout error so we can test retry):
record PaymentResult(String orderId, boolean ok, String message) {}
interface PaymentGateway {
PaymentResult charge(String orderId, long amountVnd);
}
static class FakeGateway implements PaymentGateway {
final AtomicInteger calls = new AtomicInteger();
public PaymentResult charge(String orderId, long amountVnd) {
int n = calls.incrementAndGet();
if (n <= 2) throw new IllegalStateException("gateway timeout (attempt " + n + ")");
return new PaymentResult(orderId, true, "OK " + amountVnd);
}
}The Base Decorator only holds a reference and forwards calls. The concrete decorators extend it and write only what is different:
static abstract class GatewayDecorator implements PaymentGateway {
protected final PaymentGateway next;
protected GatewayDecorator(PaymentGateway next) { this.next = Objects.requireNonNull(next); }
public PaymentResult charge(String orderId, long amountVnd) { return next.charge(orderId, amountVnd); }
}
static class RetryGateway extends GatewayDecorator {
private final int maxAttempts;
RetryGateway(PaymentGateway next, int maxAttempts) { super(next); this.maxAttempts = maxAttempts; }
public PaymentResult charge(String orderId, long amountVnd) {
RuntimeException last = null;
for (int i = 1; i <= maxAttempts; i++) {
try { return super.charge(orderId, amountVnd); }
catch (RuntimeException e) { last = e; System.out.println("[retry] attempt " + i + " failed"); }
}
throw last;
}
}LoggingGateway prints a log before and after the call, IdempotentGateway keeps a Map of results keyed by orderId so the same order ID is not charged twice, and TimingGateway measures elapsed time. Below are the other two decorators of this Decorator pattern example, enough for you to copy and run together with the snippets above (put them in one class, for example ServiceDecoratorDemo, with import java.util.*):
static class LoggingGateway extends GatewayDecorator {
LoggingGateway(PaymentGateway next) { super(next); }
public PaymentResult charge(String orderId, long amountVnd) {
System.out.println("[log] charge " + orderId + " " + amountVnd);
try {
PaymentResult r = super.charge(orderId, amountVnd);
System.out.println("[log] -> " + r);
return r;
} catch (RuntimeException e) {
System.out.println("[log] !! " + e.getMessage());
throw e;
}
}
}
static class IdempotentGateway extends GatewayDecorator {
private final Map<String, PaymentResult> done = new HashMap<>();
IdempotentGateway(PaymentGateway next) { super(next); }
public PaymentResult charge(String orderId, long amountVnd) {
PaymentResult cached = done.get(orderId);
if (cached != null) { System.out.println("[idem] returning stored result for " + orderId); return cached; }
PaymentResult r = super.charge(orderId, amountVnd);
done.put(orderId, r); // store ONLY AFTER the gateway returns successfully
return r;
}
}Pay attention to the comment in IdempotentGateway: the result is stored only after the gateway returns successfully. This decides what the decorator actually means, as a later section shows. TimingGateway is similar to LoggingGateway, just recording System.nanoTime() before and after the call. Here is what to notice when assembling the stack:
PaymentGateway a = new LoggingGateway(new RetryGateway(new IdempotentGateway(fakeA), 3));
a.charge("ORD1001", 250_000);
a.charge("ORD1001", 250_000); // second call with the same order ID
System.out.println("Calls that reached the real gateway: " + fakeA.calls.get());=== Order A: Logging(Retry(Idempotent(Fake))) ===
[log] charge ORD1001 250000
[retry] attempt 1 failed
[retry] attempt 2 failed
[log] -> PaymentResult[orderId=ORD1001, ok=true, message=OK 250000]
[log] charge ORD1001 250000
[idem] returning stored result for ORD1001
[log] -> PaymentResult[orderId=ORD1001, ok=true, message=OK 250000]
Calls that reached the real gateway: 3In order A, I deliberately put IdempotentGateway innermost, right next to the real gateway, to clearly illustrate storing a successful result; this is not yet the recommended order for the Decorator pattern in a real service, and the ordering rules below cover a different arrangement.
The first call goes through RetryGateway, fails twice and succeeds on the third attempt, for three calls to the real gateway in total. The second call with the same order ID stops right at IdempotentGateway and no longer touches the gateway, so the counter stays at 3. It is important to read correctly what this output proves: the decorator only blocks a duplicate request for the same order ID after the first one has succeeded. In the first call, the two initial timeouts leave nothing in the Map, so the retry still hits the real gateway three times. Notice that LoggingGateway is outermost, so it sees only one call each time, even though retries happen inside. Changing the order of the wrappers in the Decorator pattern changes the behavior:
PaymentGateway b = new RetryGateway(new LoggingGateway(fakeB), 3);
b.charge("ORD1002", 90_000);=== Order B: Retry(Logging(Fake)) — logging sits inside retry ===
[log] charge ORD1002 90000
[log] !! gateway timeout (attempt 1)
[retry] attempt 1 failed
[log] charge ORD1002 90000
[log] !! gateway timeout (attempt 2)
[retry] attempt 2 failed
[log] charge ORD1002 90000
[log] -> PaymentResult[orderId=ORD1002, ok=true, message=OK 90000]In order B, logging sits inside retry, so it records every attempt, which is useful when you need to know how flaky the gateway is. In order A, logging records only the final result, which is more compact for auditing. There is no absolutely correct order when assembling a Decorator pattern stack, but you must choose deliberately. A pragmatic rule for ordering: put logging and timing outermost to observe exactly what the user sees; put request-deduplication decorators (such as IdempotentGateway) outside retry so that a duplicate request for the same order ID does not trigger the whole retry loop again. But this Decorator pattern ordering does not protect you from a double charge when retrying. That needs a different mechanism, as the next section shows.
Ambiguous timeouts: you need an idempotency key on the payment gateway side
The truly dangerous situation is when the gateway has processed and charged the money, and only then times out before replying. The client sees only an error and does not know whether the money was charged. If the retry resends an identical request, the gateway has no way to recognize it as an old one. I simulated exactly that situation with a fake gateway that throws a timeout on the first call after recording the charge, and compared two kinds of gateways: one that treats every call as new, and one that accepts an idempotency key (the same key returns the previous result, like the Idempotency-Key header offered by major payment gateways such as Stripe, see the Stripe documentation). Both run through the same IdempotentGateway(RetryGateway(gateway)) stack. The fake gateway is defined as follows (it also lives in code/s6-07/src/ServiceDecoratorDemo.java):
static class ChargeThenTimeoutGateway implements PaymentGateway {
final boolean dedupByKey;
final Map<String, PaymentResult> byKey = new HashMap<>();
long totalDebited = 0;
int calls = 0;
ChargeThenTimeoutGateway(boolean dedupByKey) { this.dedupByKey = dedupByKey; }
public PaymentResult charge(String idempotencyKey, long amountVnd) {
calls++;
PaymentResult r;
if (dedupByKey && byKey.containsKey(idempotencyKey)) {
r = byKey.get(idempotencyKey); // same key: do not charge again
} else {
totalDebited += amountVnd; // the money has been charged
r = new PaymentResult(idempotencyKey, true, "OK " + amountVnd);
if (dedupByKey) byKey.put(idempotencyKey, r);
}
if (calls == 1) throw new IllegalStateException("gateway timeout after processing (attempt 1)");
return r;
}
}The test loop:
for (boolean dedup : new boolean[] { false, true }) {
ChargeThenTimeoutGateway gw = new ChargeThenTimeoutGateway(dedup);
PaymentGateway g = new IdempotentGateway(new RetryGateway(gw, 3));
PaymentResult r = g.charge("ORD1004", 500_000); // the order ID plays the role of the idempotency key
System.out.println("gateway " + (dedup ? "WITH " : "WITHOUT") + " dedup by key: gateway calls=" + gw.calls
+ ", total charged=" + gw.totalDebited + " VND, result=" + r.message());
}=== Timeout AFTER the gateway has charged: Idempotent(Retry(gateway)) ===
[retry] attempt 1 failed
gateway WITHOUT dedup by key: gateway calls=2, total charged=1000000 VND, result=OK 500000
[retry] attempt 1 failed
gateway WITH dedup by key: gateway calls=2, total charged=500000 VND, result=OK 500000Even with IdempotentGateway in the stack, the gateway without dedup still charges 1,000,000 VND for a 500,000 VND order, because the client-side decorator had not stored anything yet when the first call timed out. Only when the gateway accepts and honors a key sent by the client is the retry safe: the total charged is 500,000 VND. The conclusion for designing the Decorator pattern in payments: a retry is safe only when the write operation is idempotent on the server side, meaning the key must be sent to the payment gateway (generated once per order and reused on every retry). A client-side decorator such as IdempotentGateway only reduces duplicate requests after a success. This is a one-way simulation for illustration and does not replace reading the documentation of the payment gateway you use.
Note that IdempotentGateway in the example uses a HashMap, which is not thread-safe and has no size limit. This is illustrative code; a real service needs to store idempotency keys in durable storage (Redis or a database) with an expiry, and handle the case where two requests with the same order ID arrive at the same time.
Decorators in the JDK beyond java.io: Collections
The wrapper methods of Collections are also examples of the Decorator pattern. According to the Collections Javadoc, unmodifiableList returns an "unmodifiable view", synchronizedList returns a thread-safe list "backed by the specified list", and checkedList returns a "dynamically typesafe view". Each method keeps the List interface and adds one concern: read-only access, synchronization, runtime type checking. Try it:
List<String> src = new ArrayList<>(List.of("ORD1", "ORD2"));
List<String> view = Collections.unmodifiableList(src);
src.add("ORD3");
System.out.println("view after modifying src: " + view);
try { view.add("ORD4"); } catch (UnsupportedOperationException e) { ... }=== Decorator in the JDK: Collections.unmodifiableList ===
view after modifying src: [ORD1, ORD2, ORD3]
view.add -> UnsupportedOperationException
view class: java.util.Collections$UnmodifiableRandomAccessListThe view reflects changes to the original list, a familiar trap: unmodifiableList is not an immutable copy, it only blocks modification through the view itself. For true immutability, use List.copyOf. For synchronizedList, the Javadoc stresses that when you iterate with an Iterator, Spliterator or Stream, you must manually synchronized on the returned list, otherwise you may get non-deterministic behavior. This Decorator pattern style of wrapper synchronizes each method individually, but it does not turn a multi-step sequence of operations into one atomic unit.
Decorator pattern, Proxy and Adapter: how to tell them apart
These three patterns, the Decorator pattern included, all wrap an object, so they are easy to confuse. The table below summarizes how refactoring.guru tells them apart:
| Pattern | Interface compared with the wrapped object | Main intent | Who controls the lifecycle of the inner object |
|---|---|---|---|
| Decorator | Unchanged (or extended) | Add behavior, stackable | The client assembles the stack |
| Proxy | Unchanged | Control access (lazy, permissions, cache, remote) | Usually the proxy itself |
| Adapter | Converted to a different interface | Make two mismatched interfaces work together | The client |
Structurally, the Decorator pattern and Proxy are almost identical; the difference lies in intent. The next article in the series goes deeper into Proxy, including how the JDK and Spring generate proxies at runtime. For Adapter, the pattern that differs from the Decorator pattern by changing the interface, reread Adapter pattern: connecting legacy APIs to new interfaces. Another useful comparison is with Strategy: the Decorator pattern changes the outer "shell" of an object, while Strategy swaps the core algorithm inside.
When not to use the Decorator pattern
The Decorator pattern trades flexibility for complexity at the assembly point. The widely recognized drawbacks:
- Hard to remove a layer in the middle of the stack. To drop retry but keep logging, you have to rebuild the whole stack.
- Behavior depends on order. As shown above, changing the order changes the semantics; it is hard to write a decorator that is completely independent of its position.
- Many small, similar classes. Each decorator is usually only a few lines, but a whole system can have dozens of them, and stack traces get deep when debugging.
- An interface that is too wide. If the interface has dozens of methods, the Base Decorator must forward each one; forgetting one method is a silent bug (the fix is to use
defaultmethods or a dynamic proxy, see the next article). - Type checks get hidden. Code that uses
instanceofor casts to a concrete class breaks when the object is wrapped.
If you have only one fixed extra feature, using the Decorator pattern is overkill; a subclass or an extra method may be simpler. If the feature cuts across many classes (logging, transactions, security), Spring AOP is usually a better fit than hand-writing dozens of decorators.
Common mistakes with the Decorator pattern
- Forgetting to close the outermost stream. Closing the outermost decorator propagates
close()down to the inner stream; use try-with-resources on the outermost variable. If you keep a separate reference to the inner stream, take care not to close it twice. - Not overriding all the read/write methods. With
FilterInputStream, overridingread()but forgettingread(byte[], int, int)makes your logic get skipped when the layer above reads in blocks. - Sharing one stateful decorator between threads. The
IdempotentGatewayabove holds aHashMap; in a multithreaded environment it must be replaced with a thread-safe structure. - Confusing a view with a copy.
Collections.unmodifiableListstill changes along with the original list. - Retrying a charge operation when the gateway does not support idempotency keys. If the timeout happens after the gateway has already charged, the retry charges a second time (see the output above). You must send an idempotency key down to the gateway, generated once per order and reused on every attempt; a decorator that stores results on the client side is not enough to prevent this bug.
Decorator pattern checklist
- Have a small, stable interface that every component of the Decorator pattern implements.
- Each decorator does one thing and forwards the rest to the inner object.
- Write a Base Decorator to avoid repeating forwarding code in the Decorator pattern.
- Record the assembly order in one place (a factory or configuration), along with the reason for the chosen order.
- Write tests for each decorator on its own, and at least one test for the full stack as it runs in production.
- For application-wide cross-cutting features, consider AOP instead of hand-writing the Decorator pattern for each class.
Next article
The Decorator pattern and Proxy have the same shape but different intent. The next article in the series: Proxy pattern: JDK dynamic proxies, CGLIB and Spring AOP, where we generate proxies at runtime with java.lang.reflect.Proxy and see how Spring uses them for @Transactional.



