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

  1. The problem in PideYa: operations that don't fit in the menu
  2. The technical obstacle: Java picks methods with only one type
  3. Double dispatch, step by step
  4. Intent and structure of the pattern
  5. Complete Java implementation
  6. The two axes: what Visitor makes cheap and what it makes expensive
  7. The modern alternative: Java 21 pattern matching
  8. When to use it and when not to
  9. Relationship with other patterns
  10. Common mistakes
  11. 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: Dish accumulates 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 same if (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:

  1. The client calls node.accept(visitor) with node of static type MenuComponent.
  2. First dispatch (dynamic, by the node): the JVM picks the accept of the real class — Dish's, say. Inside that method, no doubt remains: this is a Dish, with static type Dish.
  3. That accept makes the second call: visitor.visit(this). Since this's static type is Dish, the compiler binds the visit(Dish) overload. Second dispatch (dynamic, by the visitor): the JVM picks the implementation of the visitor's real class — JsonExporter.visit(Dish).
  4. 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 accept traversal 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 accept in the base class (visitor.visit(this) with this of static type MenuComponent) either doesn't compile or binds the wrong overload; every concrete class must have its own accept, however identical they look — they're not duplication, they're the mechanism.
  • Reusing visitors without resetting: AllergenCalculator accumulates; running it over two menus in a row mixes results. A stateful visitor is single-use (or give it an explicit reset()).
  • Duplicated or missing traversal: if MenuSection.accept traverses children and the visitor also traverses them in visit(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.

© Copyright 2026. All rights reserved