Behavioral Patterns — Senior Gotchas & Follow-ups (Deep Dive)
Behavioral Patterns — Senior Gotchas & Follow-ups (Chain/Iterator/Mediator/Memento/Observer/State/Template/Visitor) (Deep Dive)
The behavioral pattern pages in this guide (Chain of Responsibility, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor) each nail the base mechanism, but a senior interviewer keeps pulling on the thread past the textbook diagram: what happens when a handler throws mid-chain? What happens when a collection changes while you're iterating it? What happens when a "reply" arrives while a dispatch loop is still running? This page arms exactly those follow-ups — the failure modes, the concurrency and lifecycle traps, and the judgment calls a senior engineer is expected to have already thought through before the interviewer asks.
1. Chain of Responsibility — what happens when a handler throws mid-chain?
Mechanism: each handler's call to the next handler sits directly on the current call stack — handler.handle(request) for GeneralSupportHandler literally contains the call to TechnicalSupportHandler.handle(request) — so an uncaught exception thrown inside a handler unwinds back through every handler already invoked before it, not forward into the handlers still waiting downstream. The request is not gracefully "lost" so much as the whole chain is violently aborted: BillingSupportHandler never runs, and the exception propagates out to whatever called the chain in the first place, with no defined outcome for the ticket at all.
The fix is a deliberate per-link policy, not an unguarded call to next:
- Continue — log/swallow this handler's failure and still invoke the next handler. Use when handlers are independent side effects (metrics, notifications) and one broken link shouldn't block the others.
- Abort — stop the chain and return a defined "chain failed" result. Use when handlers mutate shared state in a defined order and running out of order (or skipping one) would corrupt it.
- Dead-letter — route the failed request to a side channel (a queue, a log table) for manual/automated retry, independent of whether the rest of the chain also runs. Use when the request must never silently vanish, even if no handler can currently process it.
Java — wrapping the delegation call, not leaving it bare:
public abstract class SupportHandler {
enum FailurePolicy { CONTINUE, ABORT, DEAD_LETTER }
protected SupportHandler next;
private final FailurePolicy policy;
protected SupportHandler(FailurePolicy policy) { this.policy = policy; }
public final TicketResult handle(Ticket ticket) {
try {
TicketResult r = doHandle(ticket);
if (r != null) return r; // this handler resolved it
} catch (RuntimeException ex) {
switch (policy) {
case ABORT: throw ex; // stop the chain here
case DEAD_LETTER: deadLetterQueue.push(ticket, ex); // fall through to CONTINUE
case CONTINUE: log.warn("handler failed, continuing chain", ex); break;
}
}
return next != null ? next.handle(ticket) : TicketResult.unresolved(ticket);
}
protected abstract TicketResult doHandle(Ticket ticket); // null = "not mine, pass on"
}
Go — same policy, expressed as an explicit error return instead of an exception:
type FailurePolicy int
const (
Continue FailurePolicy = iota
Abort
DeadLetter
)
type Handler struct {
next *Handler
policy FailurePolicy
do func(Ticket) (result *TicketResult, err error) // nil result = "not mine"
}
func (h *Handler) Handle(t Ticket) TicketResult {
r, err := h.do(t)
if err != nil {
switch h.policy {
case Abort:
return TicketResult{Err: err} // stop the chain here
case DeadLetter:
deadLetterQueue.Push(t, err) // fall through to Continue
fallthrough
case Continue:
log.Printf("handler failed, continuing chain: %v", err)
}
} else if r != nil {
return *r // this handler resolved it
}
if h.next != nil {
return h.next.Handle(t)
}
return TicketResult{Unresolved: true}
}
The key discipline either way: never let a bare, unguarded call to the next link be the only thing standing between "handled" and "silently destroyed." Pick the policy per chain (or per handler) deliberately, the same way a senior engineer picks a retry policy for an RPC call.
2. Iterator — robust (GoF) vs fail-fast, and when each earns its cost
Mechanism: Java's default fail-fast iterators (ArrayList, HashMap) stamp a modCount on the collection when the iterator is created and re-check it on every next() call; any structural change made through a path other than the iterator's own remove() bumps modCount, so the very next next() call detects the mismatch and throws ConcurrentModificationException — this is a best-effort bug detector, not a concurrency guarantee (the Javadoc says so explicitly: it can also fail to detect a change and behave arbitrarily).
GoF's alternative — the robust iterator — flips who's responsible: the iterator registers itself with the collection at creation (and deregisters when done), and the collection is responsible for keeping every live, registered iterator's internal cursor consistent whenever a mutation happens through any path — e.g. removing an element before a registered iterator's cursor decrements that cursor so the next next() doesn't skip an element or double-return one, and an insertion behind the cursor shifts it forward. Iteration then completes without ever throwing, walking a consistent (if not perfectly deterministic) view of the collection as it evolves — at the cost of the collection permanently carrying registration bookkeeping (commonly a WeakHashMap<Iterator, CursorState>) for every live iterator, whether or not a mutation ever actually happens during that iteration.
When each:
- Fail-fast is the right default for ordinary single-threaded application code: it turns a silent bug ("I mutated a list while iterating it and got garbage results") into a loud, immediate exception at the exact call site — a debugging aid, not a correctness mechanism. It is deliberately cheap: one
intcompare pernext(), no registration bookkeeping. - Robust iteration earns its bookkeeping cost specifically when concurrent structural mutation during iteration is an expected, legitimate case in a single-threaded callback flow — the textbook example is exactly Observer's own notify loop: a subscriber's
update()callback callingunsubscribe(this)on itself mid-notification. You want that to just work, not throw. - Neither answers genuine multi-threaded access — that still needs external synchronization or a concurrent collection. In Java,
CopyOnWriteArrayList's iterator is the pragmatic middle ground: it snapshots the backing array at iterator-creation time, so any mutation during iteration is simply invisible to that iterator — no exception, no corrupted cursor, no registration bookkeeping — at the cost of copying the entire array on every write, which only pays off for read-heavy, write-rare collections (subscriber lists are the canonical case).
3. Mediator — re-entrancy: a reply arrives while the dispatch loop is still running
Mechanism: OfficeMediator.route(sender, event) loops over its registered Department colleagues and calls colleague.receive(event) on each in turn. If a department's receive() implementation itself calls mediator.send(replyEvent) to post an immediate reply — not deferred, called synchronously from inside the handler — that call re-enters route() while the outer loop's iteration over the same colleague list is still mid-flight, still lower on the call stack. Depending on how naively the mediator is written, this produces one of three failure shapes: (a) a ConcurrentModificationException if the reply causes a colleague to register/unregister while the outer loop iterates the same backing list; (b) a colleague being notified twice for what should be one logical event, if the reply re-starts iteration from the top; or (c) in the worst case, unbounded recursion — if FinanceDepartment's receive() for the reply itself sends a reply back to HRDepartment, the two departments can volley forever, each call adding another frame, until the stack overflows.
Traced: HR's budget request triggers an immediate reply that itself replies
Suppose HRDepartment.receive(BudgetRequest) immediately calls mediator.send(new ApprovalReply()), and (a genuine, if sloppy, bug) FinanceDepartment.receive(ApprovalReply) also immediately sends an acknowledgement back through the mediator:
| Call stack depth | Frame | What it's doing |
|---|---|---|
| 0 | route(BudgetRequest) | outer loop, currently at colleague = HR |
| 1 | HR.receive(BudgetRequest) | calls mediator.send(ApprovalReply) before returning |
| 2 | route(ApprovalReply) | a brand-new loop over the SAME colleague list, re-entered |
| 3 | Finance.receive(ApprovalReply) | calls mediator.send(AckReply) before returning |
| 4 | route(AckReply) | another re-entrant loop; if HR acks back again, depth 5, 6, 7… never unwinds |
Every one of these frames is still open — none of the outer calls have returned yet — so the "outer" route(BudgetRequest) loop at depth 0 is frozen mid-iteration for the entire duration of the nested reply storm underneath it. Even without an infinite volley, this is already fragile: if BillingDepartment was the third colleague in the original list and gets unregistered as a side effect of the depth-2 call, the depth-0 loop's iterator can throw or silently skip it.
Fix: a re-entrancy guard that queues instead of recursing
Treat the mediator as a single-threaded event loop: while a dispatch is in progress, a nested send() call does not recurse — it enqueues the event and returns immediately, and the original dispatch loop drains the queue (processing each event to completion, one at a time) after its own iteration finishes.
Java
public class OfficeMediator implements Mediator {
private final List<Department> colleagues = new ArrayList<>();
private final Deque<Event> pending = new ArrayDeque<>();
private boolean dispatching = false;
public void send(Event event) {
pending.addLast(event);
if (dispatching) return; // re-entrant call: queue it, DO NOT recurse
dispatching = true;
try {
while (!pending.isEmpty()) {
Event next = pending.pollFirst();
for (Department d : List.copyOf(colleagues)) { // snapshot: safe against
d.receive(next); // (un)registration mid-loop
}
}
} finally {
dispatching = false;
}
}
}
Go — the same event-loop shape; a goroutine-based system would route send calls through one dedicated dispatcher goroutine reading a channel, rather than sharing the dispatching flag across goroutines:
type Mediator struct {
colleagues []Department
pending []Event
dispatching bool
}
func (m *Mediator) Send(e Event) {
m.pending = append(m.pending, e)
if m.dispatching {
return // re-entrant call: queued above, do not recurse
}
m.dispatching = true
defer func() { m.dispatching = false }()
for len(m.pending) > 0 {
next := m.pending[0]
m.pending = m.pending[1:]
snapshot := append([]Department(nil), m.colleagues...) // safe against (un)registration
for _, d := range snapshot {
d.Receive(next)
}
}
}
Two independent fixes are stacked here, and both matter: the dispatching flag + queue stops re-entrant recursion (the reply storm now runs as a flat sequence of drained events instead of a growing call stack), and the snapshot copy of the colleague list stops a mid-loop (un)registration from corrupting the iteration — the two failure modes are related but not the same bug, and fixing only one leaves the other live.
4. Memento — the encapsulation-vs-persistence tension, resolved
Mechanism: GoF's Memento deliberately restricts state access to a wide interface visible only to the Originator (e.g. only Game can call snapshot.getBoardState()) and a narrow interface — often no methods at all — visible to the Caretaker/History, so History can store, pass around, and hand back Snapshot objects without ever being able to read or corrupt their internals. That opacity is exactly what breaks an external persistence mechanism: writing a save-game to disk with Java serialization, a JSON mapper, or a DB writer needs the persistence layer itself to read the object's fields — and if Snapshot is a private inner class with zero public accessors, an external serializer (which is not Game, and is not privileged code) structurally cannot get at the data it's meant to persist.
The resolution: move the encapsulation boundary from "structural inaccessibility" to "who is allowed to call the wide interface" — not eliminate it. A trusted persistence layer is allowed to read private state via reflection, which bypasses Java access modifiers by design (this is exactly how Java's own Serializable and libraries like Jackson normally work against private fields). So the fix is not "make the fields public" — that would let ordinary application code read/mutate save-game internals too, which is the exact thing Memento exists to prevent — it's: keep Snapshot's fields private, remove any public getters aimed at general callers, and let it implement Serializable (or annotate it for a reflection-based mapper) so the persistence layer specifically can serialize it, while ordinary calling code still cannot compile a call to a getter that doesn't exist.
// Snapshot: package-private class, private fields, no public getters for general callers.
// Serializable lets ONLY the (reflection-based) persistence layer read the private state --
// application code calling snapshot.getBoardState() still fails to compile.
class Snapshot implements java.io.Serializable {
private final int[][] boardState; // private: no public accessor for arbitrary callers
private final long savedAtMillis;
// Package-private constructor: only Game (same package) can create one.
Snapshot(int[][] boardState, long savedAtMillis) {
this.boardState = boardState;
this.savedAtMillis = savedAtMillis;
}
// Package-private "wide" accessors: only Game can call these to restore.
int[][] boardState() { return boardState; }
long savedAtMillis(){ return savedAtMillis; }
}
public class Game {
private int[][] board;
public Snapshot save() { return new Snapshot(deepCopy(board), System.currentTimeMillis()); }
public void restore(Snapshot s) { this.board = s.boardState(); }
}
public class History { // the Caretaker: only ever stores/returns opaque Snapshots
private final Deque<Snapshot> stack = new ArrayDeque<>();
public void push(Snapshot s) { stack.push(s); }
public Snapshot pop() { return stack.pop(); }
// History never calls boardState() or savedAtMillis() -- it doesn't have package access.
public void persistTopToDisk(java.io.ObjectOutputStream out) throws java.io.IOException {
out.writeObject(stack.peek()); // reflection-based serialization reads the private fields
}
}
The load-bearing insight: "no public accessors for arbitrary calling code" and "readable by a trusted persistence library" are not actually in conflict — reflection is the loophole that lets a serializer honor the encapsulation boundary Memento cares about (ordinary code can't read/mutate save internals) while still satisfying the one it doesn't (a trusted framework can read private fields to serialize them). The alternative some codebases use — a hand-written toBytes()/fromBytes() pair implemented inside Snapshot itself (or via Externalizable) — achieves the same thing without reflection, at the cost of writing the (de)serialization logic by hand.
5. Observer — auto-notify vs explicit notify, and the lapsed-listener leak (Observer's #1 senior gotcha)
5a. Should a setter auto-notify, or should the client call notify() explicitly?
Mechanism: GoF discusses both implementation shapes for when notifyObservers() actually fires. Auto-notify: every individual setter on the Subject ends by calling notifyObservers() itself (each of setTemperature(), setHumidity(), setPressure() triggers its own full notification pass). Explicit notify: setters just mutate state, and the client calls notifyObservers() once, after making however many related changes it wants to batch into one logical update.
Auto-notify guarantees observers can never observe a subject mid-update — every mutation is immediately visible — but it costs a redundant notification per field when several fields change together as one real-world event: WeatherStation.setMeasurements(temp, humidity, pressure) calling three auto-notifying setters internally fires three full observer refresh passes (each display redrawing three times) for what is conceptually one measurement update. Explicit notify fixes that redundancy — the client calls all three setters, then notifyObservers() once — but reopens a correctness gap: nothing forces the call. A caller who mutates state and forgets the trailing notifyObservers() leaves every observer silently stale, and neither the compiler nor the runtime catches the omission.
The trade-off in one line: auto-notify buys correctness-by-construction at the cost of redundant work on batched updates; explicit notify buys efficient batching at the cost of a forgettable, unenforced invariant. Libraries that need both often expose a beginUpdate()/endUpdate() pair — auto-notify per setter while outside a batch, suppressed and coalesced into one notification while inside one — giving the caller an opt-in escape hatch instead of a silent footgun.
5b. The lapsed-listener memory leak
Mechanism: when an Observer registers via weatherStation.addObserver(this), the Subject's internal list holds a strong reference to it. Garbage collection reclaims an object only once it becomes unreachable from any GC root — and the subject's observer list is itself a reachable path. A UI element that becomes logically dead (a closed dialog, a completed request handler, a dismissed page) is still reachable through that list unless something explicitly calls removeObserver — nothing about "the user closed the dialog" automatically severs that reference. The observer therefore lives forever: it accumulates in the list alongside every other observer that never unregistered (the "lapsed listener problem" — the collection has lapsed on ever cleaning up), it still runs its full update() logic on every future notification even though nothing reads the result, and if update() touches now-disposed UI resources, it can throw or corrupt state visibly.
Traced: three open/close cycles of a dialog that never unregisters
| Cycle | Action | observers list size | Live but dead-weight entries |
|---|---|---|---|
| 1 | Open StatisticsDisplay (registers) → close (no removeObserver) | 1 | 1 |
| 2 | Open a second StatisticsDisplay → close | 2 | 2 |
| 3 | Open a third StatisticsDisplay → close | 3 | 3 |
| N | ... | N | N |
Nothing here throws, and no test that only checks "did the dialog visually close" will catch it — the leak is invisible until a heap dump or a long-running-process memory graph shows the observer list growing without bound, and every notification doing more and more wasted work on objects nobody can see anymore.
Two fixes, layered
Fix 1 (the correct default): discipline. Call removeObserver(this) in every teardown path — a dispose()/onClose() lifecycle hook, a finally block, or (in managed UI frameworks) the framework's own unmount hook. This is cheap and precise, but relies on every call site remembering to do it.
Fix 2 (a safety net, not a replacement): weak references. Back the observer list with WeakReferences so the Subject's own reference to a dead Observer doesn't itself keep it reachable — the GC can reclaim the observer the moment no other strong reference exists, and the subject prunes cleared references lazily on the next notify pass.
// Java: weak-referenced observer list -- Subject's link no longer keeps a dead Observer alive.
public class WeatherStation {
private final List<WeakReference<Observer>> observers = new ArrayList<>();
public void addObserver(Observer o) { observers.add(new WeakReference<>(o)); }
public void notifyObservers() {
Iterator<WeakReference<Observer>> it = observers.iterator();
while (it.hasNext()) {
Observer o = it.next().get();
if (o == null) { it.remove(); continue; } // GC already reclaimed it -- prune
o.update(temperature, humidity, pressure);
}
}
}
Go has no idiomatic weak-reference collection type in general use (a weak package landed in Go 1.24 but is rarely reached for); the standard Go answer instead makes unregistering cheap and explicit — hand back a subscription handle at registration time whose sole job is Unsubscribe():
type Subscription struct{ id int; station *WeatherStation }
func (s Subscription) Unsubscribe() { s.station.remove(s.id) } // explicit, not weak-ref-based
func (w *WeatherStation) Subscribe(o Observer) Subscription {
id := w.nextID
w.nextID++
w.observers[id] = o
return Subscription{id: id, station: w}
}
Trade-off of weak references: they make cleanup timing indeterminate — you know a stale entry will eventually be pruned, but not exactly when (only on the next notify sweep, and only once the GC has actually collected it, which is itself nondeterministic) — and they add a per-notification liveness check for every observer. Treat them as a safety net under a design that should still call removeObserver in the common path, not as a substitute for that discipline; a codebase that relies solely on weak references to "eventually" clean up is trading a memory leak for silent, hard-to-reason-about GC-timing behavior.
6. State — entry/exit actions, hierarchical state machines, and State vs Strategy
Mechanism: the base State pattern's Draft/PendingApproval/Published classes each implement one method per event (submit(), approve(), reject()) and the concrete state itself decides the next state via context.setState(newState) — this captures the transition table but says nothing about work that must happen exactly once whenever any transition leaves or enters a given state, regardless of which event triggered it (e.g. "log a timestamp the instant a document enters Published," "release the reviewer lock the instant it leaves PendingApproval, whether by approval or rejection"). UML statecharts make this explicit with onEntry/onExit hooks declared on the State interface itself and invoked by the Context on every transition, in a fixed order: run the old state's onExit, swap the state reference, run the new state's onEntry. Centralizing this in the Context's transition method guarantees the hooks fire on every path in or out of a state, instead of relying on every individual event-handling method to remember to call them.
Hierarchical state machines (HSMs) let substates share a parent superstate's entry/exit actions and default transitions. PendingApproval and ChangesRequested can both be children of a superstate UnderReview: a cancel() event handled once on UnderReview transitions out of either child to Cancelled, without duplicating that transition on both children, and UnderReview's own onEntry/onExit (e.g. starting/stopping a review-SLA timer) fires exactly once for the whole time the document is under review — bouncing internally between PendingApproval and ChangesRequested never restarts the timer, because that transition never leaves the superstate at all. This is precisely the mechanism that keeps a state machine's transition count from growing with every orthogonal cross-cutting concern (a timer here, a lock there) multiplied across every substate.
// Java: entry/exit hooks fire on every transition, regardless of which event caused it.
public interface State {
default void onEntry(Document ctx) {}
default void onExit(Document ctx) {}
State submit(Document ctx);
State approve(Document ctx);
State reject(Document ctx);
State cancel(Document ctx);
}
public class Document {
private State state;
void transitionTo(State next) {
state.onExit(this); // exit the old state, unconditionally
this.state = next;
next.onEntry(this); // enter the new state, unconditionally
}
public void submit() { transitionTo(state.submit(this)); }
// approve(), reject(), cancel() follow the same shape
}
State vs Strategy: who decides what comes next, and who knows about siblings
Both patterns let an object swap runtime behavior via composition instead of a branching if/switch, and both are typically an interface plus concrete implementations held by reference — which is exactly why they get confused. The distinguishing question is who decides the next behavior, and does that behavior know about its siblings:
- A Strategy (
ShippingStrategy:StandardRoad/ExpressAir/InternationalSea) is stateless and swappable purely by the client's up-front choice.StandardRoadhas zero awareness thatExpressAirexists, and a strategy never replaces itself as a side effect of running — swapping only happens because the client explicitly picked a different one. - A State (
Draft/PendingApproval/Published) is expected to trigger its own replacement:Draft.submit()callscontext.setState(new PendingApproval()). Each concrete state typically knows about at least some of its siblings, because deciding the next state is the pattern's job.
Rule of thumb: if the "current mode" only ever changes because the client explicitly picked a different one, it's Strategy. If the object naturally moves between modes as a consequence of events happening to it — and that movement is part of what the object models — it's State.
7. Strategy — Null Object instead of a null strategy, and Strategy vs Command
Mechanism: ShippingService.calculate() written as return strategy.computeCost(order) throws NullPointerException the moment strategy is null — an uninitialized field, a missing config lookup, a factory method that returns null when no rule matches a given order type. The failure surfaces at the point of use, often far from wherever the null was actually introduced, which tempts every call site into defensively re-adding the exact if (strategy != null) checks the pattern was supposed to eliminate.
The disciplined fix is GoF's related Null Object idiom: implement a concrete strategy that represents "nothing configured" as a real, valid object rather than the absence of one.
public class NoShippingStrategy implements ShippingStrategy {
public BigDecimal computeCost(Order order) {
throw new UnsupportedShippingException(
"no shipping rule configured for " + order.destination()); // a specific, named failure
}
}
// Assign NoShippingStrategy instead of null wherever "unconfigured" is a real, reachable state.
// Every call site keeps calling strategy.computeCost(order) with ZERO null-checks --
// the "nothing configured" case is handled once, inside the object, not at every call site.
Whether the Null Object silently returns a safe default (e.g. BigDecimal.ZERO) or throws a specific, well-named exception is itself a judgment call — pick "silent default" only when zero cost is genuinely a meaningful, safe answer; pick "throws a specific exception" when unconfigured shipping is actually a bug that should surface loudly, just as a distinct, clearly-diagnosable failure instead of an anonymous NullPointerException three stack frames removed from the real cause.
Strategy vs Command
Both wrap a swappable unit of behavior behind one interface, so they get confused too. Strategy wraps an algorithm variant for computing one value — chosen once, invoked directly and synchronously, with no notion of queuing, logging, retrying, or undo (ShippingStrategy.computeCost() just returns an answer). Command wraps a request as an object specifically so that request can be stored, queued, logged, retried, or reversed later — it carries its own receiver and parameters, typically exposes both execute() and undo(), and is usually invoked indirectly (an invoker holds a Command without knowing what it does). Rule of thumb: if you need to store "the thing to do" for later replay or reversal, that's Command; if you just need to pick which algorithm computes an answer right now, that's Strategy. (This guide's Command pattern page covers transactional undo and macro-command atomicity in depth — this is just the boundary line against Strategy.)
8. Template Method — why the steps are protected, not public, and how to unit-test one in isolation
Mechanism: generateReport() — the template method shared by CSVReportGenerator and HTMLReportGenerator — calls a fixed sequence of steps (fetchData(), formatHeader(), formatBody(), formatFooter()), some implemented once in the abstract base and some deferred to subclasses. The template method itself is declared public final (or documented as "never override") because it is the contract: callers depend on the fixed shape of the algorithm, and letting a subclass override the orchestration itself would let it skip a step, reorder steps, or drop the final assembly — defeating the entire reason the algorithm was centralized in one place.
The individual steps are declared protected — not public, not private — because that access level is exactly what a step needs: overridable by a subclass, invisible to arbitrary outside callers. A step is an implementation detail of the algorithm, not something client code should ever invoke directly out of sequence (calling formatBody() alone, skipping fetchData(), produces a malformed report) — but it must still be visible to subclasses so they can supply their own formatting logic. Making it public would let any caller invoke steps out of order in production; making it private would make it impossible for a subclass to override at all, defeating Template Method entirely.
public abstract class ReportGenerator {
public final String generateReport(Query q) { // public + final: the fixed algorithm
Data data = fetchData(q);
StringBuilder out = new StringBuilder();
out.append(formatHeader(data));
out.append(formatBody(data)); // deferred, subclass-specific
out.append(formatFooter(data));
return out.toString();
}
protected Data fetchData(Query q) { return db.run(q); } // shared default, still overridable
protected abstract String formatHeader(Data d);
protected abstract String formatBody(Data d); // the step under test below
protected abstract String formatFooter(Data d);
}
Unit-testing a single step in isolation: because the step is protected rather than private, a test-only subclass in the same package can expose exactly that one step through a public pass-through, letting a test assert on that step's output against fixture data without running fetchData() or the rest of the template at all:
// Test-only subclass: exposes ONE protected step for direct assertion.
class TestableCSVReportGenerator extends CSVReportGenerator {
public String callFormatBody(Data fixture) { return formatBody(fixture); } // pass-through
}
@Test void formatBody_rendersOneRowPerRecord() {
var gen = new TestableCSVReportGenerator();
String csv = gen.callFormatBody(Data.of(List.of(row1, row2)));
assertEquals("a,b,c\nd,e,f\n", csv); // asserts on formatBody() alone, no DB call involved
}
If formatBody() were private, this test-subclass trick would not compile at all — only reflection could reach it. If it were public, any production caller could invoke it directly out of sequence, which is precisely the bug the pattern exists to prevent. protected is the deliberate middle: testable via inheritance, closed to arbitrary external callers.
9. Visitor — the Acyclic Visitor: is double dispatch really unavoidable?
Mechanism: classic double dispatch requires the element hierarchy (Clothing/Electronics/Food) to declare accept(Visitor v), which calls v.visit(this) — so every element class must reference the Visitor interface — and the Visitor interface must in turn declare a visit(Type) overload for every concrete element type, meaning it must reference every element class. The two hierarchies become mutually dependent: neither compiles without the other, and adding one new element subtype (say, a new Food subcategory) forces a matching visit() method onto every existing Visitor implementation — even ones with no opinion about food — or the build breaks. This coupling is real but, contrary to how the pattern is usually taught, not strictly inherent to "visiting" as a technique — it's a consequence of one specific way of implementing double dispatch.
Robert C. Martin's Acyclic Visitor breaks the cycle by never declaring one full-width Visitor interface with a visit() overload per element type. Instead:
- Elements declare
accept(VisitorBase v)against a degenerate base marker interface —VisitorBasehas no methods at all. - Each element type additionally declares its own narrow, single-method capability interface — e.g.
ClothingVisitorwith justvisitClothing(Clothing). - A concrete visitor implements only the capability interfaces for the element types it actually cares about —
DiscountVisitorcan implementClothingVisitorandElectronicsVisitorbut skipFoodVisitorentirely if it has no opinion on food. - Each element's
accept()does an unavoidable runtime type check to discover whether the visitor passed in implements this element's capability interface, and does nothing (or a defined default) if it doesn't.
public interface VisitorBase {} // degenerate: zero methods
public interface ClothingVisitor extends VisitorBase { void visitClothing(Clothing c); }
public interface ElectronicsVisitor extends VisitorBase { void visitElectronics(Electronics e); }
public interface FoodVisitor extends VisitorBase { void visitFood(Food f); }
public class Clothing implements Item {
public void accept(VisitorBase v) {
if (v instanceof ClothingVisitor cv) cv.visitClothing(this); // runtime capability check
// else: this visitor doesn't care about Clothing -- silently a no-op
}
}
// This visitor never references FoodVisitor or Electronics at all --
// adding a brand-new Beverage element type tomorrow does not touch this class or force a rebuild.
public class DiscountVisitor implements ClothingVisitor {
public void visitClothing(Clothing c) { c.applyDiscount(0.20); }
}
The trade-off: the compile-time dependency cycle is gone — a new element type no longer forces every existing visitor to change or rebuild, since a visitor's compiled code simply never references a capability interface it doesn't implement — at the direct cost of compile-time exhaustiveness. Classic Visitor makes the compiler force every visit() overload to exist; Acyclic Visitor makes "I meant to handle Food but forgot to implement FoodVisitor" a silent runtime no-op instead of a build failure, plus a small per-accept()-call runtime type check that classic Visitor's compile-time dispatch doesn't pay.
Use classic double-dispatch Visitor when the element hierarchy is genuinely stable (new element types are rare) and you want the compiler to force every visitor to account for every type. Reach for Acyclic Visitor specifically when the element hierarchy is still evolving and you cannot afford every existing visitor to break or rebuild each time a new element type ships — the textbook case is a plugin architecture where element types and visitors are shipped from independently-versioned modules that don't all rebuild together.
Judgment layer — quick reference
| Question | Answer |
|---|---|
| State vs Strategy — my object swaps behavior via an interface + concrete implementations. Which pattern is this? | Does the current behavior ever trigger its own replacement, aware of its siblings? State. Does it only change because the client explicitly picked a different one, with no self-awareness of alternatives? Strategy. |
| Command vs Strategy — both wrap "a thing to do" behind one interface. Which one? | Do you need to store it, queue it, log it, or undo it later? Command. Do you just need to pick which algorithm computes an answer right now, synchronously? Strategy. |
| Robust vs fail-fast Iterator — my collection might mutate while I iterate it. Which iterator style? | Is mutation-during-iteration an unexpected bug you want surfaced loudly and cheaply? Fail-fast (the default). Is it an expected, legitimate case in single-threaded callback code (e.g. a subscriber unsubscribing itself mid-notify)? Robust iteration (or a snapshot-based collection like CopyOnWriteArrayList). Is it genuinely multi-threaded? Neither alone — add real synchronization or a concurrent collection. |
Pitfalls
- Chain of Responsibility — the bare, unguarded delegation call. If a handler's call to the next handler isn't wrapped in a try/catch tied to an explicit policy (continue / abort / dead-letter), an exception mid-chain aborts every downstream handler and unwinds through every upstream one, with no defined outcome for the request.
- Iterator — relying on fail-fast as a correctness mechanism.
ConcurrentModificationExceptionis best-effort and explicitly documented as unreliable under real concurrency; it is a debugging aid for single-threaded misuse, not a substitute for synchronization or a concurrent collection. - Mediator — an unguarded
send()inside areceive()handler. Without a re-entrancy guard (a dispatching flag + queue) and a snapshotted colleague list, a reply sent from inside a handler can corrupt the outer loop's iteration or recurse without bound. - Memento — reaching for public getters to "fix" serialization. Making memento fields public defeats the pattern's entire purpose (arbitrary code can now read/mutate save internals); the fix is a reflection-based (or hand-written) serializer that respects the original access boundary, not a wider API surface.
- Observer — the lapsed listener. The single most common Observer bug in long-running applications: an observer that's logically dead but never called
removeObserverstays strong-referenced forever, silently growing the subscriber list and doing wasted work on every notification. Weak references are a safety net, not a substitute for explicit unregistration. - Observer — forgetting the trailing
notifyObservers()under an explicit-notify design. Nothing enforces the call; a caller who mutates state and forgets to notify leaves every observer silently stale with no error anywhere. - State — event-handling methods that forget to call
onEntry/onExitindividually. Centralize the hooks in theContext's transition method itself, not in every event handler, or some transition path will eventually skip them. - Strategy — null strategy fields instead of a Null Object. A
nullstrategy fails far from its cause with a genericNullPointerException; a Null Object fails (or safely no-ops) at the exact point the missing configuration matters, with a specific, diagnosable error. - Template Method — making a step
public"just to make it easier to test." That reopens the exact out-of-order-invocation bug the pattern exists to prevent; use a test-only subclass exposing theprotectedstep instead of loosening production access. - Visitor (Acyclic) — silent no-ops instead of compile errors. A visitor that forgot to implement a capability interface for a type it actually needs to handle compiles cleanly and silently does nothing at runtime; this is the direct price of breaking the dependency cycle, and needs its own test coverage to catch.
Takeaways
- Almost every senior follow-up on a behavioral pattern is really a question about a boundary case the base mechanism didn't specify: what happens on failure (Chain), on concurrent mutation (Iterator, Mediator), on cleanup that never happens (Observer), or on a persistence need the encapsulation model didn't anticipate (Memento) — name the boundary case explicitly instead of hand-waving past it.
- The two costliest bugs here share a shape: something is kept reachable (an observer in a list, a re-entrant call frame) past the point where it's logically "done," and the fix is always to make the lifecycle explicit — unregister, drain a queue, snapshot before iterating — rather than hoping the runtime notices on its own.
- State vs Strategy, Command vs Strategy, and robust vs fail-fast Iterator are all the same kind of judgment call: two mechanisms that look structurally identical (an interface plus swappable implementations) but differ in who decides the next step and what guarantee you actually need — know the deciding question, not just the diagram.
- Breaking a real constraint (Visitor's dependency cycle, Memento's encapsulation-vs-persistence tension) always trades one property for another — Acyclic Visitor trades compile-time exhaustiveness for decoupling; a reflection-based Memento trades "no public accessors" for "trusted-only accessors." Know exactly what you gave up, not just what you gained.
Related pages
- Creational Patterns — Senior Objections, Concurrency & Edge Gotchas (Deep Dive) — companion deep dive, same format, for creational patterns
- Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive) — companion deep dive for structural patterns
- Design Patterns — The Discriminators & Taxonomy (Deep Dive) — the taxonomy this deep dive assumes as background
- OOD Foundations & SOLID — The Precise Forms & Interview Tripwires (Deep Dive) — foundational OOD judgment calls behind these patterns
🤖 Don't fully get this? Learn it with Claude
Stuck on Behavioral Patterns — Senior Gotchas & Follow-ups (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 **Behavioral Patterns — Senior Gotchas & Follow-ups (Deep Dive)** (OO & Low-Level Design) and want to truly understand it. Explain Behavioral Patterns — Senior Gotchas & Follow-ups (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 **Behavioral Patterns — Senior Gotchas & Follow-ups (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 **Behavioral Patterns — Senior Gotchas & Follow-ups (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 **Behavioral Patterns — Senior Gotchas & Follow-ups (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.