> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Implement the Decorator Pattern in Java (Java 21)
- URL: https://www.ggorantala.dev/how-to-implement-decorator-pattern-java/
- Published: 2026-05-31T04:26:00.000Z
- Updated: 2026-10-31T06:35:20.000Z
- Description: Build a currency-rates client and bolt on logging, retries, caching and validation with the decorator pattern in Java 21. Full code, order traps, 25 checks.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, core-java, java-tutorials, design-patterns

## 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:

```java
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:

```log
[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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) first; the cache uses one.

## Set up the rates project

Make the folders and keep them as shown. The package is `com.example.rates`.

```bash
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:

```log
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.

```bash
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`

```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`

```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`

```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.

```bash
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`

```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:

```log
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:

```log
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`

```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`

```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`

```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`

```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:

```log
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](https://ggorantala.dev/how-to-build-in-memory-cache-ttl-java/?ref=ggorantala.dev) 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`

```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.

```java
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`

```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

```bash
javac -d out $(find src/main -name '*.java')
java -cp out com.example.rates.Demo

```

```log
[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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) is on the list.

`src/test/java/com/example/rates/MutableClock.java`

```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`

```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; }
    }
}

```

```bash
javac -d out $(find src -name '*.java')
java -cp out com.example.rates.RateChecks

```

```log
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](https://ggorantala.dev/how-to-implement-strategy-pattern-java/?ref=ggorantala.dev) swaps one behaviour for another. A decorator keeps the behaviour and adds to it. The [observer pattern](https://ggorantala.dev/how-to-implement-observer-pattern-java/?ref=ggorantala.dev) 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](https://ggorantala.dev/how-to-implement-proxy-pattern-java/?ref=ggorantala.dev) 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:

```log
  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](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev) 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](https://ggorantala.dev/how-to-use-java-httpclient/?ref=ggorantala.dev) 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](https://ggorantala.dev/how-to-implement-retry-exponential-backoff-java/?ref=ggorantala.dev), 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.

## References

- [java.io package (Java 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/io/package-summary.html?ref=ggorantala.dev)
- [ConcurrentHashMap.compute (Java 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html?ref=ggorantala.dev)
- [java.time.Clock (Java 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/Clock.html?ref=ggorantala.dev)
- [Collections.unmodifiableList (Java 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Collections.html?ref=ggorantala.dev)