Your library publishes a Visitor interface over a node hierarchy, and downstream teams implement it. You now must add a new node type. How do you evolve the interface without breaking every implementer, and what do you lose?
answer
- New element type = new interface method = breaks all implementers
- Default method → visitDefault fallback (throw vs no-op)
- Abstract base visitor as the published extension point
- javax.lang.model: versioned base visitors + visitUnknown
- Acyclic visitor: marker interface + per-type interfaces + runtime check
basics
~20 sAdding a method to a published interface breaks everyone implementing it. Give the new visit method a default implementation (or provide an abstract base visitor with defaults) so old code still compiles — but then a visitor that never handles the new node fails silently instead of at compile time.
solid answer
~50 sAdding `visit(NewNode)` to a published `Visitor` interface is a source- and binary-compatibility break for every downstream implementer. Options, in rough order of preference: (1) ship the method as a **default/interface default** delegating to a `visitDefault(node)` hook so existing implementers keep compiling and get an explicit, overridable fallback; (2) publish and document an **abstract base visitor** as the supported extension point, keeping the raw interface internal — then new methods land on the base with defaults and nobody breaks; (3) use the **acyclic visitor** variant, where each element checks `if (v is NewNodeVisitor)` at runtime, so visitors opt into the types they handle and adding a type breaks nobody; (4) version the interface (`Visitor2`) with a bridging adapter. What you lose in 1–3 is the compile-time exhaustiveness guarantee — the whole reason Visitor was chosen. Mitigate with a fallback that throws or logs loudly in strict mode, `@since` documentation, a deprecation window, and integration tests that assert every node kind is handled.
code
java · 12 linespublic interface NodeVisitor<R> {
R visitNumber(NumberNode n);
R visitAddition(AdditionNode a);
/** @since 2.0 — default keeps existing implementers source/binary compatible. */
default R visitLambda(LambdaNode l) { return visitUnknown(l); }
/** Fail loudly by default: a silent no-op turns a compile error into a wrong answer. */
default R visitUnknown(Node n) {
throw new UnsupportedOperationException("Visitor " + getClass() + " does not handle " + n.kind());
}
}go deeper
Note that adding a method to an interface breaks everyone who implements it, and that a default implementation avoids the break.
Present default methods plus a visitDefault fallback and the abstract-base-visitor alternative, and state that exhaustiveness checking is the price.
Compare defaults, base classes, acyclic visitor, and interface versioning with concrete costs, and add operational safety nets: strict mode, registry tests, telemetry, deprecation policy.
Treat it as API governance — binary vs source compatibility, versioned base visitors as in javax.lang.model, deprecation windows, codegen — and be willing to conclude that recurring element churn means the pattern was applied to the wrong axis and the extension point should be redesigned.
### Why this is a governance problem, not just a coding one Once a `Visitor` interface crosses an ownership boundary — an OSS library, an internal platform SDK, a published module API — its method set becomes part of your **public contract**. Interfaces are especially brittle here: unlike adding a method to a class you own, adding an abstract method to an interface breaks **every implementer**, at compile time and, in JVM/CLR terms, at link time (`AbstractMethodError` / `MissingMethodException` when an old implementation is loaded against a new interface). And Visitor guarantees this pain: its whole design says "one method per element type", so any new element type *must* touch the interface. You adopted Visitor because you believed the element hierarchy was stable; this question is what happens when that belief expires. ### Option 1 — default methods delegating to a fallback hook ``` interface NodeVisitor<R> { R visitNumber(Number n); R visitAddition(Addition a); // added in 2.0: default R visitLambda(Lambda l) { return visitDefault(l); } default R visitDefault(Node n) { throw new UnsupportedNodeException(n); } } ``` - **Keeps** source and binary compatibility: old implementers compile and link unchanged. - **Loses** exhaustiveness: nobody is *forced* to consider `Lambda`. - **Design choice inside the loss**: does the fallback *throw* (fail fast, loud, but turns a compile error into a production error) or *silently no-op / return a neutral value* (never crashes, but silently wrong analysis — usually worse)? Prefer throwing by default, with an explicit opt-in lenient mode, so the failure surfaces in the implementer's tests rather than in someone's data. - Requires a language with default/defender methods (Java 8+, Kotlin, C# 8+, TypeScript via abstract classes). C++/older languages need the abstract-base approach. ### Option 2 — publish an abstract base visitor as the supported extension point Keep the raw interface internal (or documented as "do not implement directly") and tell downstream users to extend `AbstractNodeVisitor`, which implements every method with a sensible default (typically: recurse into children, or delegate to `visitDefault`). New node types land as new methods with defaults on the base class; nobody breaks. This is precisely what mature ecosystems do: `javax.lang.model.util.SimpleElementVisitor` / `AbstractElementVisitor` (with `visitUnknown` for future language constructs), ANTLR's `AbstractParseTreeVisitor` + `BaseVisitor`, Roslyn's `CSharpSyntaxVisitor`/`CSharpSyntaxWalker`. Note the extra move in `javax.lang.model`: versioned base classes (`SimpleElementVisitor8`, `…9`, `…14`) so each language version gets a *new* base class and old subclasses never break — an explicit, disciplined answer to exactly this problem. ### Option 3 — acyclic visitor Robert Martin's variant. The base `Visitor` is a **marker interface** with no methods; each element type has its own tiny interface: ``` interface Visitor {} // marker, never changes interface LambdaVisitor extends Visitor { void visit(Lambda l) } class Lambda implements Node { accept(Visitor v) { if (v instanceof LambdaVisitor lv) lv.visit(this); else handleUnhandled(v, this); // policy: ignore, throw, or default } } ``` - **Adding an element type breaks nobody** — it adds a new interface that existing visitors simply don't implement. - **Visitors opt in** to the types they handle: a pass that only cares about lambdas implements one interface instead of twenty stubs. - **Costs**: a runtime type test per node (measurable in hot compiler loops), no compile-time exhaustiveness at all, and an unhandled-node policy you must define and test. Also removes the dependency cycle between the element and visitor hierarchies (hence the name), which matters for modular builds. ### Option 4 — interface versioning with adapters Publish `NodeVisitorV2` containing the new method, keep `NodeVisitor` frozen and deprecated, and ship an adapter that wraps a V1 visitor as a V2 (routing unknown nodes to a defined fallback). Verbose, but it makes the compatibility contract explicit and lets you eventually retire V1 on a stated schedule. Good when the change is large (several new node kinds, changed signatures) rather than a single addition. ### Recovering some of the lost safety Once exhaustiveness is no longer compiler-enforced, buy it back operationally: - **A registry test**: enumerate all node kinds (reflection, a sealed set, or a generated list) and assert each shipped visitor handles them, or is explicitly registered as intentionally partial. - **Strict mode**: `visitDefault` throws in tests/CI, logs-and-continues in production, or vice versa depending on your failure preference. - **Telemetry**: count `visitDefault` hits per node kind in production so silent gaps become visible. - **Documentation discipline**: `@since` on each new visit method, an explicit compatibility policy in the module docs, and a deprecation window with a migration note. - **Codegen**: if the node set is generated (parser generator, schema), regenerate visitors downstream rather than hand-maintaining them; the breakage becomes a build step, not a human task. ### The strategic answer The deepest point: **if you find yourself repeatedly adding element types to a published visitor, Visitor was the wrong pattern for that axis.** The right long-term fix may be to stop exposing an operation-extension point over a churning type set — invert to polymorphic methods on the nodes, expose a narrow capability interface, or expose a data representation (a generic tree with typed attributes) that outsiders traverse generically. Patching compatibility forever is treating the symptom.
- Default method or abstract base class — which do you publish?Publish the abstract base class as the supported extension point and treat the interface as closed for outside implementation; the base absorbs future methods without any compatibility story. Default methods are the right tool when you cannot force clients onto a base class or the language lacks one.
- Should the fallback throw or silently do nothing?Default to throwing, so an unhandled node surfaces in the implementer's tests instead of corrupting results; offer an explicit lenient mode for passes that are legitimately partial. A silent no-op turns a compile-time error into a wrong answer, which is strictly worse.
- What does acyclic visitor cost you?A runtime type test per node (real cost in hot traversals), no compile-time exhaustiveness whatsoever, and the need to define and test an explicit policy for nodes no visitor handles. You buy total additive freedom on the element axis with all of that.
- When should you conclude Visitor was simply the wrong choice?When the element hierarchy churns repeatedly across a published boundary. That means you bet on the wrong stable axis; the fix is to move operations onto the nodes, expose a narrow capability interface, or expose a generic traversable data representation rather than to keep patching compatibility.
Adding a mandatory field to a form everyone has already printed. You can attach an optional addendum with a default value (default method), reissue the whole form as version 2 with a conversion table (versioning), or make each section its own optional slip people pick up only if it applies (acyclic visitor).
saying these in an interview costs you the question
- Adding an abstract method to a published interface and calling it a minor version
- Making the default fallback a silent no-op, converting a compile error into a silently wrong result
- Assuming a default method restores exhaustiveness — it explicitly removes it
- Choosing acyclic visitor without accounting for the per-node runtime type check in hot traversals
- Never questioning whether the element hierarchy's churn means Visitor was the wrong pattern for that axis