The ticket says “export the Outdoor tree as CSV for merchandising.” After Composite, Sku and Category already share subtotal(). You add toCsv() to both. Tax classification adds taxClass() to both. The admin “what’s in this promo?” screen adds printTree(). Every report edits every element. A new leaf kind — a gift card that is not a Sku — touches every report you already shipped.

That is Visitor’s entire complaint. From the Design Patterns Roadmap: new operations keep arriving; the element types do not.

This post stays on the catalog tree. OrderProcessor still charges one payable. We do not re-lecture the three families. Wave 3 is the set that is easy to over-apply; Visitor is on that list because a sealed hierarchy you own often wants a switch, not double dispatch.

The methods that colonize the elements

Here is Sku after CSV and tax landed next to pricing. Category has the same two methods, with a loop. Nobody wrote this badly on purpose:

public final class Sku implements CatalogNode {

    private final String skuCode;
    private final BigDecimal price;
    private final String taxClass;

    @Override
    public BigDecimal subtotal() {
        return price;
    }

    public String toCsv() {
        return skuCode + "," + price + "," + taxClass + "\n";
    }

    public BigDecimal tax() {
        return "REDUCED".equals(taxClass)
                ? price.multiply(new BigDecimal("0.05"))
                : price.multiply(new BigDecimal("0.20"));
    }
}

Category.toCsv() and Category.tax() copy the walk. A third operation will copy it again. The instanceof version is the same walk with the types named in the operation:

public static String toCsv(CatalogNode node) {
    if (node instanceof Sku sku) {
        return sku.skuCode() + "," + sku.price() + "\n";
    }
    if (node instanceof Category cat) {
        StringBuilder out = new StringBuilder();
        for (CatalogNode child : cat.children()) {
            out.append(toCsv(child));
        }
        return out.toString();
    }
    throw new IllegalStateException("unknown node " + node.getClass());
}

Now price the next two reports — and the gift-card leaf:

Add tax walk                  -> edit Sku, Category, and the CSV file's twin
Add printTree for admin       -> third copy of the recursion
Add GiftCard node             -> every instanceof / every element method
Test CSV without tax          -> you cannot; both live on the same types

Four costs, and none of them are about charging a card. Every new report has become a reason to reopen the merchandise types.

Note: The problem is not toCsv as a one-off. A single extra method on two classes you own is fine. The problem is a stable element set — SKU, category, maybe a bundle leaf — and an unstable set of walks that other teams keep inventing.

What Visitor actually is

Four parts, one promise:

PartIn this labJob
ElementCatalogNodeaccept(CatalogVisitor). Does not name CSV or tax.
Concrete elementSku, CategoryCalls the visit method that matches its type.
VisitorCatalogVisitorOne visitX per concrete element.
Concrete visitorCsvExport, TaxWalkOne operation. Allowed to grow.

The elements stay closed. The next operation is a new visitor class. If Sku grows a toJson() because reporting asked, you did not visit; you kept colonizing the tree.

Java has no multiple dispatch. accept is the first dispatch (which element); visitSku / visitCategory is the second (which operation). You do not need a class named Visitor. You need a verb the elements already understand — “let this walker see me”:

public interface CatalogVisitor {

    void visitSku(Sku sku);

    void visitCategory(Category category);
}

public interface CatalogNode {

    String name();

    BigDecimal subtotal();

    void accept(CatalogVisitor visitor);
}

subtotal() can stay on the node: it is the tree’s own job, from Composite. CSV is not.

Each element names itself once

Sku and Category implement accept and stop learning report formats. Category still walks children because that structure is the element’s:

public final class Sku implements CatalogNode {

    private final String skuCode;
    private final BigDecimal price;
    private final String taxClass;

    @Override
    public void accept(CatalogVisitor visitor) {
        visitor.visitSku(this);
    }
}

public final class Category implements CatalogNode {

    private final String name;
    private final List<CatalogNode> children = new ArrayList<>();

    @Override
    public void accept(CatalogVisitor visitor) {
        visitor.visitCategory(this);
        for (CatalogNode child : children) {
            child.accept(visitor);
        }
    }
}

A CSV walk holds a StringBuilder. It never asks instanceof:

public final class CsvExport implements CatalogVisitor {

    private final StringBuilder out = new StringBuilder();

    @Override
    public void visitSku(Sku sku) {
        out.append(sku.skuCode())
                .append(',')
                .append(sku.price())
                .append('\n');
    }

    @Override
    public void visitCategory(Category category) {
        // structure only — children accept next
    }

    public String csv() {
        return out.toString();
    }
}

Tax is a second class with the same accept seam. The Outdoor root does not change:

public final class TaxWalk implements CatalogVisitor {

    private BigDecimal tax = BigDecimal.ZERO;

    @Override
    public void visitSku(Sku sku) {
        BigDecimal rate = "REDUCED".equals(sku.taxClass())
                ? new BigDecimal("0.05")
                : new BigDecimal("0.20");
        tax = tax.add(sku.price().multiply(rate));
    }

    @Override
    public void visitCategory(Category category) {
        // children accept next
    }

    public BigDecimal total() {
        return tax.setScale(2, RoundingMode.HALF_UP);
    }
}

Checkout never runs these walks. A controller or a job does outdoor.accept(export) and writes the file. DiscountPolicy still turns an Order into a payable. OrderProcessor still charges.

Note: Put the recursion in one place. If Category.accept walks children, visitCategory must not walk them again. If you prefer the visitor to drive the walk, accept on the composite only calls visitCategory and the visitor iterates children(). Pick one. Double walks are silent double counts.

What the diff looks like now

Same feature request, both designs:

