The idea: one interface, many wrappers
The client holds a UserRepository and calls repo.get(42). It does not know what object is behind that interface. It could be the real UserService, which queries Postgres. It could also be a wrapper: an object that implements the same interface, holds a reference to another UserRepository, and passes the call on. Wrappers can wrap wrappers, so you get a chain.
On the canvas the chain is new LoggingDecorator(new AuthProxy(new RetryDecorator(new CachingProxy(UserService)))). The call travels inward on the upper lane, the result travels outward on the lower lane. The client code is the same with no wrappers (Demo 1: plain call, no wrappers), with one (Demo 2) or with four.
Decorator: add behaviour around the call
A decorator adds something before and after the call, and always passes the call on. LoggingDecorator writes a log line on the way in and another on the way out (Demo 2: add the logging decorator). RetryDecorator catches an exception on the way out and calls inward again, up to maxAttempts = 3 (Demo 4: retry on failure). The client never sees the first timeout.
Each decorator does one job, and you combine them by stacking. That is the alternative to subclasses like LoggingRetryingUserService for every mix. Java's I/O streams are the classic example: new BufferedReader(new InputStreamReader(new FileInputStream(f))).
Proxy: control access to the real object
A proxy stands in for the real object and decides whether, when and how the call reaches it. It may answer by itself. The page shows three kinds:
- Protection proxy:
AuthProxychecks the caller. A guest getsAccessDeniedExceptionand the call never goes further in (Demo 5: access denied). - Caching proxy:
CachingProxyreturns a stored result on a hit. The real object and Postgres are never called (Demo 3: cache hit, the proxy answers). - Virtual proxy:
UserServiceis expensive to build (it opens a database pool), soCachingProxycreates it only on the first miss. This is lazy initialization. In Demo 5 it is never created at all.
A remote proxy is a fourth kind: a local stub with the same interface that sends the call over the network (gRPC and Java RMI stubs work this way).
Adapter: same shape, another interface
An adapter is built the same way: it holds a reference to another object and passes each call on. But the wrapped object, the adaptee, does not have the client's interface. Here LegacyUserApi is an old class with Map fetchUser(String userId). LegacyUserAdapter implements UserRepository turns get(42) into fetchUser("42"), the returned {user_name: "Ann"} into User(42, Ann), and the legacy LegacyApiError into the TimeoutException callers expect (Demo 6: adapter, same shape, other interface; pick the adapter wrappers and call get(7) for the error).
class LegacyUserAdapter implements UserRepository {
private final LegacyUserApi api; // the adaptee
public User get(int id) {
Map<String, String> m = api.fetchUser(String.valueOf(id));
return new User(id, m.get("user_name"));
}
}
It adds nothing and guards nothing: no log, no retry, no cache, and a guest gets the data, because there is no AuthProxy. Adapters let you plug in a third-party library, an old module or a remote API without changing the client; java.io.InputStreamReader adapts a byte stream to a Reader.
Decorator vs proxy vs adapter
In code they look the same: a class that implements the client's interface, a reference to the wrapped object, a method that calls it. The difference is the intent.
| Decorator | Proxy | Adapter | |
|---|---|---|---|
| Purpose | adds behaviour | controls access | converts one interface into another |
| Wrapped object's interface | the same | the same | a different one |
| Calls the wrapped object? | always (maybe more than once, like retry) | only when it decides to (may deny or answer from a cache) | always, once, with translated arguments |
| Who creates the wrapped object? | usually the client passes it in | often the proxy itself (lazy, remote) | passed in (an existing class) |
| Stacking | meant to be stacked in any combination | usually one per real object | one, at the boundary to the other code |
| On the canvas | LoggingDecorator, RetryDecorator | AuthProxy, CachingProxy | LegacyUserAdapter |
The order of the wrappers matters
Each wrapper only sees what the wrappers outside it let through. AuthProxy sits outside the cache, so a guest is refused before any cached data is read. RetryDecorator sits outside the cache, so each attempt checks the cache again. LoggingDecorator is outermost, so it logs every call, including denied ones. Swap two wrappers and the behaviour changes, even though every class is the same. Spring's AOP proxies and Resilience4j's decorators have an explicit order for this reason.
See also Circuit Breaker and Bulkhead, where retry, circuit breaker and bulkhead are decorators around one call, and Cache Strategies, where the cache in front of the database is a caching proxy.
Wrapping is composition; see Inheritance vs Composition for the subclassing alternative and its class explosion.
What the page leaves out
Backoff between retries and which exceptions are worth retrying; cache expiry (TTL) and invalidation; thread safety of the lazy creation; dynamic proxies that are generated at run time (java.lang.reflect.Proxy, Spring AOP, Python decorators as functions); remote proxies and their network failures; the cost of each extra layer in a deep chain; and two-way or class (inheritance-based) adapters, and the Facade pattern, which gives a simpler interface to a whole subsystem rather than one object.