Decorator, Adapter, Facade, Proxy — the patterns that confused me longer than any others, because they all take the exact same photo. The behavioral notes argued that patterns are intents, not shapes; this post is where that idea earns its keep.
Here is the photo: an object holding a reference to another object, standing in front of it. Calls arrive at the wrapper; the wrapper does something; usually the inner object gets involved. Draw the class diagram for Decorator, Adapter, Facade, or Proxy and you get that picture four times. Add Chain of Responsibility — technically a behavioral pattern, but it lives the same wrapping life, so it belongs in this post — and you have five patterns that look identical side by side.
What tells them apart is one question: what does the wrapper change?
- Decorator — changes behavior: same interface, adds a layer of work, always passes the call through. You stack them.
- Chain of Responsibility — changes who handles it: the request walks a line of handlers until one of them takes it or rejects it.
- Adapter — changes the language: a foreign interface gets translated into the one your code already speaks.
- Facade — changes the number of doors: many messy objects get one simple front entrance.
- Proxy — changes access: same interface, but the wrapper decides when and whether you reach the real thing.
Five intents, one photo. Same drill as last time: pain first, pattern second, and every one ends with when not to use it.
01 · Decorator — same shape outside, more behavior inside#
A phone in a phone case is still a phone. Same buttons, same charging port, same pocket — nothing that uses the phone has to know the case exists. But drop it and the case does its job. And cases layer: a grip ring sticks onto the case, a screen protector on the front, and it’s still a phone to everything and everyone that touches it.
Decorator is that move, in code: wrap an object in another object with the same interface, add your bit, pass the call through.
Here’s the pain it removes. Our checkout charges cards through a gateway:
public interface PaymentGateway {
PaymentResult charge(Order order);
}
public final class StripeGateway implements PaymentGateway {
private final Stripe stripe;
public StripeGateway(Stripe stripe) { this.stripe = stripe; }
@Override
public PaymentResult charge(Order order) {
return PaymentResult.from(stripe.charges().create(order.total(), order.cardToken()));
}
}
Now the asks start arriving. Ops wants every charge logged with its outcome. Then flaky-network season comes and we need retries. Then the metrics team wants timings. The first instinct is to edit StripeGateway — but logging, retrying, and timing have nothing to do with Stripe, and the PayPal gateway will need all three too. The second instinct is subclassing, and that road ends somewhere ugly:
public class LoggingStripeGateway extends StripeGateway { /* ... */ }
public class RetryingLoggingStripeGateway extends LoggingStripeGateway { /* ... */ }
public class RetryingLoggingPayPalGateway extends /* ...and here it collapses */
Underneath the class explosion: features like logging and retrying are welded to specific classes, when they’re really layers that should stack onto anything. Three features and two gateways shouldn’t cost six classes — and with inheritance, every new combination is a new class.
Decorator’s whole move: wrap the object in another object with the same interface — the wrapper adds its one bit of behavior and forwards the call, so layers stack in any order and the caller never knows.
Wrappers that stack#
Each feature becomes its own thin wrapper, and every wrapper has the same two-part skeleton: one field holding the thing it wraps (wrapped — typed as the interface, so it might be the real Stripe gateway or another wrapper; this layer can’t tell and doesn’t care), and a method that does its one bit, then hands the call inward with wrapped.charge(order):
public final class LoggingGateway implements PaymentGateway {
private final PaymentGateway wrapped;
private final Logger log;
public LoggingGateway(PaymentGateway wrapped, Logger log) {
this.wrapped = wrapped;
this.log = log;
}
@Override
public PaymentResult charge(Order order) {
log.info("charging order {}", order.id());
PaymentResult result = wrapped.charge(order);
log.info("order {} -> {}", order.id(), result.status());
return result;
}
}
public final class RetryingGateway implements PaymentGateway {
private final PaymentGateway wrapped;
private final int attempts;
public RetryingGateway(PaymentGateway wrapped, int attempts) {
this.wrapped = wrapped;
this.attempts = attempts;
}
@Override
public PaymentResult charge(Order order) {
RuntimeException last = null;
for (int i = 0; i < attempts; i++) {
try {
return wrapped.charge(order);
} catch (RuntimeException e) {
last = e;
}
}
throw last;
}
}
And at startup, you stack the layers you want, in the order you want them:
PaymentGateway gateway =
new RetryingGateway(
new LoggingGateway(
new StripeGateway(stripe), log),
3);
Read the stack from the inside out, in plain words:
StripeGatewayknows one thing: how to talk to Stripe. It has no idea it’s being wrapped.LoggingGatewayknows one thing: log before, hand the call inward withwrapped.charge(order), log after. It doesn’t know whetherwrappedis Stripe, PayPal, or another wrapper.RetryingGatewayknows one thing: callwrapped, and call it again if it blows up. Because logging sits inside the retry here, every attempt gets logged — swap the two layers and you’d log once no matter how many attempts. The nesting order is behavior you choose at wiring time, not a class you write.- Nobody overrides anybody. At runtime these are three separate objects, each with its own
charge()— no inheritance, nosuper. The caller holds only the outermost one, and each layer’scharge()explicitly calls the next object it was handed at startup. It’s a relay race, not an override: each runner knows exactly who gets the baton next. - The caller just sees a
PaymentGateway. Three features, two gateways — five small classes instead of an inheritance explosion, and any combination is onenewexpression.
Two pictures make this whole section, and they answer different questions. First the structure — who is-a what, who holds what:
classDiagram
class PaymentGateway {
<<interface>>
+charge(Order) PaymentResult
}
class StripeGateway {
+charge(Order) PaymentResult
}
namespace wrappers {
class LoggingGateway {
-wrapped: PaymentGateway
+charge(Order) PaymentResult
}
class RetryingGateway {
-wrapped: PaymentGateway
-attempts: int
+charge(Order) PaymentResult
}
}
class Client
note for Client "gateway = new RetryingGateway(new LoggingGateway(<b>new StripeGateway(stripe)</b>, log), 3);
gateway.charge(order)"
Client --> PaymentGateway : uses
PaymentGateway <|.. StripeGateway : implements
PaymentGateway <|.. LoggingGateway : implements
PaymentGateway <|.. RetryingGateway : implements
LoggingGateway o-- PaymentGateway : wrapped
RetryingGateway o-- PaymentGateway : wrapped And second, the behavior — here’s one failing charge through that stack, in slow motion:
sequenceDiagram participant Caller participant Retry as RetryingGateway participant Logging as LoggingGateway participant Stripe as StripeGateway Caller->>+Retry: charge(order) Note right of Retry: attempt 1 Retry->>+Logging: wrapped.charge(order) Note right of Logging: logs: charging order 42 Logging->>+Stripe: wrapped.charge(order) Stripe-->>-Logging: 💥 timeout, exception! Logging-->>-Retry: exception flies back out Note right of Retry: caught, attempt 2 Retry->>+Logging: wrapped.charge(order) Note right of Logging: logs: charging order 42, again! Logging->>+Stripe: wrapped.charge(order) Stripe-->>-Logging: ✓ PaymentResult Logging-->>-Retry: result travels back out Retry-->>-Caller: success
The caller saw one call succeed. The log shows two attempts — because the logging layer sits inside the retry loop. Nothing was overridden, nothing was merged: three charge() methods ran nested inside each other, each explicitly calling the one it holds.
Where this pays off: many providers, one set of layers#
The wrappers are tied only to the interface, so nothing about LoggingGateway says “Stripe.” Add more payment providers and the same two wrapper classes dress all of them — and this map is literally the Strategy lookup table from the first post, with Decorator layering features onto each entry:
Map<PaymentMethod, PaymentGateway> gateways = Map.of(
PaymentMethod.CARD, new RetryingGateway(new LoggingGateway(new StripeGateway(stripe), log), 3),
PaymentMethod.PAYPAL, new RetryingGateway(new LoggingGateway(new PayPalAdapter(paypal), log), 3),
PaymentMethod.WALLET, new LoggingGateway(new WalletGateway(ledger), log) // internal call — no retry needed
);
Count what just happened: logging and retrying were each written once and reused across every provider — with subclassing this map would have cost LoggingStripeGateway, RetryingLoggingPayPalGateway, and friends. And each provider gets its own stack: the wallet is an internal ledger call, so its stack simply skips the retry layer. Features + providers, not features × providers — combined freely at wiring time. Two patterns from these notes snapping together like bricks: Strategy picks which gateway; Decorator layers what every gateway needs.
The recipe, for the day you build one yourself
- Find the shape everyone must share. One interface holding the methods callers actually use — ours is
PaymentGatewaywithcharge(). If the real thing and the layers can’t share one interface, stop: this isn’t a decorator situation. - Keep the real worker as a plain class implementing it.
StripeGatewaydoes the actual job and never learns it will be wrapped — no special fields, no flags. - Write each feature as a layer class that does both things at once: implements the interface and holds one
wrappedfield typed as the interface. That double role is the entire pattern. - Inside each method: your one bit, then
wrapped.charge(order). Before the call, after it, or around it — where you put your bit is the layer’s behavior. Never skip the inward call; a layer that might not call through is a gatekeeper, and that’s Proxy or Chain territory. - Compose at wiring time, not in the classes. Nested
newexpressions, in the order you want, once, at startup — and hand the caller the outermost object as plain “aPaymentGateway.”
Stacked wrappers you already import#
new BufferedReader(new InputStreamReader(new FileInputStream(f)))— the textbook example that’s also real. Everyjava.iostream wrapper is a decorator: same read/write interface, one added ability per layer (buffering, character decoding, compression).Collections.unmodifiableList(list)— sameListinterface, one behavior change (writes now throw).- Resilience libraries. resilience4j’s retry, rate limiter, and circuit breaker all wrap your call in the same-shaped callable — a decorator stack in production clothing. The circuit breaker gets its own post in the distributed notes, and here’s the teaser: it’s a decorator wrapping a state machine, two patterns from these notes holding hands.
- React higher-order components and Python’s
@decoratorsyntax — the same idea applied to functions: take one in, return a wrapped one with the same signature.
When NOT to use it#
- The behavior belongs inside. If it’s core to what the object is — Stripe error mapping belongs in
StripeGateway— putting it in a wrapper just hides it. Decorate the cross-cutting stuff (the features every implementation needs: logging, retries, caching), not the essence. - Someone needs to reach through the layers. Wrappers hide what they wrap. Code that needs
StripeGateway-specific methods can’t get them from aPaymentGatewayfive layers deep — if callers keep casting to find the real object, the wrapping is fighting you. - Debugging through the onion. Ten layers deep, a stack trace reads like a hall of mirrors. Stack what earns its place; don’t decorate reflexively.
02 · Chain of Responsibility — the request walks the line until someone claims it#
Call customer support. The first person tries to help; if it’s beyond them, they escalate to level 2; billing issues go to billing; a manager takes the angry ones. Nobody at the front desk needs to solve everything — they need to solve it or pass it along. The caller just experiences “support.”
Chain of Responsibility is that line, in code — and you already know its modern name: middleware (the checks that run on every request before your actual handler).
Here’s the pain. Every checkout request must be authenticated, rate-limited, and validated before we place the order. Inline, that’s this — in every endpoint:
public Response checkout(Request request) {
if (!auth.validToken(request)) return Response.unauthorized();
if (rateLimiter.overLimit(request)) return Response.tooManyRequests();
if (!validator.validCart(request)) return Response.badRequest();
return orders.place(request);
}
Four endpoints later, the same three checks are copy-pasted four times, in slightly different orders, and the new fraud check has to be hand-added to each one — miss an endpoint and it ships unprotected. What went wrong: a sequence of “can I stop this request?” checks is baked into every handler, instead of existing once as a line the request walks.
Chain, boiled down: put the checks in a line; the request visits each one, and every check either stops the request right there or passes it along — the handler at the end only ever sees requests that survived.
Handlers in a line#
One shape for “a step that can stop a request,” and a pipeline that walks the line:
public interface CheckoutStep {
// return a Response to stop the request here, or null to let it continue
Response handle(Request request);
}
public final class Pipeline {
private final List<CheckoutStep> steps;
private final Function<Request, Response> handler;
public Pipeline(List<CheckoutStep> steps, Function<Request, Response> handler) {
this.steps = steps;
this.handler = handler;
}
public Response run(Request request) {
for (CheckoutStep step : steps) {
Response stop = step.handle(request);
if (stop != null) return stop; // a step claimed the request — done
}
return handler.apply(request); // survived every step
}
}
Each check becomes one small class:
public final class AuthCheck implements CheckoutStep {
private final AuthService auth;
public AuthCheck(AuthService auth) { this.auth = auth; }
@Override
public Response handle(Request request) {
if (!auth.validToken(request)) return Response.unauthorized();
return null; // fine — pass it along
}
}
And the line is assembled once, at startup:
Pipeline checkout = new Pipeline(
List.of(new AuthCheck(auth), new RateLimit(limiter), new CartValidation(validator)),
orders::place);
(That last argument is the destination — the code that does the real work once every check passes. orders::place is just Java shorthand for “call orders.place(request),” handed in as a value the same way the steps are.)
flowchart LR R([request]) --> A[auth check] A -->|ok| L[rate limit] L -->|ok| V[cart validation] V -->|ok| H([place the order]) A -->|bad token| X1([401]) L -->|too many| X2([429]) V -->|bad cart| X3([400])
Plain-words walkthrough:
- Each step knows one thing: its own question (“is the token valid?”) and its own rejection (“401”). It has no idea what comes before or after it in the line.
- The pipeline knows one thing: walk the list; the first non-null answer wins.
- Adding the fraud check is one class and one entry in the list — every endpoint using this pipeline gets it at once, and the ordering is written down in exactly one place instead of four.
And one rejected request in slow motion, to feel the difference from Decorator: a request with a bad token enters run(), the loop hands it to AuthCheck, AuthCheck returns Response.unauthorized() — and that’s it. The loop returns immediately: rate limiting never runs, validation never runs, and the order-placing code never even hears a request existed. A decorator stack would have carried the call all the way to the core; a chain’s whole point is that it can end the story at any link.
Decorator and Chain, separated by one if#
These two look like twins on a class diagram, so it’s worth pinning down where they actually split. In a decorator, the forward is naked: return inner.charge(order); sits there with no condition guarding it. In a chain step, the forward lives inside a decision — reject and return an answer, or let the request continue. One branch. That’s the entire structural difference.
Now, “no if around it” is not the same as “guaranteed to run.” If a retry wrapper has a bug and throws before forwarding, the core never runs either — no pattern suspends the laws of exceptions. So the deeper difference is what not reaching the core means in each world:
- In a decorator stack, skipping the core is an accident. It only happens because something went wrong, and the caller gets an exception — nothing pretended the operation happened. A failure, propagated up.
- In a chain, skipping the rest is the link’s actual job.
AuthCheckreturning 401 isn’t a crash — it’s a clean, valid response, and the system worked exactly as designed.
Same observable fact — “the core didn’t run” — with opposite meanings: one is a stack trace, the other is a Tuesday.
And the moment a wrapper grows a branch that decides whether to forward — a cache returning a stored value instead of calling through, a breaker refusing to even try — it has quietly stopped being a decorator. Deciding whether you reach the real object at all is Proxy’s whole job, and it gets its own section below. That sorts all three by their relationship to the inner call: Decorator forwards unconditionally and adds to the result. Proxy decides whether you get to the real object. Chain lets every link claim the request or pass it on.
Its modern name is middleware#
- Servlet filters and Spring Security.
FilterChainis the pattern’s name in the JDK: each filter does its bit and callschain.doFilter(...)— or doesn’t, and the request dies right there. - Express, Koa, ASP.NET Core middleware —
(req, res, next)where callingnext()is “pass it along” and answering without it is “claimed.” - OkHttp interceptors, logging framework handler chains, DOM event bubbling (the event climbs the element tree until a listener stops it).
- Exception handling itself — an exception climbs the call stack until some
catchclaims it. You’ve been using a chain of responsibility since your firsttry.
When NOT to use it#
- Two fixed checks, stable forever. Two
ifs at the top of a method beat pipeline machinery. The chain earns its keep when the checks multiply, reorder, or must be shared across many entry points. - Every step always runs and nothing ever stops. Then nothing is being claimed — that’s just a sequence of calls (or a decorator stack). Use the plain sequence.
- Steps that secretly depend on each other’s side effects. If validation only works because auth stashed something in the request, the chain’s “each link is independent” promise is broken — and reordering the list becomes a production incident. Make the dependency explicit instead.
03 · Adapter — make the foreign thing speak your language#
Your laptop charger doesn’t change in Europe, and Europe’s wall sockets don’t change for you. You buy a five-euro plug adapter: one side fits their wall, the other side fits your plug. It adds no power and no features — it only translates shapes.
Here’s the thing: you already wrote an adapter in the first post. In the Strategy section, PayPalPayment implements PaymentStrategy held a PayPal SDK object inside. PayPal’s SDK speaks PayPal: their order objects, decimal-string amounts, their status strings. Your checkout speaks PaymentGateway: orders in, PaymentResult out. That class was doing translation duty all along — Adapter is just its name.
Adapter’s job description: wrap the foreign object in a class that implements your interface and translates both directions — their language in, your language out — so the rest of the codebase never learns a second vocabulary.
The translator class#
public final class PayPalAdapter implements PaymentGateway {
private final PayPalClient paypal;
public PayPalAdapter(PayPalClient paypal) { this.paypal = paypal; }
@Override
public PaymentResult charge(Order order) {
// our language -> theirs: cents to their decimal string, our Order to their request shape
PayPalOrder created = paypal.orders().create(
order.total().toDecimalString(),
order.customer().email());
// their language -> ours: their status strings to our result type
return switch (created.status()) {
case "COMPLETED" -> PaymentResult.success(created.id());
case "DECLINED" -> PaymentResult.declined(created.reason());
default -> PaymentResult.pending(created.id());
};
}
}
The whole class is translation: amount conversion in, status mapping out, nothing else. That’s the test of a healthy adapter — it contains conversions, not decisions. And because PayPalAdapter and StripeGateway now speak the same PaymentGateway language, the Strategy table from the first post can hold either without anyone downstream knowing which country’s wall socket is behind it.
Every driver interface is one#
Arrays.asList(array)— an array wearing aListinterface.InputStreamReader— bytes adapted to characters (yes, it appeared under Decorator too: it genuinely wraps a stream and changes the interface — real code doesn’t always sit in one box, which is fine, because the useful part is the intent vocabulary, not the taxonomy).- Every DTO mapper and anti-corruption layer — translating an external API’s shapes into your domain types at the boundary is adapter work, whatever your codebase calls it.
- Driver interfaces: JDBC is one interface adapted onto dozens of databases; SLF4J is one logging interface adapted onto Logback, Log4j, and JUL. Python’s DB-API is the same move (one database interface, psycopg2/sqlite3/mysql drivers behind it), and SQLAlchemy’s dialects adapt one query language onto every engine.
When NOT to use it#
- You own both sides. If both interfaces are yours, don’t translate — change one of them. Adapters are for boundaries you can’t move: third-party SDKs, legacy modules, other teams’ contracts.
- The adapter starts making decisions. Retry policies, fallbacks, business rules — the moment translation code grows opinions, split them out (the decisions probably want to be a decorator around the adapter).
04 · Facade — one simple door into a complicated building#
Press the start button in a car. Behind that one button: the fuel pump primes, injectors fire, the starter motor cranks, the ECU checks a dozen sensors. Nobody wants to perform that choreography from the driver’s seat — the button is the choreography, packaged.
The pain in our shop: placing an order takes a dance — reserve inventory, charge payment, schedule shipping, and do them in that order. The web checkout does the dance. Then the mobile API copies it. Then the admin “manual order” tool copies it, forgets to reserve inventory first, and support gets a week of oversold-stock tickets. The failure underneath: a multi-object choreography that lives in the callers, copied imperfectly, instead of existing once behind a door.
Facade’s promise: one class offers a small, simple front door to a messy subsystem — callers say what they want (“place this order”), and the facade performs the steps in the one correct order.
One front door#
public class OrderPlacement {
private final InventoryService inventory;
private final PaymentGateway payments;
private final ShippingService shipping;
public OrderPlacement(InventoryService inventory, PaymentGateway payments, ShippingService shipping) {
this.inventory = inventory;
this.payments = payments;
this.shipping = shipping;
}
public OrderConfirmation placeOrder(Cart cart) {
Reservation reservation = inventory.reserve(cart.items());
PaymentResult payment = payments.charge(cart.toOrder());
if (!payment.succeeded()) {
inventory.release(reservation);
return OrderConfirmation.rejected(payment);
}
Shipment shipment = shipping.schedule(cart.toOrder());
return OrderConfirmation.of(payment, shipment);
}
}
Web, mobile, and admin now make one call. The subsystems are still there, still reachable for code that legitimately needs fine control — the facade is a convenience door, not a locked gate. And note the difference from Adapter: an adapter matches a shape your code already expects; a facade invents a new, smaller interface because the existing surface is too wide. Adapter translates. Facade simplifies.
(If placeOrder made you think “what if the process crashes between charge and schedule?” — good instinct. That’s the distributed notes’ territory: this exact method is where sagas and outboxes will eventually live. A facade organizes the choreography; it doesn’t yet make it crash-proof.)
Front doors you use daily#
- Spring’s
JdbcTemplate— one method call instead of the Connection/Statement/ResultSet/close dance.Files.readAllLines(path)— the same favor for file IO. - Python’s
requests—requests.get(url)is one line standing in front of urllib3’s connection pools, TLS, redirects, and encoding handling. Its actual tagline is “HTTP for Humans” — facades even advertise themselves as facades. Node’sfetchand axios do the same favor over raw HTTP plumbing. - Every SDK client object:
stripe.charges().create(...)is a facade over auth headers, HTTP, retries, JSON, and versioning. - Your own service layer, half the time: a
@Servicemethod that coordinates three repositories for the controller is facade work under a different name.
When NOT to use it#
- The facade becomes the god object. When every workflow in the system routes through one giant class with forty methods, you’ve traded “callers know too much” for “one class knows everything.” Facades should be small and per-purpose (
OrderPlacement, notShopManager). - One caller, one subsystem. A facade in front of a single class with three methods is a hallway to a door. Wait for either multiple callers or real choreography.
05 · Proxy — same interface, but it guards the door#
Email a celebrity and a reply comes back — but you were never talking to the celebrity. An assistant reads everything, answers the routine stuff from templates (a cache), filters what gets through (access control), and forwards only what’s worth the star’s time (lazy access to the expensive resource). Crucially: the interface is identical. Same address, same kind of replies. You cannot tell from outside.
That’s Proxy: a stand-in with the same interface as the real thing, whose job is controlling when — and whether — the real thing gets used. It looks exactly like Decorator in a class diagram, and the difference is pure intent: a decorator’s purpose is adding behavior on the way through; a proxy’s purpose is gatekeeping — and it may never call through at all.
The gatekeeper in code#
The classic gatekeeping jobs, in one concrete shape — a product catalog backed by a slow remote service, hidden behind a cache:
public final class CachingCatalog implements ProductCatalog {
private final ProductCatalog remote;
private final Map<ProductId, Product> cache = new ConcurrentHashMap<>();
public CachingCatalog(ProductCatalog remote) { this.remote = remote; }
@Override
public Product byId(ProductId id) {
return cache.computeIfAbsent(id, remote::byId); // cache hit -> the real catalog is never touched
}
}
(computeIfAbsent reads out loud as: if this id is already in the map, hand back the remembered answer; only if it isn’t, call remote.byId(id) once and remember it. The whole gatekeeping decision is that one line.)
sequenceDiagram participant App as checkout page participant Proxy as CachingCatalog participant Real as remote catalog App->>Proxy: byId(42) Proxy->>Real: byId(42) Real-->>Proxy: Product 42 Proxy-->>App: Product 42 App->>Proxy: byId(42) again Note over Proxy: cache hit Proxy-->>App: Product 42 Note over Proxy,Real: the second ask never reached the real catalog
Same ProductCatalog interface, and on a warm cache the real service is never called — the line a decorator would never cross. The other classic proxies all share that gatekeeping intent:
- Lazy loading (don’t fetch until someone actually asks): JPA/Hibernate hands you an
OrderwhosegetItems()is a proxy — the SQL for items runs only if you touch them. Django’s QuerySets pull the same trick: no SQL runs until you actually iterate. - Access control: a proxy that checks permissions and refuses to forward — the call may be denied, not decorated.
- Remote proxies: a Feign or gRPC client is an object that looks local — same interface as a service — while the proxy does HTTP behind your back.
- JavaScript ships the pattern as a language feature:
new Proxy(target, handler)— and Vue 3’s entire reactivity system is your state wrapped in one, intercepting every read and write to know when to re-render.
And one you’ve been bitten by if you write Spring: @Transactional and @Async work by Spring handing your callers a generated proxy around your bean — the transaction opens in the proxy, before your method. That’s exactly why a this.otherMethod() call inside the same class skips @Transactional on otherMethod: going through this bypasses the proxy, and the gatekeeper never sees the call. The pattern explains the bug.
When NOT to use it#
- You’re hand-writing proxies for behavior. Logging and retries on the way through are decorator jobs; write them as decorators (or let resilience4j/Spring do it). Reserve “proxy” thinking for access: caching, laziness, permissions, remoteness.
- Magic proxies where plain code would do. Framework-generated proxies are invisible in stack traces and break on self-invocation. If a transaction boundary can just as easily be an explicit call to a transaction runner, the boring version debugs better.
The untangler#
All five, one table — this is the part to come back to when two of them blur together:
| Pattern | The caller sees | The wrapper’s job | The tell |
|---|---|---|---|
| Decorator | the same interface | add behavior, always call through | they stack — order matters |
| Chain of Responsibility | one entry point | let each step claim or pass the request | the request can stop mid-line |
| Adapter | your interface | translate a foreign one | a third-party SDK lives inside |
| Facade | a new, smaller interface | run the choreography behind one door | callers get simpler, not more powerful |
| Proxy | the same interface | control access to the real thing | sometimes the real thing is never called |
That closes the wrapping patterns. Next in these notes: the creational bucket — factories, builders, and an honest take on Singleton in the dependency-injection era. And the distributed notes open with the question the behavioral post left hanging: the dual-write problem.
Comments
Signed in with GitHub. Be kind.