skip to content

When several Decorator wrappers are stacked around the same object, why does the order of wrapping change behavior? Give concrete examples where the wrong order is a bug.

level: middleimportance: must knowfreq 52%

answer

  1. outermost = first in, last out
  2. a wrapper only controls what is inside it
  3. cache outside retry; compress before encrypt
  4. breaker inside retry trips per attempt
  5. timeout innermost, per attempt

basics

~20 s

Each wrapper runs its logic around the call to the next one, so the outermost wrapper sees the call first and the result last. Swapping two wrappers changes what each one observes and what it can prevent, which changes behavior.

solid answer

~60 s

Stacked decorators form a nested call chain: the outermost runs first on the way in and last on the way out. So a wrapper only observes and can influence what the wrappers inside it do — it never sees work done outside itself. Classic order-sensitive pairs: - **Cache vs retry.** `cache(retry(svc))` serves cache hits without touching retry (correct); `retry(cache(svc))` retries the cache lookup too and, worse, a cache hit means the retry layer never fires. - **Retry vs circuit breaker.** Breaker outside retry counts one logical operation; breaker inside retry sees every attempt, tripping much faster. - **Compress vs encrypt.** Compress first, then encrypt — encrypted bytes are high-entropy and will not compress. Reverse order silently wastes CPU and bandwidth. - **Metrics/logging vs cache.** Timing outside the cache measures user-perceived latency including hits; timing inside measures only backend calls. - **Auth vs cache.** Caching outside an authorization wrapper can serve one user's data to another. So wrapping order is a semantic design decision, and the wiring code deserves a comment or a test.

code

pseudocode · 8 lines
pseudocode
// outermost is written first
Service a = cache(retry(timeout(realService)))
// hit  -> returns instantly, retry+timeout never run
// miss -> retry loops, each attempt bounded by timeout

Service b = retry(cache(timeout(realService)))
// hit  -> retry wrapper adds pure overhead
// a cached failure/negative result is never retried

go deeper

for a junior

Say that the outer wrapper runs first and calls the inner one, and give one example such as caching before retrying.

for a middle

Give two or three concrete order-sensitive pairs (cache/retry, compress/encrypt, timeout/retry) and explain the mechanism: short-circuiting and what each layer observes.

for a senior

Discuss resilience stacks in depth (breaker inside vs outside retry, per-attempt vs overall deadline), security implications of caching above authorization, and how you would test ordering.

for a principal

Talk about governing order centrally: a single composition point, declared priorities, platform defaults, and the failure modes when teams hand-nest wrappers inconsistently across services.

## The mechanics of a stack Decorators wrap one another, so `A(B(C(real)))` produces a chain where a call enters `A`, `A` optionally does pre-work and calls `B`, `B` calls `C`, `C` calls the real object, and results unwind back out through `C`, `B`, `A`. Two consequences follow, and they explain every ordering rule: 1. **The outer wrapper is first in and last out.** It sees the raw request and the final result. 2. **A wrapper can only affect what is inside it.** It can suppress, repeat, transform, or time inner calls. It has no knowledge of, and no control over, wrappers outside it. In particular, if an outer wrapper short-circuits (a cache hit, a tripped breaker), the inner wrappers never run at all. Think of it as an onion, or as request/response middleware: outer = closer to the caller, inner = closer to the real work. ## Worked examples where order is the whole story ### Cache and retry - `cache(retry(service))`: a cache hit returns immediately; a miss goes to the retry layer, which retries the real call. Usually what you want. - `retry(cache(service))`: retries wrap the cache lookup. On a hit the retry layer is pointless overhead; on a miss it re-enters the cache each attempt (harmless but odd); and if the cache itself throws, you retry a local lookup instead of the remote call. Also, a *negative* cached result will be returned over and over without any retry ever reaching the backend. ### Retry and circuit breaker - `breaker(retry(service))`: the breaker counts one logical call; internal retries are invisible to it. The breaker trips based on end-to-end failures, but a burst of retries can hammer a dying dependency before the breaker notices. - `retry(breaker(service))`: the breaker sees each individual attempt, so it trips faster and then rejects subsequent attempts cheaply (fast-fail), which protects the dependency. Many resilience libraries recommend this order for exactly that reason. Neither is universally right — the choice depends on whether you want retries counted against the failure budget. ### Compression and encryption Encryption output is designed to be indistinguishable from random data, and random data does not compress. `encrypt(compress(payload))` shrinks then protects — correct. `compress(encrypt(payload))` produces no size reduction and burns CPU. (Note the separate security caveat: compressing attacker-influenced data before encryption can leak information through ciphertext length — the CRIME/BREACH family of attacks — so "compress then encrypt" is right for size but must be applied carefully when secrets and attacker data share a payload.) ### Observability and short-circuiting layers - Timing *outside* a cache measures what the caller experiences (fast on hits). - Timing *inside* a cache measures backend latency only, and your hit rate must be reported separately. Both are legitimate metrics; the bug is not knowing which one you built. ### Authorization and caching If `cache` sits outside `authorize`, an authorized fetch populates the cache and a later unauthorized caller may get the cached value without the authorization wrapper ever running. Security-relevant wrappers generally belong *outside* any layer that can short-circuit, or the cache key must include the principal. ### Transactions and retry Retry outside a transactional wrapper retries the whole transaction (usually correct — a rolled-back transaction can be replayed). Retry inside the transaction re-runs a statement in an already-poisoned transaction, which most databases will refuse. ## Practical guidance - Write the wiring in one place (a factory or DI configuration) and treat the order as documented design, not incidental syntax. - A useful default heuristic, from outside in: *context/tracing → authorization → caching → bulkhead/circuit breaker → retry → timeout → real call*. Timeouts belong closest to the call so each attempt is bounded; a second overall deadline may sit outermost. - Test the order, not just each wrapper: assert that a cache hit does not increment the retry counter, that the breaker trips after N logical calls, and so on. - Beware idempotency: any retry wrapper placed above a non-idempotent operation can duplicate side effects regardless of the rest of the stack. - Composition helpers that build the stack from a list must define whether the list is outermost-first or innermost-first; getting that convention backwards silently inverts every rule above.

  • Where should a timeout wrapper sit relative to a retry wrapper, and why?
    A per-attempt timeout goes inside retry so each attempt is individually bounded and a hung attempt does not consume the whole budget. If you also need an overall deadline, add a second timeout outside retry.
  • How would you make a stack's ordering hard to get wrong across a large codebase?
    Build the chain in one shared factory that takes a fixed, documented order (or an ordered enum/priority per wrapper) rather than letting each call site nest wrappers by hand, and add tests that assert cross-layer behavior such as "a cache hit produces zero backend calls".
  • Does order matter for wrappers that only observe, such as two logging decorators?
    Largely no for correctness, but it still affects what is recorded: an outer logger records durations that include the inner logger's own work, and if an outer wrapper short-circuits, an inner logger never records anything.

Airport security lanes. Whether the bag scanner comes before or after the ID check changes what each station sees: if ID checking is last, unauthorized people still went through the scanner and consumed its capacity. Same stations, different order, different system behavior.

saying these in an interview costs you the question

  • "All wrappers always run, so order is cosmetic" — a short-circuiting wrapper (cache hit, open breaker) stops everything inside it.
  • Assuming the innermost wrapper runs first — it is the last one entered and the first to return.
  • Compressing after encrypting and expecting size savings.
  • Putting a cache outside authorization without including the principal in the cache key.
  • Retrying inside a failed transaction instead of retrying the whole transaction.
  • Treating wiring order as an implementation detail with no test and no comment.

context