Knowledge Guide
HomeOO & Low-Level DesignStructural

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.

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.

PatternReach for it when…Key distinguishing propertyvs. its closest cousin
Adapteryou have an existing, incompatible interface you cannot change, and must make it fit what the client expectsconverts ONE interface to another; wraps a single adaptee of a different interfacevs Facade: Adapter resolves an interface mismatch; Facade doesn't change any interface, it adds a simpler one in front
Facadea subsystem has many moving parts and most callers only need a common-case entry pointpurely additive / optional — the subsystem stays directly reachable for callers who need itvs Adapter: no mismatch is being resolved, just complexity being hidden
Bridgeyou know UP FRONT that an abstraction must vary independently from its implementation, along two separate axesdesigned-in at architecture time; the abstraction holds an implementor interface rather than inheriting an implementationvs Adapter: Adapter is retrofitted after the fact for one incompatible object; Bridge is planned before either hierarchy is written
Decoratoryou need to add responsibilities to ONE object dynamically, in any combination, without touching its classsame interface in and out; recursive/stackable wrapping; the order you wrap in changes the resultvs Proxy: Decorator adds behavior and always forwards the call onward
MediatorN objects need to talk to each other and the pairwise (N²) wiring itself is the problemcentralizes many-to-many communication into one hub; colleagues know only the mediator, never each othervs Facade: Facade simplifies calls into a subsystem from outside; Mediator centralizes calls among peers on the inside
Proxyyou need to control or manage access to an object — lazy init, caching, remote stub, permission checks — while looking identical to the real thingsame interface as the real subject; may not forward the call to the real object at allvs Decorator: Proxy controls whether/how the call reaches the real object; Decorator always forwards and layers extra behavior around it
Decision tree walking through the six structural pattern discriminators, gate by gate, ending in Adapter, Facade, Decorator, Proxy, Mediator, or Bridge
Decision tree walking through the six structural pattern discriminators, gate by gate, ending in Adapter, Facade, Decorator, Proxy, Mediator, or Bridge

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:

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:

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.

Traced comparison: compressing plaintext then encrypting yields about 372 bytes from a 1KB payload, while encrypting then trying to compress the ciphertext yields about 1075 bytes because ciphertext is incompressible
Traced comparison: compressing plaintext then encrypting yields about 372 bytes from a 1KB payload, while encrypting then trying to compress the ciphertext yields about 1075 bytes because ciphertext is incompressible
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):

Calldepthprinted line
company.print(0)0Company
↪ 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

When to use which — the judgment layer, summarized

Takeaways

Related pages


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes