PideYa's Composite menu has spent two modules collecting suitors: exporting it to JSON for the app, computing each section's accumulated allergens, auditing prices against the chain's policy... Every new operation threatens to fatten Dish and MenuSection with methods that are none of their business — what is exportToJson() doing in a domain class? Visitor inverts the arrangement: the hierarchy is frozen with a single entry method (accept), and each new operation is a separate visitor class, addable without touching a single node. The price of the trick is the catalog's highest in conceptual complexity — a mechanism called double dispatch that we'll walk through step by step — and a very concrete commitment: the hierarchy must be stable, because every new node breaks all the visitors.
Contents
- The problem in PideYa: operations that don't fit in the menu
- The technical obstacle: Java picks methods with only one type
- Double dispatch, step by step
- Intent and structure of the pattern
- Complete Java implementation
- The two axes: what Visitor makes cheap and what it makes expensive
- The modern alternative: Java 21 pattern matching
- When to use it and when not to
- Relationship with other patterns
- Common mistakes
- Exercises and conclusion
The problem in PideYa: operations that don't fit in the menu
Recall the module-3 hierarchy: MenuComponent with children Dish (leaf), MenuSection (recursive group), and Combo (a composition of dishes with its own price). The domain operations (price, availability) live inside, and they belong there. But the requests arriving now are of a different species:
- Export to JSON for the mobile app (an integration concern, not a domain one).
- Compute allergens aggregated per section (the food-safety team's concern).
- Audit prices (any dish below cost? combos more expensive than the sum of their parts?) — a business-finance concern.
First impulse: one method per operation on MenuComponent, implemented in every node. Three operations × three classes = nine methods... and the problems:
- The hierarchy becomes a junk drawer:
Dishaccumulates integration, health, and finance responsibilities (SRP pulverized); every team touches the same central classes, with conflicts and cross-cutting deployments. - Every new operation modifies the whole hierarchy (goodbye OCP on the operations axis).
- The artisanal alternative — traversing from outside with
instanceof— scatters through the code the sameif (x instanceof Dish)... else if (x instanceof MenuSection)...that Iterator came to eradicate, and the compiler doesn't warn when a case is missing.
What we want, stated precisely: the operations outside the hierarchy (one class per operation), but with safe dispatch by node type (the compiler forcing coverage of dishes, sections, and combos). There's a serious technical obstacle in the way.
The technical obstacle: Java picks methods with only one type
Let's try the external operation with overloading:
public class JsonExporter {
public String export(Dish dish) { /* ... */ }
public String export(MenuSection section) { /* ... */ }
public String export(Combo combo) { /* ... */ }
}
MenuComponent node = menu.getChildren().get(0); // static type: MenuComponent
exporter.export(node); // COMPILE ERROR!It doesn't compile (there's no export(MenuComponent)), and it's the symptom of a deep rule: overload resolution happens at compile time using the argument's static type. Java only dispatches dynamically on one type: that of the call's receiver (object.method() picks the implementation according to object's real class). That is single dispatch. Choosing a method based on two real types at once — which operation? and which node? — is double dispatch, and Java doesn't ship with it. Visitor is, in essence, the trick to simulate it with two single-dispatch calls.
Double dispatch, step by step
The full trick in four steps. Read it twice; it's the heart of the lesson:
- The client calls
node.accept(visitor)withnodeof static typeMenuComponent. - First dispatch (dynamic, by the node): the JVM picks the
acceptof the real class —Dish's, say. Inside that method, no doubt remains:thisis aDish, with static typeDish. - That
acceptmakes the second call:visitor.visit(this). Sincethis's static type isDish, the compiler binds thevisit(Dish)overload. Second dispatch (dynamic, by the visitor): the JVM picks the implementation of the visitor's real class —JsonExporter.visit(Dish). - Result: the method matching the combination (real node, real visitor) has run. Two chained virtual calls = double dispatch.
sequenceDiagram
participant C as Client
participant P as Dish (real node)
participant V as JsonExporter (real visitor)
C->>P: accept(visitor)
Note over P: 1st dispatch: the JVM picked<br/>Dish's accept(),<br/>here this IS Dish
P->>V: visit(this)
Note over V: 2nd dispatch: JsonExporter.visit(Dish)<br/>executes
V-->>C: (operation applied to the exact type)
The sentence to remember it by: the node doesn't perform the operation; the node confesses its type by calling the visitor's correct overload. Every node's accept looks identical (visitor.visit(this)) but each binds a different overload, because this's static type differs in each class.
Intent and structure of the pattern
Intent (GoF): represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates.
classDiagram
class MenuVisitor {
<<interface>>
+visit(dish: Dish)
+visit(section: MenuSection)
+visit(combo: Combo)
}
class MenuComponent {
<<abstract>>
+accept(v: MenuVisitor)*
}
class Dish {
+accept(v: MenuVisitor)
}
class MenuSection {
+accept(v: MenuVisitor)
}
class Combo {
+accept(v: MenuVisitor)
}
class AllergenCalculator {
-accumulated: Set~Allergen~
+visit(dish) +visit(section) +visit(combo)
}
class JsonExporter {
+visit(dish) +visit(section) +visit(combo)
}
class PriceAuditor {
-findings: List~String~
+visit(dish) +visit(section) +visit(combo)
}
MenuComponent <|-- Dish
MenuComponent <|-- MenuSection
MenuComponent <|-- Combo
MenuVisitor <|.. JsonExporter
MenuVisitor <|.. AllergenCalculator
MenuVisitor <|.. PriceAuditor
MenuComponent ..> MenuVisitor : accept(v) calls v.visit(this)
| GoF role | In PideYa |
|---|---|
Visitor (one visit overload per node type) |
MenuVisitor |
| ConcreteVisitor (one class per operation) | JsonExporter, AllergenCalculator, PriceAuditor |
Element (declares accept) |
MenuComponent |
| ConcreteElement | Dish, MenuSection, Combo |
| ObjectStructure (where the nodes live) | the Composite menu from module 3 |
Complete Java implementation
The only change to the hierarchy — made once and frozen. It's the pattern's honest concession: to never touch the hierarchy again, you must touch it once:
public interface MenuVisitor {
void visit(Dish dish);
void visit(MenuSection section);
void visit(Combo combo);
}
public abstract class MenuComponent {
// ... everything from module 3 intact ...
public abstract void accept(MenuVisitor visitor);
}
public class Dish extends MenuComponent {
@Override
public void accept(MenuVisitor visitor) {
visitor.visit(this); // this is Dish → binds visit(Dish)
}
}
public class MenuSection extends MenuComponent {
@Override
public void accept(MenuVisitor visitor) {
visitor.visit(this); // binds visit(MenuSection)
for (MenuComponent child : getChildren()) {
child.accept(visitor); // composite traversal: here
}
}
}
public class Combo extends MenuComponent {
@Override
public void accept(MenuVisitor visitor) {
visitor.visit(this); // binds visit(Combo)
// design decision: the combo does NOT traverse its internal dishes
// (each visitor decides in visit(Combo) whether it cares about them)
}
}Note the embedded decision: MenuSection.accept traverses its children — the traversal lives in the structure, and the visitors receive the nodes one by one without knowing how to navigate. (An equally valid alternative: traversal in the visitor, or delegated to an Iterator; what matters is deciding once and documenting it.)
First operation: the allergens. A stateful visitor: it accumulates during the traversal and is harvested at the end — the typical idiom:
public class AllergenCalculator implements MenuVisitor {
private final Set<Allergen> accumulated = EnumSet.noneOf(Allergen.class);
@Override
public void visit(Dish dish) {
accumulated.addAll(dish.getAllergens());
}
@Override
public void visit(MenuSection section) {
// nothing: the allergens are in the leaves; the children will arrive on their own
}
@Override
public void visit(Combo combo) {
combo.getDishes().forEach(d -> accumulated.addAll(d.getAllergens()));
}
public Set<Allergen> getResult() {
return Set.copyOf(accumulated);
}
}Second operation: the price audit — the one that shows why per-type overloads are worth their weight in gold: each node type has its own rule:
public class PriceAuditor implements MenuVisitor {
private final List<String> findings = new ArrayList<>();
@Override
public void visit(Dish dish) {
if (dish.getPrice().compareTo(dish.getCost()) <= 0) {
findings.add("Dish below cost: " + dish.getName());
}
}
@Override
public void visit(MenuSection section) {
if (section.getChildren().isEmpty()) {
findings.add("Empty section published: " + section.getName());
}
}
@Override
public void visit(Combo combo) {
BigDecimal sumOfParts = combo.getDishes().stream()
.map(Dish::getPrice).reduce(BigDecimal.ZERO, BigDecimal::add);
if (combo.getPrice().compareTo(sumOfParts) > 0) {
findings.add("Combo more expensive than its parts: " + combo.getName());
}
}
public List<String> getFindings() { return List.copyOf(findings); }
}Usage — and here the whole pattern shows in three lines:
PriceAuditor auditor = new PriceAuditor();
menu.accept(auditor); // one traversal, exact dispatches
auditor.getFindings().forEach(EventLog.INSTANCE::warn);Tomorrow's new operation ("count vegan dishes per section", "generate the menu's PDF") will be a new class implementing MenuVisitor — neither Dish, nor MenuSection, nor Combo will ever be touched again. And if the visitor interface gains a method, the compiler forces every visitor to take a stance: per-type coverage guaranteed, with no instanceof and no forgotten cases.
The two axes: what Visitor makes cheap and what it makes expensive
The pattern is an exchange of extensibility axes, and choosing it well requires seeing the full table:
| Change | Without Visitor (methods in the hierarchy) | With Visitor |
|---|---|---|
| New operation | Touch every node class | One new visitor class; nodes intact |
| New node type | One new class; existing operations intact (each implemented inside it) | Touch the visitor interface and ALL the visitors |
That second row is the pattern's famous price: if the menu gains a Combo node tomorrow, you must add visit(Combo) to MenuVisitor and to every existing visitor (at least the compiler will point them all out). Hence the canonical decision rule: Visitor when the hierarchy is stable and the operations proliferate; methods in the hierarchy when the types proliferate and the operations are stable. PideYa's menu fits the profile: Dish/MenuSection/Combo haven't changed since module 3, while three teams queue up with new operations.
Two other minor but real costs: double dispatch bewilders those who don't know it (the flow bounces between classes; document the pattern by name), and visitors only see the nodes' public API — if an operation needs private guts, you either open accessors (weakening the encapsulation that Memento protected so carefully) or that operation belongs inside.
The modern alternative: Java 21 pattern matching
The promised mention: since Java 21, pattern-matching switch and sealed hierarchies attack the same problem without the ceremony:
public sealed interface MenuComponent permits Dish, MenuSection, Combo { }
public String exportJson(MenuComponent node) {
return switch (node) {
case Dish d -> "{\"dish\":\"" + d.getName() + "\",\"price\":" + d.getPrice() + "}";
case MenuSection s -> "{\"section\":\"" + s.getName() + "\",\"children\":["
+ s.getChildren().stream().map(this::exportJson)
.collect(joining(",")) + "]}";
case Combo c -> "{\"combo\":\"" + c.getName() + "\",\"price\":" + c.getPrice() + "}";
// no default: being sealed, the compiler DEMANDS covering all types
};
}The crucial part is sealed: the hierarchy declares its permitted children, and the default-less switch enforces exhaustiveness — the same "new operation without touching nodes + compiler-verified per-type coverage" guarantee that Visitor achieved with double dispatch, now with one function and zero accept methods. Furthermore, if a new node appears, every default-less switch stops compiling: the same warning the visitors gave. In Java 21+ code with sealed hierarchies, this is today's default option for external operations; Visitor keeps the edge when the operation is large and stateful (a class organizes it better than a mile-long switch), when the hierarchy can't be sealed (plugins, third-party nodes), or in codebases predating Java 21.
When to use it and when not to
Use it when:
- A stable object structure needs many mutually unrelated operations, and putting them inside would pollute the classes (or different teams would be signing them).
- You need compiler-guaranteed per-type dispatch when operating from outside (no unguarded
instanceof). - The operations need to accumulate state during the traversal (exporters, auditors, calculators).
Avoid it when:
- The hierarchy changes often: every new node breaks all the visitors — you'd be buying extensibility on the wrong axis.
- There are one or two stable operations: the ceremony (interface + accept in every node) doesn't pay off; ordinary methods or a sealed switch suffice.
- You work in Java 21+ with a sealed hierarchy and the operations are simple functions: pattern matching gives the same with much less.
Relationship with other patterns
- Composite: its historic partner — the composite defines the structure, the visitor adds operations to it. With Iterator they form the structure/traversal/operations trio; in fact the
accepttraversal can be delegated to the previous module's iterator. - Interpreter: ASTs are Visitor's classic habitat (evaluating, printing, optimizing the tree as different visitors) — we mentioned it there.
- Iterator: Iterator hands out uniform elements (our
Iterator<Dish>flattened); Visitor distinguishes each type of node. Choose by that question: traverse, or dispatch? - Command: a stateful visitor that records per-node actions can generate commands to execute later.
Common mistakes
- Breaking double dispatch with shortcuts: an
acceptin the base class (visitor.visit(this)withthisof static typeMenuComponent) either doesn't compile or binds the wrong overload; every concrete class must have its ownaccept, however identical they look — they're not duplication, they're the mechanism. - Reusing visitors without resetting:
AllergenCalculatoraccumulates; running it over two menus in a row mixes results. A stateful visitor is single-use (or give it an explicitreset()). - Duplicated or missing traversal: if
MenuSection.accepttraverses children and the visitor also traverses them invisit(MenuSection), each node gets visited twice; if nobody does, none does. Decide the traversal's owner (structure or visitor) and be consistent across the whole hierarchy. - Opening up the nodes' guts to serve a visitor (internal-state getters "just for the export"): if the operation needs private data, its place is inside the class, not in a visitor.
- Applying Visitor to volatile hierarchies "because it's the elegant pattern": reread the two-axes table; with changing types, it's exactly the opposite of the right choice.
Exercises
Exercise 1: the menu counter
Implement MenuStats implements MenuVisitor, which in a single traversal counts dishes, sections, and combos, and computes the dishes' average price. Also write the three lines of usage.
Exercise 2: tracing double dispatch with your finger
Given MenuComponent node = new Combo("Lunch special", ...) and MenuVisitor v = new PriceAuditor(), the call is node.accept(v). Enumerate, in order: (1) which method the JVM picks in the first dispatch and by what criterion; (2) which overload the compiler binds inside that method and why; (3) which implementation the JVM executes in the second dispatch. Point out which decision is compile-time and which runtime.
Exercise 3: Visitor or pattern matching?
For each scenario, choose classic Visitor or pattern-matching switch over a sealed hierarchy, and justify: (a) a Java 17 project (no full pattern matching for switch), a stable menu, five operations expected; (b) Java 21, sealed hierarchy, a single "maximum tree depth" function; (c) Java 21, but the menu's nodes may be contributed by third-party modules (open hierarchy), and the export accumulates complex state.
Solutions
Solution 1:
public class MenuStats implements MenuVisitor {
private int dishes, sections, combos;
private BigDecimal priceSum = BigDecimal.ZERO;
@Override public void visit(Dish dish) {
dishes++;
priceSum = priceSum.add(dish.getPrice());
}
@Override public void visit(MenuSection section) { sections++; }
@Override public void visit(Combo combo) { combos++; }
public String summary() {
BigDecimal average = dishes == 0 ? BigDecimal.ZERO
: priceSum.divide(BigDecimal.valueOf(dishes), 2, RoundingMode.HALF_UP);
return dishes + " dishes, " + sections + " sections, " + combos
+ " combos; average price " + average + " €";
}
}
MenuStats stats = new MenuStats();
menu.accept(stats);
System.out.println(stats.summary());Solution 2: (1) first dispatch — the JVM picks Combo.accept(MenuVisitor) according to node's real class (Combo), a runtime decision; (2) inside that method, the compiler binds visit(Combo) because the static type of this in Combo.accept is Combo — a compile-time decision (overload selection); (3) second dispatch — the JVM executes PriceAuditor.visit(Combo) according to the visitor's real class, a runtime decision. That sandwich — dynamic, static, dynamic — is the complete double dispatch.
Solution 3: (a) Visitor: without exhaustive pattern matching available, it's the only one that gives compiler-verified per-type coverage, and with five operations the ceremony pays off; (b) pattern-matching switch: a small function, a sealed hierarchy with guaranteed exhaustiveness — a visitor for this is empty liturgy; (c) Visitor: the hierarchy can't be sealed (the switch loses exhaustiveness, its big argument) and the complex stateful operation fits better in a visitor class; besides, the third-party modules can ship their accept already implemented, something a central switch cannot anticipate.
Conclusion
Visitor closes the behavioral catalog with its characteristic trade: the menu got frozen behind a single accept per node, and the operations — export, allergens, audit, statistics — each live in their own class, addable without grazing Dish, MenuSection, or Combo, with the compiler guaranteeing per-type coverage. You understood the engine from inside — two chained single dispatches simulating the double dispatch Java doesn't have — and its exact bill: a stable hierarchy or broken visitors, plus the modern alternative of pattern matching over sealed hierarchies that in Java 21+ covers the simple cases with far less noise.
And with that, all eleven are on the table. Eleven behavioral patterns in eleven lessons: requests that travel along chains, freeze into commands, and get interpreted as languages; traversals, mediations, and photographs; observers, states, strategies, templates, and visitors. Too many to choose from memory: what's missing is the lesson that puts them face to face — the pairs everyone confuses, the decision flowchart, the combinations that work together — and that reviews what got installed in every corner of PideYa. See you in Comparing and Choosing Behavioral Patterns.
Software Design Patterns Course
Module 1: Introduction to Design Patterns
- What Are Design Patterns?
- History and Origin of Design Patterns
- Design Principles: SOLID and Other Foundations
- Essential UML for Understanding Patterns
- Classification of Design Patterns
- Advantages and Disadvantages of Using Design Patterns
Module 2: Creational Patterns
- Introduction to Creational Patterns
- Singleton
- Factory Method
- Abstract Factory
- Builder
- Prototype
- Comparing and Choosing Creational Patterns
Module 3: Structural Patterns
- Introduction to Structural Patterns
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- Comparing and Choosing Structural Patterns
Module 4: Behavioral Patterns
- Introduction to Behavioral Patterns
- Chain of Responsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
- Comparing and Choosing Behavioral Patterns
Module 5: Applying Design Patterns
- How to Select the Right Pattern
- Practical Examples of Pattern Usage
- Design Patterns in Real Projects
- Refactoring with Design Patterns
- Anti-Patterns: When Patterns Become a Problem
Module 6: Advanced Design Patterns
- Design Patterns in Modern Architectures
- Design Patterns in Microservices
- Design Patterns in Distributed Systems
- Concurrency Patterns
- Design Patterns in Agile Development
