← blog

Everything Looks Like a Wrapper — Decorator, Chain of Responsibility, Adapter, Facade, Proxy

Five patterns that are all "an object standing in front of another object" — and the one question that tells them apart: what does the wrapper change?

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:

  • StripeGateway knows one thing: how to talk to Stripe. It has no idea it’s being wrapped.
  • LoggingGateway knows one thing: log before, hand the call inward with wrapped.charge(order), log after. It doesn’t know whether wrapped is Stripe, PayPal, or another wrapper.
  • RetryingGateway knows one thing: call wrapped, 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, no super. The caller holds only the outermost one, and each layer’s charge() 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 one new expression.

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
Everyone is-a PaymentGateway — that's why the caller can't tell them apart. The boxed two also hold one (wrapped) — that's why layers stack. Stripe just does the work, and the client wires it all once around the bold core.

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
One failing charge, in slow motion. Each layer's charge() runs nested inside the one outside it — the vertical bars show who is mid-call. The exception flies out to the retry layer, attempt 2 dives back in, and the logging layer (sitting inside the loop) logs both attempts. The caller sees a single successful call.

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.

callercharge()RetryingGatewayLoggingGatewayStripeGatewaythe real HTTP callwrapped.charge()wrapped.charge()one interface, three layers — same shape at every depth
A decorated call. The caller holds 'a PaymentGateway' and can't tell it's three layers deep. Each layer adds its bit and forwards to the one it holds — the retry wraps the logging, the logging wraps the real thing.

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
  1. Find the shape everyone must share. One interface holding the methods callers actually use — ours is PaymentGateway with charge(). If the real thing and the layers can’t share one interface, stop: this isn’t a decorator situation.
  2. Keep the real worker as a plain class implementing it. StripeGateway does the actual job and never learns it will be wrapped — no special fields, no flags.
  3. Write each feature as a layer class that does both things at once: implements the interface and holds one wrapped field typed as the interface. That double role is the entire pattern.
  4. 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.
  5. Compose at wiring time, not in the classes. Nested new expressions, in the order you want, once, at startup — and hand the caller the outermost object as plain “a PaymentGateway.”

Stacked wrappers you already import#

  • new BufferedReader(new InputStreamReader(new FileInputStream(f))) — the textbook example that’s also real. Every java.io stream wrapper is a decorator: same read/write interface, one added ability per layer (buffering, character decoding, compression).
  • Collections.unmodifiableList(list) — same List interface, 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 @decorator syntax — 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 a PaymentGateway five 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])
The request walks the line. Each step either lets it continue or stops it with an answer — the order-placing code at the end only ever meets requests that survived every check.

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. AuthCheck returning 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. FilterChain is the pattern’s name in the JDK: each filter does its bit and calls chain.doFilter(...) — or doesn’t, and the request dies right there.
  • Express, Koa, ASP.NET Core middleware(req, res, next) where calling next() 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 catch claims it. You’ve been using a chain of responsibility since your first try.

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());
        };
    }
}
checkoutcharge(order)PayPalAdapteryour languagetheir languageorders().create(…)PayPalClienttheir SDK, untouchedconversions in, conversions out — no decisions inside
An adapter is a two-faced box: one face speaks your interface, the other speaks theirs, and nothing but translation happens between them.

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 a List interface. 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);
    }
}
before: every caller does the dancewebmobileadmininventorypaymentsshippingadmin forgot reserve — a week of oversold stockafter: one door owns the dancewebmobileadminOrderPlacement1 reserve → 2 charge → 3 scheduleinventorypaymentsshippingthe steps exist once, in the one correct order
The choreography, before and after. Left: three callers each perform the reserve-charge-schedule dance themselves — and the admin tool forgot a step. Right: the dance exists once, in order, behind one door.

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 requestsrequests.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’s fetch and 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 @Service method 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, not ShopManager).
  • 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
The gatekeeper at work. First ask: the proxy forwards and remembers. Every ask after that is answered at the door, and the slow remote service is never contacted again. A decorator would always call through — the proxy's whole point is that it may not.

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 Order whose getItems() 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:

PatternThe caller seesThe wrapper’s jobThe tell
Decoratorthe same interfaceadd behavior, always call throughthey stack — order matters
Chain of Responsibilityone entry pointlet each step claim or pass the requestthe request can stop mid-line
Adapteryour interfacetranslate a foreign onea third-party SDK lives inside
Facadea new, smaller interfacerun the choreography behind one doorcallers get simpler, not more powerful
Proxythe same interfacecontrol access to the real thingsometimes the real thing is never called
Five wrappers told apart by intent. The photo is always 'an object in front of another object' — the intent is what the wrapper changes.

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.