Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive)
Every one of these six patterns does the same mechanical thing — it wraps one object around another and forwards calls through an interface — so the UML diagrams look almost interchangeable. The question that actually separates a senior answer from a memorized one is never “can you draw the boxes,” it is why are you wrapping: to convert an interface you don’t control (Adapter), to simplify a subsystem while leaving it directly reachable (Facade), to decouple two hierarchies you designed together up front (Bridge), to add behavior to one object dynamically (Decorator), to centralize N-to-N communication (Mediator), or to control access while looking identical to the real thing (Proxy). This page assumes you already know each pattern's mechanics from its own page — it is the judgment layer and the gotchas that sit on top.
1. Adapter: object adapter vs class adapter
An adapter's job is to make an existing, incompatible interface look like the one your client already expects — and there are exactly two ways to build that translation layer.
- Object adapter (composition) — the adapter class implements the target interface and holds a reference to an instance of the adaptee; every call is delegated to that held object. This is the default choice: it works in any language, it can adapt the adaptee or any of its subclasses (since you're holding a reference, not extending a specific class), and it composes cleanly with other patterns.
- Class adapter (inheritance) — the adapter extends the adaptee class and implements the target interface, so adaptee methods are inherited directly rather than delegated. This only works in languages that allow the adapter to inherit from the adaptee while also satisfying the target interface — true multiple inheritance (C++) makes it natural; Java's single-class-inheritance rule makes it rare and awkward (you burn your one superclass slot on the adaptee, and can't adapt a subclass differently); Go has no class inheritance at all, so class adapter is not expressible — every Go adapter is necessarily an object adapter.
When each: reach for the object adapter by default — composition over inheritance, works everywhere, adapts a whole family of adaptee subclasses through one reference type. Reach for the class adapter only when your language supports it cleanly, you need to override a piece of the adaptee's behavior (not just forward to it), and you're certain you'll only ever adapt that one concrete class.
// ---- Object adapter (Java): HOLDS the adaptee, forwards to it ----
class LegacyXmlParser { // existing, incompatible, can't change
String parseXml(String xml) { return "parsed:" + xml; }
}
interface JsonParser { // the interface the client expects
String parseJson(String json);
}
class XmlToJsonAdapter implements JsonParser {
private final LegacyXmlParser adaptee; // composition, not inheritance
XmlToJsonAdapter(LegacyXmlParser adaptee) { this.adaptee = adaptee; }
@Override public String parseJson(String json) {
String xml = "<doc>" + json + "</doc>";
return adaptee.parseXml(xml); // delegate to the held object
}
}
// ---- Class adapter (Java): INHERITS the adaptee — shown to illustrate the shape;
// rarely used in Java because it spends your one superclass slot on the adaptee ----
class XmlToJsonClassAdapter extends LegacyXmlParser implements JsonParser {
@Override public String parseJson(String json) {
return parseXml("<doc>" + json + "</doc>"); // inherited method, called directly
}
}
// Go has no class inheritance, so every adapter is an object adapter by construction.
type LegacyXMLParser struct{}
func (LegacyXMLParser) ParseXML(xml string) string { return "parsed:" + xml }
type JSONParser interface { // interface the client expects
ParseJSON(json string) string
}
type xmlToJSONAdapter struct {
adaptee LegacyXMLParser // held, not embedded-as-inheritance
}
func (a xmlToJSONAdapter) ParseJSON(json string) string {
xml := "<doc>" + json + "</doc>"
return a.adaptee.ParseXML(xml) // delegate
}
2. The discriminator matrix — the most-asked question, and where candidates route wrong
Given a one-paragraph scenario, interviewers watch which of these six you reach for. The table below is the routing table; the diagram after it is the same routing table as a decision walk.
| Pattern | Reach for it when… | Key distinguishing property | vs. its closest cousin |
|---|---|---|---|
| Adapter | you have an existing, incompatible interface you cannot change, and must make it fit what the client expects | converts ONE interface to another; wraps a single adaptee of a different interface | vs Facade: Adapter resolves an interface mismatch; Facade doesn't change any interface, it adds a simpler one in front |
| Facade | a subsystem has many moving parts and most callers only need a common-case entry point | purely additive / optional — the subsystem stays directly reachable for callers who need it | vs Adapter: no mismatch is being resolved, just complexity being hidden |
| Bridge | you know UP FRONT that an abstraction must vary independently from its implementation, along two separate axes | designed-in at architecture time; the abstraction holds an implementor interface rather than inheriting an implementation | vs Adapter: Adapter is retrofitted after the fact for one incompatible object; Bridge is planned before either hierarchy is written |
| Decorator | you need to add responsibilities to ONE object dynamically, in any combination, without touching its class | same interface in and out; recursive/stackable wrapping; the order you wrap in changes the result | vs Proxy: Decorator adds behavior and always forwards the call onward |
| Mediator | N objects need to talk to each other and the pairwise (N²) wiring itself is the problem | centralizes many-to-many communication into one hub; colleagues know only the mediator, never each other | vs Facade: Facade simplifies calls into a subsystem from outside; Mediator centralizes calls among peers on the inside |
| Proxy | you need to control or manage access to an object — lazy init, caching, remote stub, permission checks — while looking identical to the real thing | same interface as the real subject; may not forward the call to the real object at all | vs Decorator: Proxy controls whether/how the call reaches the real object; Decorator always forwards and layers extra behavior around it |
3. Bridge's two discriminators: vs Strategy, and vs Adapter
Bridge vs Strategy — identical structure, different intent
Draw the UML for Bridge and for Strategy and you get the same picture: a class holds a reference to an interface, and multiple concrete classes implement that interface. The patterns are structurally indistinguishable — the only thing that tells them apart is intent and scope:
- Strategy swaps ONE algorithm for ONE operation at runtime — a single axis of variation (e.g. which comparator to sort with, which payment processor to charge with). It's a local, tactical choice usually made per call or per object instance.
- Bridge spans TWO orthogonal dimensions that were designed up front as separate hierarchies — an abstraction hierarchy (e.g. Shape: Circle, Square) and an implementation hierarchy (e.g. Renderer: OpenGL, DirectX) that must be able to combine freely. It's an architectural decision made before either hierarchy grows subclasses.
If you're asked "is this Bridge or Strategy," ask: is there really a second family of variation the abstraction was built to plug into, or is this one interchangeable behavior on an otherwise fixed object? One axis → Strategy. Two axes, planned together → Bridge.
Bridge vs Adapter — retrofit vs designed-in
Both have an object holding a reference to another interface, so the shapes overlap — but the moment they're introduced is opposite:
- Adapter is reactive: some interface already exists (usually third-party, usually one you don't control) and you bolt a translator on afterward because the two sides were never designed to fit together.
- Bridge is proactive: you own both hierarchies and you deliberately split "what it does" (abstraction) from "how it does it" (implementation) before you write the first concrete subclass on either side.
Quantifying the scaling claim
Without Bridge, if you need every combination of M abstractions × N implementations as a concrete class (because each subclass inherits one fixed implementation), you need M × N classes. Example: 3 shapes (Circle, Square, Triangle) × 4 renderers (OpenGL, DirectX, Software, Vulkan) = 12 classes — CircleOpenGL, CircleDirectX, … one per pairing, and every new renderer multiplies by M again.
With Bridge, the abstraction holds a Renderer reference instead of inheriting one, so you need only M + N classes: 3 shape subclasses + 4 renderer subclasses = 7 classes total, and any shape can be paired with any renderer at construction time, with zero new classes.
The boundary case where Bridge does NOT pay: if there is only ever ONE implementation axis (N = 1), the un-bridged cost is M×1 = M classes, while the bridged cost is M+1 — Bridge adds a class (the single concrete implementor) for zero combinatorial savings, plus a permanent extra layer of indirection on every call. Don't introduce Bridge speculatively "in case we need a second renderer someday" — introduce it when N is already ≥ 2, or when the seam is independently justified (e.g. you need to substitute a fake implementor in tests even with one real one).
4. Decorator gotchas: order-dependence, and the canonical anchor
Order-dependence
Decorators compose by construction: new A(new B(new C(real))) means, at call time, A's transform runs first on the caller's data, then A forwards its result to B, then B forwards to C, then C forwards to the real object — construction order (outside-in reading) IS execution order.
For some decorators that order is commutative — stacking a "+2 cost" decorator and a "+3 cost" decorator on a coffee gives 5 either way, because each one just adds an independent number. But most real decorators are NOT commutative, because each one transforms the bytes the next one sees. The textbook trap: encrypt-then-compress is not compress-then-encrypt.
interface DataSource { byte[] write(byte[] data); }
class FileDataSource implements DataSource {
public byte[] write(byte[] data) { return data; } // base: stores raw bytes
}
abstract class DataSourceDecorator implements DataSource {
protected final DataSource wrappee;
DataSourceDecorator(DataSource wrappee) { this.wrappee = wrappee; }
}
class CompressionDecorator extends DataSourceDecorator {
CompressionDecorator(DataSource wrappee) { super(wrappee); }
public byte[] write(byte[] data) { return wrappee.write(gzip(data)); } // transform THEN forward
private byte[] gzip(byte[] d) { /* real gzip */ return d; }
}
class EncryptionDecorator extends DataSourceDecorator {
EncryptionDecorator(DataSource wrappee) { super(wrappee); }
public byte[] write(byte[] data) { return wrappee.write(aesEncrypt(data)); }
private byte[] aesEncrypt(byte[] d) { /* real AES-GCM */ return d; }
}
// GOOD: outer = Compression sees the real plaintext first (real ratio), THEN encrypts the small result
DataSource good = new CompressionDecorator(new EncryptionDecorator(new FileDataSource()));
// BAD: outer = Encryption runs first, so Compression only ever sees incompressible ciphertext
DataSource bad = new EncryptionDecorator(new CompressionDecorator(new FileDataSource()));
// Go's io.Writer chain is the idiomatic decorator — same outside-in rule applies.
type gzipWriter struct{ w io.Writer }
func (g gzipWriter) Write(p []byte) (int, error) {
zw := gzip.NewWriter(g.w) // compress p, forward compressed bytes to g.w
defer zw.Close()
return zw.Write(p)
}
type encryptWriter struct {
w io.Writer
block cipher.Block
}
func (e encryptWriter) Write(p []byte) (int, error) {
return e.w.Write(encrypt(e.block, p)) // encrypt p, forward ciphertext to e.w
}
// good: outer=gzip sees plaintext, compresses, hands compressed bytes to encrypt
good := gzipWriter{w: encryptWriter{w: file, block: key}}
// bad: outer=encrypt sees plaintext, encrypts, hands incompressible ciphertext to gzip
bad := encryptWriter{w: gzipWriter{w: file}, block: key}
The canonical anchor: java.io
The textbook Decorator, cited in nearly every interview answer, is Java's I/O stream stack: InputStream is the Component; FileInputStream is a ConcreteComponent; FilterInputStream is the abstract Decorator (it holds a wrapped InputStream and forwards read()); BufferedInputStream and GZIPInputStream are ConcreteDecorators that each override read() to transform what the wrapped stream returns before handing bytes up. new GZIPInputStream(new BufferedInputStream(new FileInputStream(path))) reads: open the file, buffer its raw bytes, decompress what the buffer hands up — same outside-in rule, same interface (InputStream) at every layer, which is exactly why any code that accepts an InputStream accepts the whole stack transparently.
5. Composite: the two senior probes
Composite's own page covers the mechanism (uniform Component interface, Leaf implements directly, Composite delegates to children and combines results). The two follow-ups that separate a senior answer are about the tree's other direction and about using the tree, not just building it.
Probe 1 — "how do you walk UP the tree, or remove a node?"
The textbook Composite only stores children, so it can only walk DOWN. Removing an arbitrary node, finding a node's path to root, or moving a node to a new parent all need to walk UP — which requires each composite to also store a parent back-reference, wired the moment a child is added and cleared the moment it's removed.
Probe 2 — the transfer probe: "print the org chart indented by depth" / "find an employee"
This is a direct test of whether you actually understand the recursion, not just the UML. Walking down is a depth-first recursive traversal: print the current node at the current indent, then recurse into each child at indent+1.
interface OrgComponent {
String getName();
List<OrgComponent> getChildren(); // empty list for a leaf
default void print(int depth) {
System.out.println(" ".repeat(depth) + getName());
for (OrgComponent child : getChildren()) child.print(depth + 1); // walk DOWN
}
}
class Department implements OrgComponent {
private final String name;
private Department parent; // walk UP — wired on add(), cleared on remove()
private final List<OrgComponent> children = new ArrayList<>();
Department(String name) { this.name = name; }
public String getName() { return name; }
public List<OrgComponent> getChildren() { return children; }
void add(OrgComponent child) {
children.add(child);
if (child instanceof Department d) d.parent = this; // wire the back-pointer
}
void remove(OrgComponent child) {
children.remove(child);
if (child instanceof Department d) d.parent = null;
}
List<String> pathToRoot() { // walking UP uses the back-reference
List<String> path = new ArrayList<>();
for (Department n = this; n != null; n = n.parent) path.add(0, n.name);
return path;
}
}
type OrgComponent interface {
Name() string
Children() []OrgComponent // nil/empty for a leaf
}
func Print(c OrgComponent, depth int) {
fmt.Println(strings.Repeat(" ", depth) + c.Name())
for _, child := range c.Children() {
Print(child, depth+1) // walk DOWN, same recursion shape
}
}
type Department struct {
name string
parent *Department // walk UP / remove-by-node
children []OrgComponent
}
func (d *Department) PathToRoot() []string {
var path []string
for n := d; n != nil; n = n.parent {
path = append([]string{n.name}, path...)
}
return path
}
Traced example — tree: Company → Engineering → {Backend → {Alice, Bob}, Frontend}. Calling company.print(0):
| Call | depth | printed line |
|---|---|---|
| company.print(0) | 0 | Company |
| ↪ engineering.print(1) | 1 | Engineering |
| ↪↪ backend.print(2) | 2 | Backend |
| ↪↪↪ alice.print(3) | 3 | Alice |
| ↪↪↪ bob.print(3) | 3 | Bob |
| ↪↪ frontend.print(2) | 2 | Frontend |
Each frame only knows its own depth and its own children — the indentation is entirely a function of the recursion depth passed down, never stored on the node.
6. Proxy: the caching-proxy thread-safety nuance
A caching proxy's entire job is "check the cache, and if absent, compute once and store it" — and the obvious Java one-liner hides a real hazard:
class CachingProxy implements DataService {
private final DataService real;
private final ConcurrentHashMap<String, Data> cache = new ConcurrentHashMap<>();
public Data fetch(String key) {
// computeIfAbsent holds a lock on the map's internal bin for `key`
// for the ENTIRE duration of the mapping function. If real.fetch is
// slow (network call), every other thread requesting a key that
// hashes into the SAME bin blocks until this call returns. And the
// JDK explicitly documents that the mapping function must not try
// to update this same map, or you risk a hang/infinite loop, or (in modern JDKs)
// an IllegalStateException ("Recursive update") — ConcurrentHashMap's
// iterators are explicitly documented as never throwing ConcurrentModificationException.
return cache.computeIfAbsent(key, real::fetch);
}
}
Mitigation — compute outside any map lock, then insert with putIfAbsent, accepting that two threads racing on the same brand-new key may occasionally both do the work (first writer wins, second result is discarded):
class SaferCachingProxy implements DataService {
private final DataService real;
private final ConcurrentHashMap<String, Data> cache = new ConcurrentHashMap<>();
public Data fetch(String key) {
Data cached = cache.get(key);
if (cached != null) return cached;
Data fresh = real.fetch(key); // compute OUTSIDE any map lock
Data winner = cache.putIfAbsent(key, fresh); // first writer wins, no lock held during fetch
return winner != null ? winner : fresh; // rare duplicate work, never a deadlock risk
}
}
The trade-off is explicit: computeIfAbsent gives you free cache-stampede protection (concurrent callers for the SAME key share one computation) at the cost of a lock held for the whole computation; the get-then-putIfAbsent pattern never blocks or deadlocks but can do the expensive work more than once under a race. If short and non-reentrant, accept the lock; if slow or possibly reentrant on the same map, use the safer pattern — or a dedicated single-flight mechanism.
// Go's sync.Map has no computeIfAbsent-with-lock hazard, but naive get-then-set still
// allows duplicate work under a race. golang.org/x/sync/singleflight solves exactly this:
// concurrent Fetches for the SAME key share one real.Fetch call; DIFFERENT keys never
// block each other — there is no cross-key bin lock to worry about.
var group singleflight.Group
var cache sync.Map
func Fetch(key string) (Data, error) {
if v, ok := cache.Load(key); ok {
return v.(Data), nil
}
v, err, _ := group.Do(key, func() (interface{}, error) {
d, err := real.Fetch(key)
if err == nil {
cache.Store(key, d)
}
return d, err
})
return v.(Data), err
}
Pitfalls
- Adapter — stacking adapter-on-adapter to paper over a bad boundary instead of fixing it; a "two-way" adapter that translates both directions gets confusing fast — prefer two one-way adapters.
- Facade — letting the facade become the ONLY entry point (it should stay optional); facades that accumulate unrelated convenience methods turn into a god-object / hidden Singleton bottleneck.
- Bridge — introducing it speculatively when the implementation axis is still N=1; pure indirection cost with no M+N payoff yet (YAGNI).
- Decorator — unbounded wrapper stacks make stack traces and debugging painful; if a decorator adds its OWN state, wrapped-object identity/equals() comparisons done by unaware callers can silently break.
- Composite — forgetting the parent back-reference until "delete a node" comes up in the interview; not deciding transparent-vs-safe up front (leaf's add() throwing at runtime vs the client downcasting) leaves an unowned design gap.
- Proxy — the computeIfAbsent bin-lock hazard above; also caching without a TTL/invalidation path, so the proxy serves stale data forever once warmed.
When to use which — the judgment layer, summarized
- Reach for Adapter when the interface mismatch already exists and you can't touch either side.
- Reach for Facade when you want to simplify entry into a subsystem without removing direct access to it.
- Reach for Bridge when you're designing two independent hierarchies together, up front, and N (implementations) is genuinely ≥ 2.
- Reach for Decorator when behavior needs to stack dynamically on one object behind one stable interface — and always check whether the stacking order matters for your specific decorators.
- Reach for Mediator when it's peers-to-peers wiring, not outside-to-subsystem calls, that's exploding.
- Reach for Proxy when the client must be unable to tell it isn't talking to the real object, and access needs to be gated, cached, or deferred.
Takeaways
- All six patterns wrap something; what differs is the verb — convert, simplify, decouple, add, centralize, or control. Memorize the verb, not the UML.
- Bridge and Strategy share a structure; only intent (two designed-up-front dimensions vs one swappable algorithm) tells them apart, and Bridge only earns its keep once the implementation axis is genuinely ≥ 2.
- Decorator order is not free — encrypt/compress ordering changes both output size and, in some protocol contexts, security; java.io's stream stack is the reference mental model for "same interface in, transformed data out."
- Composite's two senior probes — parent pointers to walk up, recursion to walk down — and Proxy's computeIfAbsent bin-lock trade-off are two of the most common "wait, does this actually work under load" questions in LLD interviews.
Related pages
- Adapter Pattern — OO & Low-Level Design — the intro treatment of the object/class adapter mechanics this page's discriminator section builds on
- Bridge Pattern — OO & Low-Level Design — base mechanics for the Bridge vs Strategy/Adapter discriminators and the M×N vs M+N scaling argument here
- Decorator Pattern — OO & Low-Level Design — the wrapping mechanics behind the order-dependence and java.io gotchas covered here
- Proxy Pattern — OO & Low-Level Design — the access-control mechanics behind the caching-proxy thread-safety nuance covered here
- Facade Pattern — OO & Low-Level Design — the subsystem-simplification mechanics contrasted against Adapter and Mediator in the discriminator matrix
Re-authored/Deepened for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive)** (OO & Low-Level Design) and want to truly understand it. Explain Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.