Visitor

Part of the free Low-Level Design course on LogicWiz, in the chapter: Behavioural patterns III. The class-diagram exercises run in the app.

Three reports, three classes to edit

"Finance wants a customs report next week and a returns report after that. Do I edit GroceryItem, ElectronicsItem and GiftCard every single time?"

Arjun, asking Priya in a review


A new report means a new method in every item class

A SwiftKart cart holds three kinds of item: groceries (a price and a quantity), electronics (a price and a warranty in months) and gift cards (a face value). Finance needs three results from a cart: the tax, a handling fee, and the lines of an invoice. Prices are in paise (one rupee is 100 paise).

Arjun's first design put the work inside the items. Each item class got three methods:

record GroceryItem(String name, long pricePaise, int qty) implements CartItem {
    public long tax() { return pricePaise * qty * 5 / 100; }
    public long fee() { return 100L * qty; }
    public String invoiceLine() { return name + " x" + qty; }
}

Three operations on three item types make nine methods. The pain has two parts:

  • Each operation is scattered. To change the tax rule, you open three files.
  • Each new operation edits every item class. Finance's customs report is a fourth method in all three. The catalogue team owns these classes, and they now hold finance code.

Priya said: "Keep each report in one class, and leave the items alone." So Arjun wrote a TaxCalculator with one method for each item type, all named tax. He kept a fallback tax(CartItem) for "anything else". Each item also has a small method kind() that returns a label such as "electronics", so we can watch Java's choices. Here are the items and the calculator:

interface CartItem { String kind(); }

record GroceryItem(String name, long pricePaise, int qty) implements CartItem {
    public String kind() { return "grocery"; }
}
record ElectronicsItem(String name, long pricePaise, int warrantyMonths) implements CartItem {
    public String kind() { return "electronics"; }
}
record GiftCard(String name, long faceValuePaise) implements CartItem {
    public String kind() { return "gift card"; }
}
public class TaxCalculator {
    public long tax(CartItem item) { return 0; }      // fallback for "something else"

    public long tax(GroceryItem g) { return g.pricePaise() * g.qty() * 5 / 100; }
    public long tax(ElectronicsItem e) { return e.pricePaise() * 18 / 100; }
    public long tax(GiftCard c) { return 0; }
}

Then he calculated the tax on earbuds that cost 2,000 rupees. The correct tax at 18 percent is 36,000 paise.

        TaxCalculator calc = new TaxCalculator();
        CartItem item = new ElectronicsItem("Earbuds", 200000, 12);

        System.out.println("kind:  " + item.kind());   // which kind() runs?
        System.out.println("tax:   " + calc.tax(item)); // which tax() runs?

        ElectronicsItem same = new ElectronicsItem("Earbuds", 200000, 12);
        System.out.println("tax:   " + calc.tax(same));
kind:  electronics
tax:   0
tax:   36000

The first line is right: the object says "electronics". The second line is wrong: the tax is 0. The third call passes the same earbuds in a variable declared as ElectronicsItem, and gives 36,000. Same object, two answers. To see why, you need to know how Java chooses a method.

How Java picks a method: two kinds of choice

A method call has two parts: the object before the dot, called the receiver, and the arguments. Java makes its choice in two different ways.

Choice 1: overriding, decided while the program runs. In item.kind(), the variable item is declared as CartItem, but the object is an ElectronicsItem. Overriding means each class writes its own version of one method. Java looks at the real object at the moment of the call and runs that class's own kind(). This is called dynamic dispatch. To dispatch is to decide which method runs. Here the decision uses one thing: the real type of the receiver.

Choice 2: overloading, decided before the program runs. In calc.tax(item), several methods are called tax. The compiler picks one while it compiles, using only the types written in the code. The variable item is declared CartItem, so the compiler picks tax(CartItem). It cannot know that the list will hold earbuds at run time. The third call used a variable declared ElectronicsItem, so the compiler chose tax(ElectronicsItem).

Without the fallback, the program does not compile at all:

Main.java:8: error: no suitable method found for tax(CartItem)
1 error

⚠️ Warning: A fallback such as tax(CartItem) turns this compile error into a silent wrong answer: tax of 0 on every item.

So Java uses the real type for the receiver, and only the declared type for the arguments. We need the method to depend on two things at once: the kind of item, and the operation (tax, fee or invoice). Choosing a method by the real types of two objects is called double dispatch. Java has no such feature, but we can build it from two single choices.

