On This Page
Start with five lines that do a lot
This is the call site you'll have by the end. One rates source that times out twice, wrapped four times, and the caller can't tell:
RateProvider rates = wrap(source,
retrying(3),
validating(),
caching(Duration.ofMinutes(5), Clock.systemUTC()),
logging("app", System.out::println));
And what it prints when you call it three times:
[app] USD/EUR -> 0.9200 (138 ms)
[app] USD/EUR -> 0.9200 (0 ms)
[app] USD/INR -> 83.2500 (40 ms)
upstream calls: 4
Look at the numbers. The first call took 138 ms because the source failed twice before it answered, and the retry wrapper hid that. The second call took 0 ms because the cache answered. The last line says four upstream calls: three for the first lookup, one for the second pair. Nothing in the source class knows about any of this.
That's the decorator pattern in Java: wrap an object in another object that has the same interface, add one behaviour, and pass everything else through. You can stack as many wrappers as you like, and the order you stack them in changes what the program does. Most tutorials show coffee with milk and sugar. I'll use a currency rates feed instead, because logging, retries and caching are the things you'll really wrap around a remote call at work, and they show the ordering traps that a coffee shop never will.
Versions I used
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| Build | plain javac, no Maven or Gradle |
javac -version |
| IDE | IntelliJ IDEA or Eclipse, either one | Help → About |
You need Java 21 or anything from 17 up (the code uses record and nothing newer). I wrote the whole thing with the command line, so the steps are the same on every machine. IDE notes are below, and I didn't open either IDE for this one, so those menu paths come from the vendors' docs rather than my run.
You should already be comfortable with interfaces and anonymous classes. If records are new, read how to use Java records first; the cache uses one.
Set up the rates project
Make the folders and keep them as shown. The package is com.example.rates.
mkdir -p rates/src/main/java/com/example/rates rates/src/test/java/com/example/rates
cd rates
When you're done the tree looks like this:
src/main/java/com/example/rates/CachingRateProvider.java
src/main/java/com/example/rates/Demo.java
src/main/java/com/example/rates/FakeRateSource.java
src/main/java/com/example/rates/LoggingRateProvider.java
src/main/java/com/example/rates/RateException.java
src/main/java/com/example/rates/RateProvider.java
src/main/java/com/example/rates/RateProviderDecorator.java
src/main/java/com/example/rates/RateProviders.java
src/main/java/com/example/rates/RetryingRateProvider.java
src/main/java/com/example/rates/ValidatingRateProvider.java
src/test/java/com/example/rates/MutableClock.java
src/test/java/com/example/rates/RateChecks.java
From the command line (what I did): compile everything into out, then run a class by name.
javac -d out $(find src -name '*.java')
java -cp out com.example.rates.Demo
On Windows PowerShell, swap the $(find ...) part for (Get-ChildItem -Recurse src -Filter *.java).FullName. I didn't run Windows, so check that line.
In IntelliJ IDEA: File → New → Project, pick Java, choose JDK 21, and untick the sample-code box. Mark src/main/java as Sources Root and src/test/java as Test Sources Root (right-click the folder → Mark Directory as). Run Demo with the green gutter arrow.
In Eclipse: File → New → Java Project, set the execution environment to JavaSE-21, then add src/main/java and src/test/java as source folders in Build Path → Configure Build Path → Source. Run As → Java Application on Demo.
No Maven, no Gradle, no containers. Every class here is plain Java, which is the point: the pattern doesn't need a framework.
Build it, one wrapper at a time
I started with the interface and a fake source, then added wrappers in the order I'd need them. The first time I stacked them I got the order wrong; Step 6 shows how.
Step 1: Define what a rate provider is
The goal is a small interface that every layer, including the real source, will share. Decorators only work when the wrapper and the thing it wraps have the same type.
src/main/java/com/example/rates/RateProvider.java
package com.example.rates;
import java.math.BigDecimal;
public interface RateProvider {
/** Units of {@code to} you get for one unit of {@code from}. */
BigDecimal rate(String from, String to);
/** A label for log lines and debugging. */
String name();
}
src/main/java/com/example/rates/RateException.java
package com.example.rates;
public class RateException extends RuntimeException {
public RateException(String message) {
super(message);
}
public RateException(String message, Throwable cause) {
super(message, cause);
}
}
Why two methods? name() is there on purpose. In Step 2 you'll see what happens to every decorator the day an interface grows a method.
Checkpoint: nothing to run yet. javac -d out src/main/java/com/example/rates/*.java will fail until the next file exists, and that's fine.
Step 2: Write the thing being decorated
The goal is a stand-in for a remote rates API that is slow, counts how often it's called, and can fail on demand. That lets you watch each wrapper work.
src/main/java/com/example/rates/FakeRateSource.java
package com.example.rates;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/** Stands in for a remote rates API: slow, counts calls, and can fail on demand. */
public class FakeRateSource implements RateProvider {
private static final Map<String, String> TABLE = Map.of(
"USD/EUR", "0.9200",
"EUR/USD", "1.0870",
"USD/INR", "83.2500",
"GBP/USD", "1.2700");
private final AtomicInteger calls = new AtomicInteger();
private final int failFirst;
private final long latencyMillis;
public FakeRateSource(int failFirst, long latencyMillis) {
this.failFirst = failFirst;
this.latencyMillis = latencyMillis;
}
@Override
public BigDecimal rate(String from, String to) {
int n = calls.incrementAndGet();
sleep();
if (n <= failFirst) {
throw new RateException("upstream timeout (call " + n + ")");
}
String value = TABLE.get(from + "/" + to);
if (value == null) {
throw new RateException("no such pair: " + from + "/" + to);
}
return new BigDecimal(value);
}
@Override
public String name() {
return "fake-source";
}
public int calls() {
return calls.get();
}
private void sleep() {
if (latencyMillis <= 0) return;
try {
Thread.sleep(latencyMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RateException("interrupted", e);
}
}
}
failFirst makes the first N calls throw. latencyMillis makes each call sleep. The counter is an AtomicInteger because Step 5 calls this from sixteen threads.
Checkpoint: compile.
javac -d out $(find src/main -name '*.java')
No output means it compiled. (If you get class Demo is public, you pasted a file into the wrong folder name; the file name has to match the class.)
Step 3: Write one base class that forwards everything
This is the step most tutorials skip and the one I'd keep. The goal is to avoid writing the same forwarding code in every decorator.
src/main/java/com/example/rates/RateProviderDecorator.java
package com.example.rates;
import java.math.BigDecimal;
import java.util.Objects;
/** Base class: forwards everything. A real decorator overrides only what it changes. */
public abstract class RateProviderDecorator implements RateProvider {
protected final RateProvider delegate;
protected RateProviderDecorator(RateProvider delegate) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
}
@Override
public BigDecimal rate(String from, String to) {
return delegate.rate(from, to);
}
@Override
public String name() {
return delegate.name();
}
}
A decorator that extends this overrides only what it changes. Now see why it matters. I forced it with an implementer written before name() existed:
Bad.java:5: error: <anonymous Bad$1> is not abstract and does not override abstract method name() in RateProvider
With ten hand-written decorators you fix ten files. With the base class you fix one. And if you forget to forward a method in a hand-written one, nothing complains at all. Mine returned the wrong answer quietly:
Forgetful name(): unknown
That's the real cost of the pattern: every wrapper has to forward every method, and the compiler can't tell you that you did it correctly, only that you did it at all.
Checkpoint: the same javac command still compiles cleanly.
Step 4: Add logging
The goal is the simplest useful wrapper, so you can see the shape of every other one.
src/main/java/com/example/rates/LoggingRateProvider.java
package com.example.rates;
import java.math.BigDecimal;
import java.util.function.Consumer;
public class LoggingRateProvider extends RateProviderDecorator {
private final String label;
private final Consumer<String> sink;
public LoggingRateProvider(RateProvider delegate, String label, Consumer<String> sink) {
super(delegate);
this.label = label;
this.sink = sink;
}
@Override
public BigDecimal rate(String from, String to) {
long start = System.nanoTime();
try {
BigDecimal result = delegate.rate(from, to);
sink.accept("[" + label + "] " + from + "/" + to + " -> " + result
+ " (" + elapsedMillis(start) + " ms)");
return result;
} catch (RateException e) {
sink.accept("[" + label + "] " + from + "/" + to + " FAILED: " + e.getMessage()
+ " (" + elapsedMillis(start) + " ms)");
throw e;
}
}
private static long elapsedMillis(long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000;
}
}
It takes a Consumer<String> instead of calling System.out directly, so a test can capture the lines. It times the call, logs, and passes the result (or the exception) along unchanged. A decorator that changes the exception type or swallows it is a different animal; this one is invisible to the caller, and that's what you want from logging.
Step 5: Add retries, a cache and a validator
Three more wrappers. Each is about twenty lines and does one thing.
src/main/java/com/example/rates/RetryingRateProvider.java
package com.example.rates;
import java.math.BigDecimal;
public class RetryingRateProvider extends RateProviderDecorator {
private final int maxAttempts;
public RetryingRateProvider(RateProvider delegate, int maxAttempts) {
super(delegate);
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be >= 1");
}
this.maxAttempts = maxAttempts;
}
@Override
public BigDecimal rate(String from, String to) {
RateException last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return delegate.rate(from, to);
} catch (RateException e) {
last = e;
}
}
throw new RateException("gave up after " + maxAttempts + " attempts: " + last.getMessage(), last);
}
}
src/main/java/com/example/rates/CachingRateProvider.java
package com.example.rates;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
public class CachingRateProvider extends RateProviderDecorator {
private record Entry(BigDecimal value, Instant expiresAt) {}
private final Duration ttl;
private final Clock clock;
private final ConcurrentHashMap<String, Entry> cache = new ConcurrentHashMap<>();
public CachingRateProvider(RateProvider delegate, Duration ttl, Clock clock) {
super(delegate);
this.ttl = ttl;
this.clock = clock;
}
@Override
public BigDecimal rate(String from, String to) {
// compute() holds a lock on this key while it runs, so concurrent callers
// for the same pair wait for one upstream call instead of making their own.
return cache.compute(from + "/" + to, (key, existing) -> {
Instant now = clock.instant();
if (existing != null && now.isBefore(existing.expiresAt())) {
return existing;
}
return new Entry(delegate.rate(from, to), now.plus(ttl));
}).value();
}
}
src/main/java/com/example/rates/ValidatingRateProvider.java
package com.example.rates;
import java.math.BigDecimal;
/** Refuses to pass on a rate that is zero or negative. */
public class ValidatingRateProvider extends RateProviderDecorator {
public ValidatingRateProvider(RateProvider delegate) {
super(delegate);
}
@Override
public BigDecimal rate(String from, String to) {
BigDecimal value = delegate.rate(from, to);
if (value == null || value.signum() <= 0) {
throw new RateException("bad rate for " + from + "/" + to + ": " + value);
}
return value;
}
}
Two lines in the cache deserve a second look.
Clock is passed in, so a test can move time by hand. I'd never build a TTL cache on Instant.now() directly; you end up sleeping in tests.
cache.compute(...) is deliberate. My first version did get, then delegate.rate, then put, which looks identical and isn't. I ran both with sixteen threads asking for the same pair against a source that takes 80 ms:
naive cache, 16 threads, upstream calls: 16
Sixteen callers, sixteen upstream calls, and the cache saved nothing. compute locks that key while the loader runs, so the other fifteen wait and then read the stored value. With compute, the same sixteen-thread check in the section below shows one call. The trade-off is that unrelated keys sharing a bucket can wait on each other too. For a rates feed with a few hundred pairs I'd accept that; for something bigger, look at Caffeine (an in-memory cache with TTL tutorial is planned).
Two more details. A failure isn't cached, because the exception escapes compute and no entry gets stored. And the validator throws on zero or negative rates, which a real feed does return on a bad day.
Checkpoint: compile again. No output is good.
Step 6: Stack them, and watch the order matter
The goal is a tidy way to build a stack without nested new calls that read inside out.
src/main/java/com/example/rates/RateProviders.java
package com.example.rates;
import java.time.Clock;
import java.time.Duration;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
/** Reads left to right: the first wrapper is closest to the source. */
public final class RateProviders {
private RateProviders() {}
@SafeVarargs
public static RateProvider wrap(RateProvider source, UnaryOperator<RateProvider>... wrappers) {
RateProvider current = source;
for (UnaryOperator<RateProvider> wrapper : wrappers) {
current = wrapper.apply(current);
}
return current;
}
public static UnaryOperator<RateProvider> retrying(int attempts) {
return p -> new RetryingRateProvider(p, attempts);
}
public static UnaryOperator<RateProvider> caching(Duration ttl, Clock clock) {
return p -> new CachingRateProvider(p, ttl, clock);
}
public static UnaryOperator<RateProvider> validating() {
return ValidatingRateProvider::new;
}
public static UnaryOperator<RateProvider> logging(String label, Consumer<String> sink) {
return p -> new LoggingRateProvider(p, label, sink);
}
}
wrap(source, a, b, c) applies a first, so a sits closest to the source and c is the outermost layer. That's the reverse of how nested constructors read, and the one thing here that I'd tell a colleague twice.
Now the traps. Take two stacks that differ only in where the logger sits.
wrap(source, caching(ttl, clock), logging("o", outer::add)); // logger outside the cache
wrap(source, logging("i", inner::add), caching(ttl, clock)); // logger inside the cache
Call each three times for the same pair. The first logger writes three lines (every request the app made). The second writes one (only the request that reached the source). Both are useful, and they answer different questions: "what did my code ask for?" versus "what did I pay the upstream for?".
The second trap is nastier. Put the validator outside the cache and a bad rate gets stored for the whole TTL, because the cache saw a value and kept it. Put it inside, and bad values never reach the cache. Same two wrappers, opposite behaviour. My rule: validators and retries go next to the source; caches go outside them; logging goes wherever your question needs it.
Checkpoint: after Step 7 you'll run the checks and see both of these as assertions.
Step 7: Write the demo
src/main/java/com/example/rates/Demo.java
package com.example.rates;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import static com.example.rates.RateProviders.*;
public class Demo {
public static void main(String[] args) {
FakeRateSource source = new FakeRateSource(2, 40); // first two calls time out
RateProvider rates = wrap(source,
retrying(3),
validating(),
caching(Duration.ofMinutes(5), Clock.systemUTC()),
logging("app", System.out::println));
rates.rate("USD", "EUR");
rates.rate("USD", "EUR");
rates.rate("USD", "INR");
System.out.println("upstream calls: " + source.calls());
}
}
Run it
javac -d out $(find src/main -name '*.java')
java -cp out com.example.rates.Demo
[app] USD/EUR -> 0.9200 (138 ms)
[app] USD/EUR -> 0.9200 (0 ms)
[app] USD/INR -> 83.2500 (40 ms)
upstream calls: 4
Your millisecond figures will differ. The shape won't: a slow first line, a near-zero second, and upstream calls: 4. If your second line isn't near zero, the cache isn't in the stack.
Prove it with checks
I couldn't reach Maven Central from the machine I built this on, so there's no JUnit here. The checks below are plain Java with a check(name, boolean) helper and exit code 1 on failure. They'll port to JUnit 5 and AssertJ line for line; JUnit 5 tutorial is on the list.
src/test/java/com/example/rates/MutableClock.java
package com.example.rates;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
/** A clock the test moves by hand. */
public class MutableClock extends Clock {
private Instant now = Instant.parse("2026-10-25T09:00:00Z");
public void advance(Duration d) {
now = now.plus(d);
}
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
}
src/test/java/com/example/rates/RateChecks.java
package com.example.rates;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import static com.example.rates.RateProviders.*;
public class RateChecks {
static int passed, failed;
static void check(String name, boolean ok) {
if (ok) { passed++; System.out.println("PASS " + name); }
else { failed++; System.out.println("FAIL " + name); }
}
static boolean throwsRate(Runnable r, String contains) {
try { r.run(); return false; }
catch (RateException e) { return e.getMessage().contains(contains); }
}
public static void main(String[] args) throws Exception {
// plain source
FakeRateSource plain = new FakeRateSource(0, 0);
check("source returns USD/EUR", plain.rate("USD", "EUR").compareTo(new BigDecimal("0.92")) == 0);
check("source rejects unknown pair", throwsRate(() -> plain.rate("USD", "XXX"), "no such pair"));
// base class forwards
RateProvider passthrough = new RateProviderDecorator(plain) {};
check("base decorator forwards rate", passthrough.rate("EUR", "USD").compareTo(new BigDecimal("1.087")) == 0);
check("base decorator forwards name", passthrough.name().equals("fake-source"));
check("null delegate rejected", throwsNpe());
// logging
List<String> log = new ArrayList<>();
RateProvider logged = wrap(new FakeRateSource(0, 0), logging("t", log::add));
logged.rate("USD", "EUR");
check("logging writes one line on success", log.size() == 1 && log.get(0).startsWith("[t] USD/EUR -> 0.9200"));
log.clear();
throwsRate(() -> logged.rate("USD", "XXX"), "no such pair");
check("logging records failure and rethrows", log.size() == 1 && log.get(0).contains("FAILED"));
// retry
FakeRateSource flaky = new FakeRateSource(2, 0);
RateProvider retried = wrap(flaky, retrying(3));
check("retry succeeds on third attempt", retried.rate("USD", "EUR").signum() > 0);
check("retry made exactly 3 upstream calls", flaky.calls() == 3);
FakeRateSource dead = new FakeRateSource(10, 0);
check("retry gives up after max attempts",
throwsRate(() -> wrap(dead, retrying(3)).rate("USD", "EUR"), "gave up after 3"));
check("retry stopped at 3 calls", dead.calls() == 3);
check("retry(0) rejected", throwsIae());
// cache + ttl
MutableClock clock = new MutableClock();
FakeRateSource counted = new FakeRateSource(0, 0);
RateProvider cached = wrap(counted, caching(Duration.ofMinutes(5), clock));
cached.rate("USD", "EUR"); cached.rate("USD", "EUR");
check("second call served from cache", counted.calls() == 1);
cached.rate("USD", "INR");
check("different pair is a different key", counted.calls() == 2);
clock.advance(Duration.ofMinutes(4));
cached.rate("USD", "EUR");
check("still fresh after 4 minutes", counted.calls() == 2);
clock.advance(Duration.ofMinutes(2));
cached.rate("USD", "EUR");
check("expired after 6 minutes, refetched", counted.calls() == 3);
// failures are not cached
FakeRateSource failOnce = new FakeRateSource(1, 0);
RateProvider cachedFail = wrap(failOnce, caching(Duration.ofMinutes(5), clock));
throwsRate(() -> cachedFail.rate("USD", "EUR"), "timeout");
check("failure is not cached", cachedFail.rate("USD", "EUR").signum() > 0 && failOnce.calls() == 2);
// validating
RateProvider zero = new RateProvider() {
public BigDecimal rate(String f, String t) { return BigDecimal.ZERO; }
public String name() { return "zero"; }
};
check("validating rejects zero rate", throwsRate(() -> wrap(zero, validating()).rate("A", "B"), "bad rate"));
// order: validating inside vs outside the cache
int[] upstreamA = {0}, upstreamB = {0};
RateProvider zeroA = counting(upstreamA), zeroB = counting(upstreamB);
RateProvider validateInside = wrap(zeroA, validating(), caching(Duration.ofMinutes(5), clock));
RateProvider validateOutside = wrap(zeroB, caching(Duration.ofMinutes(5), clock), validating());
for (int i = 0; i < 2; i++) {
throwsRate(() -> validateInside.rate("A", "B"), "bad rate");
throwsRate(() -> validateOutside.rate("A", "B"), "bad rate");
}
check("validating inside cache: bad value never stored (2 upstream calls)", upstreamA[0] == 2);
check("validating outside cache: bad value stored for the TTL (1 upstream call)", upstreamB[0] == 1);
// order: logging outside vs inside cache
List<String> outer = new ArrayList<>(), inner = new ArrayList<>();
RateProvider logOutside = wrap(new FakeRateSource(0, 0), caching(Duration.ofMinutes(5), clock), logging("o", outer::add));
RateProvider logInside = wrap(new FakeRateSource(0, 0), logging("i", inner::add), caching(Duration.ofMinutes(5), clock));
for (int i = 0; i < 3; i++) { logOutside.rate("USD", "EUR"); logInside.rate("USD", "EUR"); }
check("logging outside cache sees all 3 calls", outer.size() == 3);
check("logging inside cache sees only the miss", inner.size() == 1);
// identity trap
RateProvider stacked = wrap(new FakeRateSource(0, 0), validating(), logging("x", s -> {}));
check("instanceof the concrete source is false once wrapped", !(stacked instanceof FakeRateSource));
check("name() still reaches the source", stacked.name().equals("fake-source"));
// concurrency: 16 threads, same key, one upstream call
FakeRateSource slow = new FakeRateSource(0, 80);
RateProvider shared = wrap(slow, caching(Duration.ofMinutes(5), clock));
ExecutorService pool = Executors.newFixedThreadPool(16);
List<Future<BigDecimal>> fs = new ArrayList<>();
for (int i = 0; i < 16; i++) fs.add(pool.submit(() -> shared.rate("USD", "EUR")));
for (Future<BigDecimal> f : fs) f.get();
pool.shutdown();
check("16 concurrent callers cause 1 upstream call", slow.calls() == 1);
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) System.exit(1);
}
static RateProvider counting(int[] counter) {
return new RateProvider() {
public BigDecimal rate(String f, String t) { counter[0]++; return BigDecimal.ZERO; }
public String name() { return "zero"; }
};
}
static boolean throwsNpe() {
try { new RateProviderDecorator(null) {}; return false; }
catch (NullPointerException e) { return true; }
}
static boolean throwsIae() {
try { new RetryingRateProvider(new FakeRateSource(0, 0), 0); return false; }
catch (IllegalArgumentException e) { return true; }
}
}
javac -d out $(find src -name '*.java')
java -cp out com.example.rates.RateChecks
PASS source returns USD/EUR
PASS source rejects unknown pair
PASS base decorator forwards rate
PASS base decorator forwards name
PASS null delegate rejected
PASS logging writes one line on success
PASS logging records failure and rethrows
PASS retry succeeds on third attempt
PASS retry made exactly 3 upstream calls
PASS retry gives up after max attempts
PASS retry stopped at 3 calls
PASS retry(0) rejected
PASS second call served from cache
PASS different pair is a different key
PASS still fresh after 4 minutes
PASS expired after 6 minutes, refetched
PASS failure is not cached
PASS validating rejects zero rate
PASS validating inside cache: bad value never stored (2 upstream calls)
PASS validating outside cache: bad value stored for the TTL (1 upstream call)
PASS logging outside cache sees all 3 calls
PASS logging inside cache sees only the miss
PASS instanceof the concrete source is false once wrapped
PASS name() still reaches the source
PASS 16 concurrent callers cause 1 upstream call
25 passed, 0 failed
All 25 pass. The first time I ran an earlier draft, one check passed for the wrong reason: I'd written a "retry inside vs outside the cache" comparison where both stacks made two upstream calls, so it proved nothing. I replaced it with the validator comparison, where the numbers really differ (2 calls against 1). If a check can't fail, delete it.
Why wrapping works
A decorator is two things at once: it is a RateProvider (so callers accept it anywhere) and it has a RateProvider (so it can hand off). That "is-a plus has-a" is the whole mechanism. Inheritance can't give you this, because you'd need a subclass for every combination: CachedLoggedRetryingSource, LoggedCachedSource, and so on. With wrappers, four small classes give you every combination at run time.
You've used the pattern before without naming it. new BufferedReader(new InputStreamReader(new FileInputStream(f))) is three decorators around a file. Collections.unmodifiableList(list) wraps a list and forbids writes. That's also why the UnaryOperator helpers in Step 6 work: a wrapper is a function from one provider to another.
How does this differ from its neighbours? The strategy pattern swaps one behaviour for another. A decorator keeps the behaviour and adds to it. The observer pattern notifies other objects on the side; a decorator sits in the call path. A proxy has the same shape as a decorator but a different intent (controlling access rather than adding behaviour), and a proxy pattern tutorial is planned.
One more consequence, from the checks: stacked instanceof FakeRateSource is false once anything wraps it. Code that peeks at the concrete class breaks the moment you decorate. And a stack trace through four plain forwarders gets longer, one RateProviderDecorator.rate frame per layer. Here's four of them from my run:
at com.example.rates.FakeRateSource.rate(FakeRateSource.java:30)
at com.example.rates.RateProviderDecorator.rate(RateProviderDecorator.java:17)
at com.example.rates.RateProviderDecorator.rate(RateProviderDecorator.java:17)
at com.example.rates.RateProviderDecorator.rate(RateProviderDecorator.java:17)
at com.example.rates.RateProviderDecorator.rate(RateProviderDecorator.java:17)
at Exp.main(Exp.java:38)
That's harmless in a test and noisy in a production log, which is one reason to keep stacks shallow.
Errors I hit, and the fixes
| What I saw | Cause | Fix |
|---|---|---|
is not abstract and does not override abstract method name() in RateProvider |
An implementer of the interface lacks a method the interface now has | Extend RateProviderDecorator so only one class forwards |
constructor LoggingRateProvider ... cannot be applied to given types; required: RateProvider,String,Consumer<String> |
Called the constructor without the sink | Pass the sink, or use logging(label, sink) from RateProviders |
Forgetful name(): unknown, no error at all |
A hand-written decorator didn't forward a method | Use the base class; add a check that calls every interface method through the stack |
| 16 threads, 16 upstream calls | A get / put cache lets every thread miss at once |
Use ConcurrentHashMap.compute (or a real cache library) |
| Bad rate served for minutes | Validator placed outside the cache | Put validation between the source and the cache |
ClassCastException after adding a wrapper |
Caller casts to the concrete type | Program to RateProvider, never to FakeRateSource |
For stack-trace puzzles with deep call chains, reading Java stack traces walks through what each frame means.
Before you use this on a real feed
Decide the order on purpose and write it down next to the wrap(...) call. Everyone who reads that line will otherwise guess.
Keep each decorator to one job. If a wrapper starts needing two collaborators and a config object, it has become a service and should be one.
Swap FakeRateSource for a real HTTP client behind the same interface. The wrappers don't change, which is what you bought. Java 11+ ships a HttpClient that fits here (tutorial planned).
My retry loop retries immediately. A real one waits and backs off, and gives up on errors that will never succeed, like an unknown pair. That's retry with exponential backoff, also planned.
If you use Spring, you've got @Cacheable and @Retryable, which are decorators applied by proxies. Writing them by hand once, as you just did, makes their behaviour much less mysterious.
Version notes: nothing here needs more than Java 17 (record). On Java 8 replace the record Entry with a small static class.
Questions people ask about the decorator pattern
What is the decorator pattern in Java?
It's a way to add behaviour to an object by wrapping it in another object with the same interface. The wrapper forwards calls and adds something before or after. You can stack wrappers at run time, which subclassing can't do.
What's the difference between decorator and inheritance?
Inheritance fixes the extra behaviour when you write the class. A decorator picks it when you build the object, so you can mix and match without a subclass for each combination.
Is the decorator pattern the same as a proxy?
They look the same in code. A decorator adds behaviour; a proxy controls access to the object (lazy loading, permissions, remote calls). Java's java.lang.reflect.Proxy can build either.
Where does the JDK use the decorator pattern?
The java.io classes, for one: BufferedInputStream, GZIPInputStream and DataInputStream all wrap another stream. Collections.unmodifiableList and Collections.synchronizedList do the same with collections.
What you did
- Defined one small interface and a stand-in source.
- Wrote a base class that forwards every method, so each decorator overrides one.
- Built logging, retry, cache and validation wrappers, each doing a single job.
- Stacked them with
wrap(...)and saw that order changes behaviour. - Checked 25 cases, including 16 threads and a hand-moved clock.
Four rules to keep: wrappers share the interface; forward everything; one job per wrapper; choose the order on purpose.