Before — add CSV (then tax)
  M Sku.java                 toCsv, then tax, then printTree
  M Category.java            the same methods, with a loop
  M CatalogNode.java         every new verb on the interface

After — add CSV (then tax)
  A CsvExport.java           new class, elements untouched
  A CsvExportTest.java       Outdoor fixture, no gateway
  M CatalogNode.java         accept only — once, with the first visitor

The second operation is an added file. Sku is closed against “a new report appears” and still open when merchandise changes — a new field merchandising owns, a new leaf type. A new leaf type does edit every visitor. That is the trade: stable elements, arriving operations. Invert it and Visitor is the expensive choice.

Proving a walk without charging a card

The seam pays a second dividend: CSV and tax are unit-testable without OrderProcessor.

@Test
void csvListsLeafSkusNotCategoryNames() {
    CatalogNode outdoor = new Category("Outdoor")
            .add(new Sku("TENT", new BigDecimal("50.00"), "STANDARD"))
            .add(new Category("Hiking")
                    .add(new Sku("BOOTS", new BigDecimal("100.00"), "STANDARD")));
    CsvExport export = new CsvExport();

    outdoor.accept(export);

    assertEquals("TENT,50.00\nBOOTS,100.00\n", export.csv());
}

A processor test still injects new FixedDiscount(...) from the Strategy post. It should not construct a CsvExport. If a declined-charge test needs Outdoor’s CSV, you have mixed two reasons to change.

The switch you already own is often enough

Visitor is double dispatch because the language will not pick visit(Sku) from a CatalogNode reference. Java will pick a sealed branch, exhaustively, when you own the types:

public sealed interface CatalogNode permits Sku, Category {

    String name();

    BigDecimal subtotal();
}

static String toCsv(CatalogNode node) {
    return switch (node) {
        case Sku sku -> sku.skuCode() + "," + sku.price() + "\n";
        case Category cat -> cat.children().stream()
                .map(n -> toCsv(n))
                .collect(Collectors.joining());
    };
}

The hub already named this: on a closed set you own, an exhaustive switch is honest. Adding GiftCard fails compilation until every switch is updated. Adding a third operation is a third function next to toCsv — still cheaper than accept plus an interface per walk, if those operations stay few and in your repository.

Visitor pays off when operations keep arriving from outside the element owners — export jobs, SEO audits, tax engines — and merchandising will not let you keep merging into Sku.

When Visitor is the wrong move

Skip accept when:

  • You own a sealed hierarchy and the operations are few. Two types, two walks, all in one module: the switch above. Visitor here is an interface, two accept methods, and two visitor types to replace two functions.
  • The element set is what grows. A plugin catalog of twelve line kinds, two reports: add a method on the interface (or a default). Each new leaf would otherwise edit every visitor. That is the expensive direction.
  • The “operation” is the element’s own job. subtotal() belongs on CatalogNode. Moving Composite’s verb into a SubtotalVisitor so “everything is a visitor” is a walk you already had as a method.
  • There is one walk in one class. Inlining toCsv next to the only exporter is fine until a second format — or a second team — needs the same types untouched.
  • You are reaching for the name to look designed. Review comments that say “this should be a Visitor” on a DTO with two records and one mapper are unactionable.

The healthy trigger is a second (third) report you can paste from a ticket on a tree whose leaf list is finished. CSV vs tax vs print-tree on Sku / Category is that trigger. subtotal() plus one admin dump is not.

Visitor also is not Interpreter. Interpreter’s nodes are the operations (a grammar you evaluate). Visitor’s nodes are data; the operations arrive later as walkers. Do not accept a promo expression tree — evaluate it. Interpreter is the next catalog post; the skip test until you open it is “do I have a language, or a report?”

It is not Iterator. Iterator yields elements; it does not branch per concrete type. You can iterate billable lines and still switch on SKU vs fee. That switch is not Visitor until the operations start multiplying.

Cheat sheet

Element    CatalogNode.accept        first dispatch; no CSV/tax names
Visitors   CsvExport, TaxWalk        one class per operation
Leaves     Sku, Category             stable; accept names their visitX
Caller     job / controller          outdoor.accept(visitor); not process()

Trigger to apply: operations keep arriving; element types are finished
Trigger to stop:  sealed types you own, few operations — exhaustive switch
Scoreboard:       new report = new visitor file; Sku/Category do not change
Cost of a new leaf: every visitor must grow a visitX — that is the trade

Do:

  • Name the visitor after the operation (CsvExport, TaxWalk), not CatalogVisitorImpl.
  • Pay accept once. Judge the pattern on the second operation.
  • Test each walk with a small tree; keep OrderProcessor on fakes.
  • Use sealed + switch when you own the types and can list the operations on one hand.

Don’t:

  • Add toCsv / tax onto Sku for every report after the tree is stable.
  • Put accept on Order so checkout can “visit” paid vs draft — that is State or a method.
  • Visitor a sealed pair of types with one operation because the catalog listed it.
  • Recurse in both Category.accept and visitCategory.

Wrap-up

Visitor is an accept seam on a stable set of elements, and a new class per operation that needs to see every kind. Sku and Category already knew subtotal(). CSV and tax did not belong there. CsvExport and TaxWalk keep merchandising closed, keep each report unit-testable, and leave OrderProcessor out of the tree. If the types are sealed, yours, and few operations exist, the exhaustive switch is the honest design — the hub said so, and Wave 3 is easy to over-apply.

If the next pain is a tiny rule language — AND/OR of percent-off and sku-in-category — that is Interpreter, not another walker.

Next optional step in the series Evaluate a promo grammar as objects — then stop, because Wave 3 is easy to over-apply. Interpreter: Represent a Grammar and Evaluate It