Here is the trick. Do not hand the item to the calculator. Hand the calculator to the item, with item.accept(visitor). This is choice 1: the real type of the item picks which accept runs. Inside GroceryItem.accept, the code calls visitor.visit(this). The compiler knows that this inside GroceryItem is a GroceryItem, so it picks visit(GroceryItem) (choice 2) and always gets it right. Then the real type of the visitor picks the tax, fee or invoice version (choice 1 again). Two choices, made one after the other.

The idea in plain words

Think of a tax inspector who visits shops. At the door, each shop says what it is: "I am a bakery, use your bakery form." The inspector has one form for each kind of shop. A fire inspector uses the same doors with different forms. When the city adds an inspector, no shop changes.

Where the picture stops working: if the city adds a new kind of shop, every inspector needs a new form. Real inspectors improvise. Code does not, so every visitor must be updated. Also, every shop needs its door (accept) built before anyone knows which inspections will come.

This is the Visitor pattern: you put an operation over a family of classes in its own object, the visitor, with one method per class. Each class accepts the visitor and calls back with itself. The roles are:

  • Element: the interface of the things being visited, CartItem, with one method accept.
  • Concrete elements: GroceryItem, ElectronicsItem and GiftCard.
  • Visitor: an interface with one visit method for each concrete element.
  • Concrete visitors: one class per operation, such as TaxVisitor.
classDiagram
  class CartItem {
    <<interface>>
    +accept(v: CartVisitor)
  }
  class CartVisitor {
    <<interface>>
    +visit(GroceryItem)
    +visit(ElectronicsItem)
    +visit(GiftCard)
  }
  CartItem <|.. GroceryItem
  CartItem <|.. ElectronicsItem
  CartItem <|.. GiftCard
  CartVisitor <|.. TaxVisitor
  CartVisitor <|.. FeeVisitor
  CartVisitor <|.. InvoiceVisitor
  CartItem ..> CartVisitor : calls back

Build it step by step

Step 1: the two interfaces, and one line in each item.

public interface CartItem {
    void accept(CartVisitor visitor);
}
public interface CartVisitor {
    void visit(GroceryItem item);
    void visit(ElectronicsItem item);
    void visit(GiftCard item);
}
record GroceryItem(String name, long pricePaise, int qty) implements CartItem {
    public void accept(CartVisitor visitor) { visitor.visit(this); }
}

record ElectronicsItem(String name, long pricePaise, int warrantyMonths) implements CartItem {
    public void accept(CartVisitor visitor) { visitor.visit(this); }
}

record GiftCard(String name, long faceValuePaise) implements CartItem {
    public void accept(CartVisitor visitor) { visitor.visit(this); }
}

Each accept has the same one line, but in each class this has a different type. That makes the compiler choose a different visit. The items now know nothing about tax, fees or invoices, and a new report never touches them.

Step 2: one visitor for tax.

public final class TaxVisitor implements CartVisitor {
    private long totalPaise;

    public void visit(GroceryItem item) { totalPaise += item.pricePaise() * item.qty() * 5 / 100; }
    public void visit(ElectronicsItem item) { totalPaise += item.pricePaise() * 18 / 100; }
    public void visit(GiftCard item) { /* a gift card carries no tax */ }

    public long totalPaise() { return totalPaise; }
}

The visitor has a field, totalPaise, that adds up across the whole cart. A visitor can keep running state between items, which a method on one item cannot. The gift card branch is empty on purpose: gift cards carry no tax.

Step 3: more reports, with no edits to the items. The fee visitor follows the same shape. The invoice visitor builds text instead of a number, and the invoice lines come from the item data. Then one loop runs all three:

public final class FeeVisitor implements CartVisitor {
    private long totalPaise;

    public void visit(GroceryItem item) { totalPaise += 100L * item.qty(); }   // packing, per unit
    public void visit(ElectronicsItem item) { totalPaise += 4900; }            // insured packing
    public void visit(GiftCard item) { }

    public long totalPaise() { return totalPaise; }
}
        List<CartItem> cart = List.of(
                new GroceryItem("Milk", 3000, 2),
                new ElectronicsItem("Earbuds", 200000, 12),
                new GiftCard("Gift card", 50000));

        TaxVisitor tax = new TaxVisitor();
        FeeVisitor fee = new FeeVisitor();
        InvoiceVisitor invoice = new InvoiceVisitor();
        for (CartItem item : cart) {
            item.accept(tax);
            item.accept(fee);
            item.accept(invoice);
        }
        System.out.println("tax paise: " + tax.totalPaise());
        System.out.println("fee paise: " + fee.totalPaise());
        System.out.print(invoice.text());
tax paise: 36300
fee paise: 5100
Milk x2                            Rs 60.00
Earbuds (12 months warranty)       Rs 2000.00
Gift card                          Rs 500.00

The tax is 300 paise for the milk and 36,000 for the earbuds, which is 36,300. The fee is 200 for two milk packs and 4,900 for the earbuds, which is 5,100. Finance's customs report is a new CustomsVisitor class. No item changes.

A visitor must read each item's data, so items must show their fields. Records do this with accessor methods. This is a cost of keeping operations outside: items cannot keep everything private.

The trade-off, and when not to use it

Visitor makes one direction easy and the other hard. SwiftKart adds a Subscription item. We add it to the items and add visit(Subscription) to the visitor interface. This is the result:

FeeVisitor.java:2: error: FeeVisitor is not abstract and does not override abstract method visit(Subscription) in CartVisitor
InvoiceVisitor.java:2: error: InvoiceVisitor is not abstract and does not override abstract method visit(Subscription) in CartVisitor
TaxVisitor.java:2: error: TaxVisitor is not abstract and does not override abstract method visit(Subscription) in CartVisitor
3 errors

Every visitor fails to compile until it handles the new type. That is good, because the compiler lists every place you must update, unlike an instanceof chain that silently skips the new type. But it is also the cost. With 6 visitors, one new item type is 6 edits.

You add Cost with Visitor
A new operation One new class, no edits to items
A new item type An edit to the interface and every visitor

Use Visitor when the item types are stable and the operations keep growing. A compiler is the classic case: the kinds of syntax node rarely change, but new passes keep arriving. A document that exports to HTML, PDF and Markdown is another.

Do not use it in these cases:

  • The item types change often. If a new product type arrives every week, every release edits every visitor.
  • There are one or two operations. A method on the item is clearer.
  • The only difference is a number. If tax differs only by rate, a table from category to rate is simpler than three visitors.

In interviews, Visitor is rare as a core class. It appears in design discussions ("how do I add a new report without editing the item classes?") and as the follow-up "what is double dispatch?". It differs from Strategy, which swaps how one job is done. Visitor adds jobs over a family of types.

Newer Java can replace it. This paragraph is about features newer than the Java 17 this course uses. A sealed interface lists exactly which classes may implement it. It is final in Java 17, so it works here. Pattern matching for switch lets a switch choose by the real type of an object. It became final in Java 21; in Java 17 it is only a preview feature. The code below compiles and runs with --release 21. With --release 17 the compiler refuses it and says patterns in switch need Java 21 or higher.

sealed interface CartItem permits GroceryItem, ElectronicsItem, GiftCard { }

record GroceryItem(String name, long pricePaise, int qty) implements CartItem { }
record ElectronicsItem(String name, long pricePaise, int warrantyMonths) implements CartItem { }
record GiftCard(String name, long faceValuePaise) implements CartItem { }
    static long tax(CartItem item) {
        return switch (item) {
            case GroceryItem g -> g.pricePaise() * g.qty() * 5 / 100;
            case ElectronicsItem e -> e.pricePaise() * 18 / 100;
            case GiftCard c -> 0;
        };   // no default: the compiler checks that every case is covered
    }

The compiler checks that every permitted type has a case, so a missing type is a compile error, like the visitor's check. Do not add a default branch, or you lose the check. The trade-off is unchanged: a new item type means editing every switch, and a new operation is a new method. You save the accept and visit ceremony.

Practice: move the operations out of the items

The starter is Arjun's first design, with tax, fee and invoice methods inside every item. Add a visitor interface with one visit method for each item, give the items an accept method, and write two visitors.

📌 Summary: Visitor puts an operation over a family of classes in its own object. Each item calls visitor.visit(this), which gives double dispatch. New operations are free; a new item type edits every visitor.

What Arjun learns next

Finance has its reports. Then marketing makes a new request: they want to write coupon rules themselves, such as "cart total over 500 and a dairy item", and change them without a release. The rules arrive as text. That calls for the Interpreter pattern.

Check your understanding

  1. A variable is declared `CartItem item` and holds an ElectronicsItem. `calc.tax(item)` has overloads for CartItem, GroceryItem and ElectronicsItem. Which one runs?
  2. Inside GroceryItem, the method accept calls `visitor.visit(this)`. Why does this reach visit(GroceryItem)?
  3. SwiftKart has 6 visitors and adds a new Subscription item type. What must change?
  4. Which situation suits Visitor best?