The Visitor pattern is the most awkward design in the GoF catalog. To total the price of two kinds of products, you write a Visitor interface, add an accept method to every class, and then turn each new operation into a visitor class with a method for every element type. Since Java 21, three finalized language features (sealed types in Java 17, plus record patterns and pattern matching for switch, both in Java 21) let you solve the same problem in far less code, while the compiler still checks that every case is covered.
This article implements the classic Visitor pattern, rewrites it with a sealed interface and switch, runs both on a real JDK 21, and shows where the Visitor pattern still earns its place.
This article follows Concurrency patterns: Producer-Consumer, thread pools and CompletableFuture. If you have read DDD in Java: Entity, Value Object and Aggregate, the record types used here are exactly the Value Objects from that article, and the expression-tree processing below can be seen as a way to walk an immutable domain model.
What problem the Visitor pattern solves
According to the Refactoring.Guru description, the Visitor pattern lets you separate an algorithm from the objects it operates on, so you can add new operations without modifying those classes. The typical problem for the Visitor pattern is a structure made of several different classes (a document tree, a shopping cart with different kinds of line items) that must be processed in several ways (pricing, computing shipping weight, printing an invoice). If you put every operation into every class, the domain classes bloat and their responsibilities blur. If you use a chain of if (x instanceof ...), you lose the compiler's help when a new type is added.
The core mechanism of the Visitor pattern is double dispatch. Java picks an overriding method by the runtime type of the object, but it picks an overloaded method by the declared type of the variable at compile time. So we let the element call back into the visitor with the method that matches itself: the first dispatch selects accept by the element's type, and the second selects visitPhysical, visitDigital, and so on by the visitor's type.
Implementing the classic Visitor pattern
The business example for the Visitor pattern is a shopping cart with three kinds of lines: physical goods, digital goods (an ebook) and a discount voucher. There are two operations: computing the total and computing the shipping weight. The code is in code/s6-30/src/classic/Demo1Classic.java:
interface Visitor<R> {
R visitPhysical(Physical p);
R visitDigital(Digital d);
R visitVoucher(Voucher v);
}
interface LineItem { <R> R accept(Visitor<R> v); }
record Physical(String sku, long unitPriceVnd, int qty, double weightKg) implements LineItem {
public <R> R accept(Visitor<R> v) { return v.visitPhysical(this); }
}
record Digital(String sku, long priceVnd) implements LineItem {
public <R> R accept(Visitor<R> v) { return v.visitDigital(this); }
}
record Voucher(String code, long discountVnd) implements LineItem {
public <R> R accept(Visitor<R> v) { return v.visitVoucher(this); }
}
static class Pricing implements Visitor<Long> {
public Long visitPhysical(Physical p) { return p.unitPriceVnd() * p.qty(); }
public Long visitDigital(Digital d) { return d.priceVnd(); }
public Long visitVoucher(Voucher v) { return -v.discountVnd(); }
}Running it with a cart of two T-shirts (150,000 VND, 0.4 kg each), one ebook at 99,000 VND and a 50,000 VND voucher:
[classic] total = 349000 VND, weight = 0.8 kg
overload with a LineItem variable -> LineItem (not Physical)The first line: 2 × 150,000 + 99,000 − 50,000 = 349,000, which is correct. The second line is the reason double dispatch exists: with a variable of type LineItem that holds a Physical, the call describe(asItem) still picks the overload describe(LineItem), because Java chooses by static type. The Visitor pattern gets around that limit with accept.
The advantage of the Visitor pattern is clear: adding a new operation is just adding a visitor class, with no change to the records. The drawbacks are just as clear, and the reference source lists them too: adding a new element type forces you to change every visitor, and the ceremony of the Visitor pattern (an accept in every class, a Visitor interface that grows with the number of types) hides the real business logic.
Sealed interface: telling the compiler every type
The root of the Visitor pattern's verbosity is that Java used to have no way to declare "these and only these subclasses exist". JEP 409 (final in Java 17) adds sealed: an interface or class with a permits clause that lists its direct subclasses, and each subclass must declare exactly one of three modifiers: final, sealed or non-sealed. A record is implicitly final, so it fits perfectly. The JEP also states the benefit: combined with pattern matching for switch, the compiler verifies that all permitted subclasses are covered, needs no default branch, and reports an error if one is missing.
sealed interface LineItem permits Physical, Digital, Voucher {}
record Physical(String sku, long unitPriceVnd, int qty, double weightKg) implements LineItem {}
record Digital(String sku, long priceVnd) implements LineItem {}
record Voucher(String code, long discountVnd) implements LineItem {}Replacing the Visitor pattern with switch pattern matching
JEP 441 finalizes pattern matching for switch in Java 21: a case accepts a type pattern, can have a when guard, and for a sealed hierarchy the switch must be exhaustive. JEP 440 adds record patterns, which let you deconstruct a record's components right inside the case. The two operations of the original Visitor pattern are now just two ordinary functions (code/s6-30/src/modern/Demo2Sealed.java):
static long price(LineItem i) {
return switch (i) {
case Physical p -> p.unitPriceVnd() * p.qty();
case Digital d -> d.priceVnd();
case Voucher v -> -v.discountVnd();
}; // no default: the compiler checks exhaustiveness
}
static double weight(LineItem i) {
return switch (i) {
case Physical(var sku, var price, var qty, var kg) -> kg * qty; // record pattern
case Digital d -> 0.0;
case Voucher v -> 0.0;
};
}
// NEW operation: just add one function, no change to the records
static String describe(LineItem i) {
return switch (i) {
case Physical(var sku, var price, var qty, var kg) when qty >= 10 -> "bulk: " + sku + " x" + qty;
case Physical(var sku, var price, var qty, var kg) -> "retail: " + sku + " x" + qty;
case Digital(var sku, var price) -> "digital: " + sku;
case Voucher(var code, var off) -> "voucher: " + code;
};
}Running it with a cart that also has 12 ceramic mugs (40,000 VND, 0.3 kg each):
[sealed] total = 829000 VND, weight = 4.4 kg
retail: AO-THUN x2
digital: EBOOK-JAVA
voucher: GIAM50K
bulk: COC-SU x12Checking by hand: 300,000 + 99,000 − 50,000 + 480,000 = 829,000 VND; weight 0.8 + 3.6 = 4.4 kg. Compared with the classic Visitor pattern, we drop the Visitor interface, every accept method and the visitor classes; each operation sits in one function that the switch reads top to bottom. The guard when qty >= 10 is something the classic Visitor does not give you for free: you would have to nest an if inside visitPhysical. The order of the cases matters as well: JEP 441 states that the first matching label wins, and a label completely dominated by an earlier one is a compile error, so a case with a guard must come before a case of the same type without a guard.
Nested record patterns: where the classic Visitor pattern loses most clearly
For recursive structures, record patterns let you match several levels deep in a single label. The example is a promotion price expression tree made of constants, addition, multiplication, percentage and negation (code/s6-30/src/modern/Demo3Nested.java):
sealed interface Expr permits Const, Add, Mul, Percent, Neg {}
record Const(long v) implements Expr {}
record Add(Expr l, Expr r) implements Expr {}
record Mul(Expr l, Expr r) implements Expr {}
record Percent(Expr base, int pct) implements Expr {}
record Neg(Expr e) implements Expr {}
static long eval(Expr e) {
return switch (e) {
case Const(var v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
case Percent(var b, var p) -> eval(b) * p / 100;
case Neg(var x) -> -eval(x);
};
}
static Expr simplify(Expr e) {
return switch (e) {
case Neg(Neg(var x)) -> simplify(x); // nested 2 levels
case Mul(Const(var v), var r) when v == 0 -> new Const(0);
case Mul(var l, Const(var v)) when v == 1 -> simplify(l);
case Add(var l, var r) -> add(simplify(l), simplify(r));
case Mul(var l, var r) -> new Mul(simplify(l), simplify(r));
case Percent(var b, var p) -> new Percent(simplify(b), p);
case Neg(var x) -> new Neg(simplify(x));
case Const c -> c;
};
}expression: ((0 * 999) + (--2000000 * 15% + 0))
value : 300000 VND
simplified: 2000000 * 15%
value : 300000 VND (must be equal)Two details are worth noting. First, Neg(Neg(var x)) and Mul(Const(var v), var r) match nested structure in a single line; with the Visitor pattern, inside visitNeg you would have to check the type of the child element and cast it, or build a nested visitor. Second, the simplification preserves the value (300,000 VND before and after), which is the kind of check your tests should include when you transform a tree. Note that null does not match any record pattern according to JEP 440, so the tree must be built without null; a record with components that may be null needs separate handling.
Sealed and separate compilation: the compiler catches it, then the runtime finishes the job
With sealed types, the promise "add a new type and the compiler flags every place that is missing it" works on two levels. At compile time, add Triangle and recompile a function that has no matching branch:
tmp2/AreaMissing.java:3: error: the switch expression does not cover all possible input values
return switch (s) {
^
1 errorThat is exactly the behavior we want. The second level is subtler: if you add a subclass to a sealed interface but do not recompile the class that contains the switch (for example, one packaged in a prebuilt library), the old .class file still runs. JEP 441 describes the compiler inserting a default branch that throws an error for this case. The directory code/s6-30/sep has the source files; Area.java contains a switch over Shape with only two branches, Circle and Square. The steps (run.sh does exactly this, and the tmp directory is where the files are assembled):
# 1. Version v1: Shape permits Circle, Square. Compile everything into out2, then run
javac -d out2 tmp/*.java && java -cp out2 Area
# 2. Version v2: add Triangle. Recompile ONLY Shape, Triangle, Factory
# (the old Area.class stays untouched in out2 and is not recompiled)
javac -d out2 -cp out2 tmp/Shape.java tmp/Triangle.java tmp/Factory.java
java -cp out2 Area x # the argument x makes Factory return a TriangleThe result of the two runs:
== Add Triangle to the sealed interface, Area.class is not recompiled
area = 4.0
Exception in thread "main" java.lang.MatchException
at Area.area(Area.java:3)
at Area.main(Area.java:9)The first run (Square) prints 4.0. After adding Triangle and recompiling only Shape, Triangle and Factory, passing a Triangle into the old area function throws MatchException instead of silently returning a wrong result. The practical consequence for library authors: adding a subclass to a sealed hierarchy is a source-incompatible change and can cause runtime failures in call sites compiled earlier, so for a public API it must be treated as a major version change. This is the price that comes from the same source as the benefit: the Visitor pattern also breaks when you add an element type, except that it breaks immediately at compile time with a "missing method" error.
Comparing the Visitor pattern with sealed and switch
| Criterion | Classic Visitor pattern | Sealed + switch |
|---|---|---|
| Adding a new operation | Add one visitor class, no change to elements | Add one function, no change to elements |
| Adding a new element type | Change the interface and every visitor | The compiler points to every switch missing a branch |
| Amount of ceremony code | A lot: accept, interface, visitor classes | Almost none |
| Different return type per operation | Generic Visitor<R> | Each function declares its own |
| Open hierarchy, extended from outside | Fits: each element picks its branch via accept | Does not fit: sealed needs every subclass known upfront |
| State accumulated while traversing | The visitor is an object with fields | Pass parameters or use a separate object |
| Deep multi-level matching | Verbose | A single case label |
The pragmatic conclusion about the Visitor pattern: if the set of element types is closed and under your control (a shopping cart, an expression tree, a payment result), sealed types with switch are usually the first choice. If the hierarchy must stay open to plugins or other modules, or the visitor needs to carry complex state while traversing a large structure, the Visitor pattern still has a place. Even JEP 441 does not claim a full replacement: its "future work" section only says that deconstruction patterns for ordinary classes could help write logic without a clumsy visitor, which means that today this approach is strongest with records and sealed types.
When not to use the Visitor pattern
- Do not use the Visitor pattern when the set of elements changes often: every new type forces changes in every visitor, and if there are only one or two operations, a method inside the class (ordinary polymorphism) is simpler.
- Do not use sealed for a hierarchy that outside code needs to extend; use an ordinary interface, or
non-sealedon the branch that must stay open. - Do not add a
defaultbranch to aswitchover a sealed type just to "make it quiet": JEP 441 warns thatdefaulthides new types and throws away the benefit of exhaustiveness checking. - Do not deconstruct too deeply in one go: a record pattern four or five levels deep is harder to read than a helper function with a clear name.
- Do not pack large business logic into one
switch: each branch should call a named function; theswitchis only a dispatcher.
Common mistakes
- Wrong case order: placing a general case before a case with a guard makes the later branch unreachable, and the compiler reports a dominance error.
- Forgetting that patterns do not match
null: aswitchon anullvalue throwsNullPointerExceptionunless there is acase null. - Adding a subclass to a sealed interface of a released library: call sites that were compiled earlier may throw
MatchException, as shown above. - Using a record that holds a mutable collection and then destructuring it in a
switch: record patterns only read, but the collection inside can still change outside your control; make a defensive copy in the constructor. - Confusing the Visitor pattern with Strategy: Strategy swaps the algorithm for one object, while the Visitor pattern applies an algorithm across a whole structure of many element types.
- Expecting
switchto check exhaustiveness for ordinary classes: only sealed types (and enums) give that guarantee; with an open hierarchy, a patternswitchalways needs adefault.
Checklist
- The set of element types is closed and under your control: use a
sealed interfacewith records. - Make each operation a
switchfunction withoutdefault, so the compiler catches a missing branch. - Use record patterns to extract data,
whenguards for conditions, and put specific labels before general ones. - For an open hierarchy, or a visitor that needs state: keep the classic Visitor pattern.
- Treat adding a subclass to a public sealed API as a breaking change; recompile every call site.
- Write tests for transformations (like
simplify) that check the value is equal before and after.
In short, the Visitor pattern solves a real problem (adding operations without modifying the elements) with a double dispatch mechanism that the language used to force us to build by hand. With sealed types and pattern matching in Java 21, most of that ceremony disappears while the compile-time exhaustiveness check remains. All the code is in code/s6-30, run on JDK 21 (run.sh; the exact patch version is recorded in run.sh).
Next article
With Java 21, the breakthrough is not only in syntax but also in the threading model. Continue with Virtual threads in Java 21: when to replace a thread pool.



