THIS EXPLANATION
THE ROOM
CDA·269 Computing, Data & AI 6 MIN · 8 STATIONS

Wrapping without changing

A Socratic walk-through of wrapping without changing — reasoned out one step at a time, not lectured.

abcdefgh
a

The question we started with

THE QUESTION #

How do you add behaviour to something without altering it or anything that calls it?

A payment service works. Now it needs request logging. Next month, retries. After that, a cache, a metrics counter, and a rate limit. Each of these is a legitimate requirement, and each has nothing whatever to do with taking payments.

The obvious move is to edit the payment service five times. The obvious objection is that a class which has been edited five times for five unrelated reasons is no longer about payments. The less obvious move is to leave both the service and every caller of it completely untouched, and still have all five behaviours run. That sounds like a trick, and it is worth working out exactly where the trick lives.

b

Reasoning it through

REASONING #

Ask first what the callers actually depend on. Not the class — the shape: a method named charge that takes an amount and returns a result. If two different objects present that same shape, no caller can tell which one it holds. That is the entire foundation, and everything else follows from it.

So suppose we build a second object with the same shape, which does not know how to take payments at all. It holds a reference to something that does. When charge is called on it, it writes a log line, calls charge on the thing it holds, writes another log line, and returns whatever came back. From outside, it is a payment service. From inside, it is a payment service plus a habit.

Now the question that makes the pattern powerful rather than merely cute: what can the wrapper hold? Anything with that shape — including another wrapper. So retries can wrap the log, the cache can wrap the retries, and each layer was written once, in ignorance of the others. Behaviour is composed at assembly time rather than declared at compile time, which is what inheritance could not give us: a subclass fixes its combination when it is written, and five optional behaviours would need thirty-one subclasses to cover every combination.

But ordering deserves a hard look, because this is where the elegance stops being free. Does the cache sit inside the retry or outside it? Outside, a cached response short-circuits before any retry happens; inside, every retry attempt consults the cache and a failure may be cached and re-served. Both compose cleanly and neither is a mistake in the pattern — yet they behave very differently, and the difference is invisible in any single layer's source. The composition is now a design decision, made in the wiring code, that no individual class documents.

Two further constraints are worth naming honestly.

The wrapper can only intercept what the interface exposes. If callers depend on the concrete class and its extra public methods, wrapping breaks them — which is why this technique presupposes that callers were coded against an abstraction in the first place. And object identity does not survive: the wrapper is not the same object as the thing it wraps, so equality checks, reference comparisons, and anything relying on runtime type will see the wrapper. Frameworks that wrap your objects behind your back are a recurring source of surprise for exactly this reason.

The cataloguing name is the Decorator, from the 1994 Design Patterns book, and its canonical illustration is Java's I/O library, where a BufferedInputStream wrapping a GZIPInputStream wrapping a FileInputStream is precisely this construction, layer by layer. The same idea appears far from object-oriented code: a web framework's middleware chain, a shell pipeline, and a higher-order function that takes a function and returns a wrapped one are all the same move. Python's @ syntax is named after this pattern, though it is the function-wrapping variant rather than the object one.

Underneath it all is a single principle: a module should be open to extension and closed to modification. The decorator is one of the few constructions that delivers that literally — the original file is not edited, and the calling code is not edited, yet the observable behaviour changes.

c

The analogy

THE ANALOGY #
THE FIGURE

Think of a parcel passed through a sorting office. Each station adds something to the outside — a barcode, a customs form, a fragile sticker, a tracking label — and hands the parcel on. None of them opens it, and the contents arrive exactly as posted. The recipient still receives a parcel, and treats it as one, however many labels accumulated in transit.

WHERE IT BREAKS DOWN

stickers only ever add and never intervene, whereas a decorator sits directly in the call path and may return a cached answer, retry, or refuse outright — so unlike a label, a wrapper can prevent the parcel from ever reaching the depot at all.

d

Clarifying the model

THE MODEL #

Three clarifications.

First, a decorator is not a subclass, even where a language makes it convenient to declare it as one. Inheritance fixes the combination at compile time and gives access to the parent's internals; a decorator holds a reference and can therefore be chosen, reordered, or omitted at run time, with no access to anything the interface does not expose.

Second, it is not a proxy, though the code can look identical. The distinction is intent: a proxy stands in for something to control access to it — lazily, remotely, or with a permission check — while a decorator assumes the real object is there and adds to what it does. The same class can drift from one to the other, which is why the distinction matters more in review than in compilation.

Third, the cost is real and it is legibility. A stack trace runs through every layer, a bug may live in the ordering rather than in any class, and answering "what actually happens when I call charge?" now requires reading the wiring code as well as the classes. Two or three layers is usually a gain; eight is a debugging problem the original class never had.

e

A picture of it

THE PICTURE #
Wrapping without changing
Wrapping without changing Read left to right as depth: the checkout code speaks only to the leftmost object and believes it is the payment service. Each wrapper does a little work, delegates inward, and does a little more on the way back out -- the two attempts in the middle are entirely contained within the retry layer, which is why the outermost timer measures the whole ordeal and the caller sees only a single successful call. {"generator":"[email protected]","source":"../Socrates/.diagram-cache/_src/wrapping-without-changing.md","sourceIndex":1,"sourceLine":4,"sourceHash":"61571eae8f6aa1a5986931df2554eb7cd1e1d77da9d6dd147d0057a68b1b186c","diagramType":"sequence","layoutVariant":"source","repairedDuplicateIds":[],"motion":"entrance-with-reduced-motion-fallback","presentation":"editorial","attempt":1,"viewBox":{"x":0,"y":0,"width":1258,"height":1216},"qa":{"passed":true,"findings":[]}} Card network 01 Payment service 02 Retry wrapper 03 Metrics wrapper 04 Checkout code 05 every participant offers the same charge method the caller never learns a retry happened charge(amount) 1 start timer 2 charge(amount) 3 charge(amount) attempt 1 4 authorise 5 timeout 6 transient failure 7 charge(amount) attempt 2 8 authorise 9 approved 10 success 11 success 12 record duration and outcome 13 success 14
KINDSlifelineparticipantmessage

How to readRead left to right as depth: the checkout code speaks only to the leftmost object and believes it is the payment service. Each wrapper does a little work, delegates inward, and does a little more on the way back out — the two attempts in the middle are entirely contained within the retry layer, which is why the outermost timer measures the whole ordeal and the caller sees only a single successful call.

f

What became clearer

WHAT CLEARED #
WHAT CLEARED

The trick is not in the wrapper at all; it is in the interface. Once callers depend on a shape rather than a class, any object of that shape can be substituted for any other, and a wrapper that delegates inward becomes an invisible place to add behaviour. What you gain is composition at assembly time; what you pay is that the system's real behaviour now lives partly in the order things were assembled, which no single class records.

g

Where to go next

ONWARD #
  • The open-closed principle, and how literally different patterns manage to satisfy it.
  • Middleware chains in web frameworks as the same construction under a different name.
  • When aspect-oriented weaving is preferable to explicit wrapping, and what it costs in traceability.
h

Key terms

TERMS #
TermWhat it means
Decoratoran object implementing the same interface as the one it holds, adding behaviour before or after delegating to it.
Proxystructurally the same construction, but intended to control access to the wrapped object rather than to extend its behaviour.
Open-closed principlethe guideline that a module should be extensible without its source being modified.
Composition over inheritancebuilding behaviour by assembling collaborating objects at run time rather than by fixing it in a class hierarchy.

Every term the collection defines is gathered in the glossary.

Nearby on the shelf

4