skip to content

What practical problems appear once a system leans heavily on Decorator — around object identity, type checks, and calls an object makes to itself?

level: seniorimportance: should knowfreq 34%

answer

  1. wrapper != wrappee: identity, ==, instanceof
  2. never fake equals/hashCode across a wrapper
  3. self-calls bypass wrappers (same as generated proxies)
  4. new interface method = every decorator updated
  5. forward close/flush or leak; beware double wrapping

basics

~20 s

The wrapper is a different object than the one wrapped, so reference comparison, type checks, and casts to the concrete class fail. Also, when the inner object calls its own methods, those calls skip the wrappers entirely.

solid answer

~60 s

Key pitfalls: 1. **Identity.** `wrapper != wrapped`. Anything keyed on reference identity — identity maps, caches, `==` comparisons, some equality contracts — sees two objects. Decorators generally must not forge `equals`/`hashCode`, since a symmetric equality between wrapper and wrappee is impossible to define correctly. 2. **Type interrogation.** Runtime type checks and downcasts to the concrete implementation fail once wrapped. Client code doing `instanceof ConcreteImpl`, reading annotations off the class, or relying on the concrete return type is not decoration-friendly. Some frameworks expose an unwrap/target accessor as an escape hatch. 3. **Self-calls.** Decoration intercepts only calls that cross the object boundary. If the wrapped object internally calls its own public method, the wrapper is bypassed — the same limitation as framework-generated proxies for transactions or caching. 4. **Interface growth.** Adding a method to the interface breaks every decorator; an abstract pass-through base decorator localizes that, but then a new method silently goes undecorated. 5. Plus operational friction: deep stack traces, many small classes, unclear which wrappers are active, and lifecycle methods (`close`, `flush`) that must be delegated or resources leak.

code

pseudocode · 13 lines
pseudocode
class RealStore implements Store {
  void writeAll(items) { for (i in items) this.write(i) }   // self-call
  void write(item) { disk.put(item) }
}

Store s = new AuditingStore(new RealStore())
s.writeAll([a, b])
// audit log: 1 entry (writeAll). The two write() calls
// never leave RealStore, so the decorator cannot see them.

// fix: split responsibilities so the loop lives outside
class BatchWriter { Store store;  void writeAll(items){ for(i in items) store.write(i) } }
new BatchWriter(new AuditingStore(new RealStore()))   // now every write is audited

go deeper

for a junior

Note the basics: the wrapper is a different object, so reference comparison and casts to the concrete class fail.

for a middle

Add the self-call limitation and interface-growth burden, and mention delegating lifecycle methods.

for a senior

Explain why faking equality is unsound, give the split-the-object fix for self-calls, and connect the limitation to framework-generated proxies for transactions/caching.

for a principal

Treat it as an architectural constraint: design interfaces to stay decoratable, centralize composition, guard against double wrapping, and decide where explicit wrappers beat implicit generated interception.

