Core Java

Java’s Sealed Classes Meet the Visitor Pattern

Replacing a classic GoF pattern with pattern matching: a before and after refactor of a Visitor hierarchy, collapsed into a sealed interface and an exhaustive switch expression.

The Visitor pattern has been part of every Java developer’s toolkit since the Gang of Four wrote it down in 1994. It solves a real problem, but it solves it with a lot of ceremony: an accept method on every element class, a visitor interface with one method per element type, and a fresh concrete visitor class for every new operation. Since Java 21 finalized pattern matching for switch, much of that ceremony can simply disappear. This article walks through a real refactor, from a working Visitor hierarchy to a sealed interface with an exhaustive switch, and looks honestly at what you gain and what you still might not.

Why the Visitor Pattern Existed in the First Place

Visitor exists to solve what is often called the expression problem. Given a fixed set of element types, such as shapes, you want to be able to add new operations, such as computing area or rendering to SVG, without touching the element classes themselves every time. Visitor achieves this through double dispatch: each element implements an accept method that calls back into the visitor, and the visitor interface declares one method per concrete element type. The compiler forces every implementation of that visitor interface to handle every element type, which is a genuine and useful safety net.

The cost is structural. You end up with an element hierarchy, a parallel visitor hierarchy, and an indirect two-step dispatch mechanism just to answer a question as simple as “what is the area of this shape.” For a codebase with many operations and a stable set of types, that cost can be worth paying. For most everyday cases, it is a lot of scaffolding around a small idea.

The Before: A Visitor Hierarchy for Shape Areas

Here is a standard Visitor implementation for computing the area of three shape types. It compiles and runs as shown on Java 21.

public class Before {
    public static void main(String[] args) {
        Shape[] shapes = { new Circle(3.0), new Rectangle(4.0, 5.0), new Triangle(6.0, 2.0) };
        AreaVisitor areaVisitor = new AreaVisitor();
        for (Shape shape : shapes) {
            double area = shape.accept(areaVisitor);
            System.out.printf("Area: %.2f%n", area);
        }
    }
}

interface Shape {
    <R> R accept(ShapeVisitor<R> visitor);
}

final class Circle implements Shape {
    final double radius;
    Circle(double radius) { this.radius = radius; }
    public <R> R accept(ShapeVisitor<R> visitor) {
        return visitor.visitCircle(this);
    }
}

final class Rectangle implements Shape {
    final double width;
    final double height;
    Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    public <R> R accept(ShapeVisitor<R> visitor) {
        return visitor.visitRectangle(this);
    }
}

final class Triangle implements Shape {
    final double base;
    final double height;
    Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }
    public <R> R accept(ShapeVisitor<R> visitor) {
        return visitor.visitTriangle(this);
    }
}

interface ShapeVisitor<R> {
    R visitCircle(Circle circle);
    R visitRectangle(Rectangle rectangle);
    R visitTriangle(Triangle triangle);
}

class AreaVisitor implements ShapeVisitor<Double> {
    public Double visitCircle(Circle c) {
        return Math.PI * c.radius * c.radius;
    }
    public Double visitRectangle(Rectangle r) {
        return r.width * r.height;
    }
    public Double visitTriangle(Triangle t) {
        return 0.5 * t.base * t.height;
    }
}

Save this as Before.java and run it with java Before.java on Java 11 or later, no build tool required. It prints the three areas as expected. Six top-level types are doing the work of one idea.

The After: Sealed Interface Plus Exhaustive Switch

Now the same behavior, rebuilt around a sealed interface and a pattern-matching switch. This needs Java 21 or later, since that is the release where pattern matching for switch was finalized.

public class After {
    public static void main(String[] args) {
        Shape[] shapes = { new Circle(3.0), new Rectangle(4.0, 5.0), new Triangle(6.0, 2.0) };
        for (Shape shape : shapes) {
            System.out.printf("Area: %.2f%n", area(shape));
        }
    }

    static double area(Shape shape) {
        return switch (shape) {
            case Circle c -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
            case Triangle t -> 0.5 * t.base() * t.height();
        };
    }
}

sealed interface Shape permits Circle, Rectangle, Triangle {}

record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

Save this as After.java and run it the same way, java After.java. It prints identical output to the Visitor version. There is no accept method, no visitor interface, and no separate visitor class. The area logic lives in exactly one place, right next to where it is used.

The exhaustiveness guarantee did not disappear either. Remove the Triangle case from that switch and try to compile it, and javac refuses outright with “the switch expression does not cover all possible input values.” The sealed interface’s permits clause tells the compiler the full set of possible shapes, so it can check the switch against that list without needing a default branch to catch cases nobody thought about.

What Actually Changed

AspectVisitor patternSealed interface + switch
Top-level types needed6 (element interface, 3 elements, visitor interface, 1 concrete visitor)4 (sealed interface, 3 records)
Dispatch mechanismDouble dispatch via accept and visitX methodsSingle switch over the sealed type
Where exhaustiveness is enforcedInside every ShapeVisitor implementationAt every switch site over the sealed type
Minimum Java versionAny version with generics (Java 5+)Java 21 (pattern matching for switch, JEP 441)
New operation, e.g. perimeterNew visitor class implementing ShapeVisitorNew method with its own switch
Line counts measured directly from the two working examples above, including imports, braces, and blank lines. A three-shape, single-operation example is a small case; the gap tends to widen further as more element types are added, since each one still needs its own visitor method in the Visitor version.

The Real Tradeoff: Adding Operations vs Adding Types

It would be too simple to say pattern matching just wins. Visitor was designed to make adding new operations painless while accepting that adding new element types is expensive, since every existing visitor implementation must be updated. Sealed interfaces and switch expressions do not remove that expense; they relocate it. Add a new shape and every switch over Shape anywhere in the codebase becomes non-exhaustive, and the compiler will refuse to build until each one is updated. That is still work, but it surfaces as a precise, itemized list of compile errors rather than a silent gap you might not notice until a test fails, or worse, until it does not.

Both mechanisms rely on relatively recent Java history. Sealed classes reached the compiler timeline well before switch caught up to make full use of them.

Source: OpenJDK JEP index (JEP 409JEP 441). Bars show each feature’s preview-to-final journey across JDK releases.

When Visitor is still the better tool:

  • The element hierarchy lives in a library you do not own and cannot seal
  • You need to add new operations far more often than new element types
  • The traversal needs to walk two independent open hierarchies at once, true double dispatch
  • You are targeting a Java version older than 21 and cannot upgrade

When sealed interface plus switch wins:

  • You own the full set of types and it is genuinely closed, such as AST nodes, event types, or API response variants
  • The set of types changes more often than the number of operations
  • You want the logic for one operation to read top to bottom in a single place

What We Have Learned

The Visitor pattern was never really about visiting. It was a workaround for a language that had no way to tell the compiler “this is the complete list of types, please check my switch against it.” Sealed interfaces and pattern matching for switch give Java exactly that capability directly, without the double dispatch machinery Visitor needed to fake it. The refactor shown here keeps the same compile-time safety with roughly a third of the code and none of the indirection. It is not a universal replacement, Visitor still earns its place when you are extending a hierarchy you do not control or adding operations far more often than types, but for a closed set of types you own, the sealed interface and switch combination is very often the simpler, more honest way to write the same guarantee.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button