## 1. Identity is not preserved A decorator is a genuinely different object. Consequences: - **Reference comparison fails.** Code doing `if (store == expectedStore)` breaks after wrapping. - **Identity-keyed structures split.** Registering the raw object and later looking up the wrapper (or vice versa) yields a miss. - **Equality is a trap.** Making the wrapper `equals` the wrappee cannot be done symmetrically or transitively: the wrappee has no knowledge of the wrapper, so `wrapper.equals(inner)` might be true while `inner.equals(wrapper)` is false — a broken equality contract that corrupts hash-based collections. The safe rule: decorate behavior, never identity. If callers must compare, give them a stable business key on the interface instead of relying on object identity. - **Serialization and caching by object graph** likewise see the wrapper's shape, not the original's. ## 2. Type interrogation and casting Once wrapped, the runtime type is the decorator's. Anything that reaches past the interface breaks: - `instanceof ConcreteImpl` / `as ConcreteImpl` casts. - Reading class-level annotations or attributes from the object's class. - Reflective inspection expecting the real class's fields or method annotations. - Framework registries keyed by concrete class. Mitigations: keep clients depending strictly on the interface; if unavoidable, expose an explicit `unwrap()`/`getTarget()` on a marker interface so callers can descend deliberately rather than by accident — but treat every use as a design smell, because it re-couples clients to the implementation the pattern was meant to hide. ## 3. The self-invocation (self-reference) problem This is the subtlest and most important one. Decoration works by interposing on calls that arrive *from outside*. If the inner object's method calls another of *its own* methods directly, that call is dispatched on the inner object's own reference — the wrapper never sees it. ``` class RealStore implements Store { void writeAll(items) { for (i in items) this.write(i) } // internal call void write(item) { ...disk... } } Store s = new AuditingStore(new RealStore()) s.writeAll(items) // audited ONCE, for writeAll only // the individual write() calls are NOT audited ``` The wrapped object has no reference to its wrapper, so it cannot route self-calls outward. This is exactly why framework annotations implemented by generated proxies (declarative transactions, caching, security, async) famously "do not work on internal method calls". Workarounds, roughly in order of preference: - **Split the object** so the composite operation and the primitive operation live in different objects; the outer one then calls the inner one through the interface, and decoration applies. - **Keep decorated methods free of self-calls** — treat internal helpers as private so they were never part of the decorated contract in the first place. - **Inject a self-reference** (the object receives the fully-wrapped instance and routes outward through it), which works but creates a circular dependency and is easy to get wrong. - **Move the concern inside** the class when interception genuinely cannot be made to work. ## 4. Interface evolution Every method added to the component interface must be implemented by every decorator. Two mitigations, each with a downside: - An **abstract base decorator** that forwards all methods: new methods only need a pass-through added once. But new methods will silently be *undecorated* in every concrete decorator — a caching wrapper that never learned about the new `readBatch` method silently bypasses caching. - **Default/interface methods** delegating to existing ones: convenient, but a default implemented in terms of other methods may double-count in decorators that override both. Narrow interfaces (Interface Segregation) keep decorators cheap; a fat interface makes each wrapper a wall of boilerplate and discourages the pattern entirely. ## 5. Operational and cognitive costs - **Stack depth and traces.** Ten wrappers add ten frames to every trace and make "where did this actually happen" harder to read; give wrappers descriptive class names. - **Invisible configuration.** Reading a class tells you nothing about which wrappers are active; the truth lives in the wiring code. Keep composition in one place, and consider logging the composed stack at startup. - **Lifecycle leaks.** `close()`, `flush()`, `shutdown()` must be forwarded or resources leak; a wrapper that forgets one method of a lifecycle contract creates a hard-to-find bug. - **Thread safety and state.** A wrapper holding mutable state (a cache, counters) must be as thread-safe as the contract requires; and wrapping a stateful object twice from different code paths can produce two independent caches over the same target. - **Exception and contract fidelity.** A wrapper must not narrow or widen the contract: swallowing exceptions, changing null semantics, or altering ordering breaks substitutability (Liskov) for callers who cannot see the wrapper. - **Double wrapping.** Without care, wiring can apply the same decorator twice (metrics counted twice, retries multiplied: 3 retries inside 3 retries = 9 attempts). Make composition idempotent or centralized.

  • Why is it dangerous for a decorator to override equals() so that it compares equal to the object it wraps?
    Equality must be symmetric and transitive. The wrapped object knows nothing about the wrapper, so it will not report equality back, breaking the contract and corrupting hash-based collections; two different decorators over the same target would also compare equal to it but not to each other.
  • Your framework's declarative caching annotation is ignored when one method of a class calls another. Why, and what is the cleanest fix?
    The framework intercepts via a generated proxy or wrapper, and internal self-calls never cross the object boundary. The clean fix is to move the called method into a separate collaborator invoked through its interface, so the call goes through the interceptor.
  • How do you keep adding methods to a widely-decorated interface from becoming a maintenance nightmare?
    Keep interfaces small and role-focused (Interface Segregation), provide an abstract pass-through base decorator so additions are mechanical, and review each existing concrete decorator to decide whether the new method also needs its behavior rather than assuming pass-through is right.

Wearing a coat does not change who you are, but a fingerprint scanner still reads the glove, not your hand. And if you talk to yourself inside the coat, no one outside the coat hears it — that is the self-call problem.

saying these in an interview costs you the question

  • "Just override equals so the wrapper looks like the original" — breaks the symmetry/transitivity contract.
  • "instanceof still works because it's the same interface" — true for the interface, false for the concrete class.
  • "Internal method calls are decorated too" — they are not; interception only sees calls crossing the object boundary.
  • Forgetting to delegate close/flush/lifecycle methods in a wrapper.
  • Assuming an abstract base decorator makes interface growth free — pass-through means the new method is silently undecorated.
  • Nesting the same retry wrapper twice and being surprised by multiplied attempts.

context