The last pattern in the book’s order, and often called the hardest. Visitor has a reputation for being confusing, mostly because of a trick called double dispatch. But the problem it solves is very ordinary: the same switch on object types, copied into every place that needs to treat those types differently.
| Category | Behavioral |
| Smell fixed | S23: Tax, weight and reports switch on line-item types |
| Code | patterns/behavioral/visitor |
The Scene
CornerStore sells three kinds of product, and each is treated differently in several places:
- Physical products (shoes, mugs) have weight and are taxed.
- Digital products (ebooks) weigh nothing but are still taxed.
- Gift cards weigh nothing and aren’t taxed.
You can see all three rules at work in the end-of-day report:
[074] report.csv status=all
CSV report (all)
type,total
Physical,543.32
Digital,7.50
GiftCard,25.00
Tax, weight and reporting: three operations, three product types. Where does each combination live?
The Smell
Everywhere. Here’s tax:
// SMELL S23: tax depends on the line type, picked with a type switch
switch (product)
{
case PhysicalProduct:
return Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero);
case DigitalProduct:
return Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero);
case GiftCardProduct:
return 0m;
And weight:
// SMELL S23: weight depends on the line type, picked with a type switch
switch (product)
{
case PhysicalProduct physical:
return physical.WeightKg * quantity;
case DigitalProduct:
return 0m;
case GiftCardProduct:
return 0m;
And the CSV exporter. And the JSON exporter. Four switches on the same three types.
Now add a fourth product type, say a subscription. You have to find every one of those switches. Miss one, and you’ll only find out at runtime, when the default: branch throws unknown product type.
The Idea
Visitor flips the structure. Instead of every operation switching on types, you write one visitor class per operation, with one method per type. Each product knows how to accept a visitor and calls the method meant for its own type.
Think of a tax adviser visiting different clients. A salaried employee, a sole trader and a company all need their tax done, but each one is handled differently. The adviser brings a different checklist for each kind of client, and the client tells the adviser which kind they are. Send a different adviser, say an auditor, and you get a completely different operation, with no change to the clients.
The Refactor
The visitor interface has one method per product type:
internal interface ILineItemVisitor
{
void Visit(PhysicalProduct product);
void Visit(DigitalProduct product);
void Visit(GiftCardProduct product);
}
Each product implements Accept by calling the overload for its own type:
internal override void Accept(ILineItemVisitor visitor)
{
visitor.Visit(this);
}
That one line is the famous double dispatch. Inside PhysicalProduct, this is a PhysicalProduct, so the compiler picks Visit(PhysicalProduct). The first dispatch picks the product’s Accept. The second picks the right Visit. No switch needed.
Each operation is now a visitor. Here’s weight, with all three rules side by side:
internal sealed class WeightVisitor(int quantity) : ILineItemVisitor
{
public decimal Weight { get; private set; }
public void Visit(PhysicalProduct product)
{
Weight = product.WeightKg * quantity;
}
public void Visit(DigitalProduct product)
{
Weight = 0m;
}
public void Visit(GiftCardProduct product)
{
Weight = 0m;
}
}
And using it is three lines:
var visitor = new WeightVisitor(quantity);
product.Accept(visitor);
return visitor.Weight;
classDiagram
Product <|-- PhysicalProduct
Product <|-- DigitalProduct
Product <|-- GiftCardProduct
Product ..> ILineItemVisitor : Accept dispatches
ILineItemVisitor <|.. TaxVisitor
ILineItemVisitor <|.. WeightVisitor
ILineItemVisitor <|.. ReportTotalsVisitor
TaxCalculator ..> TaxVisitor
WeightCalculator ..> WeightVisitor
CsvReportExporter ..> ReportTotalsVisitor
JsonReportExporter ..> ReportTotalsVisitor
The real win: the compiler has your back
Remember adding a subscription product? With Visitor, you add Visit(SubscriptionProduct) to the interface, and the build breaks in every visitor that doesn’t handle it yet. The compiler hands you a to-do list. No default: branch waiting to throw at runtime. That’s the strongest argument for Visitor in C#.
What It Cost
14 files: about 164 lines added and 62 removed. That’s a wide change, because every product type gets an Accept method and every operation gets a visitor. Visitor touches both sides of the type-and-operation grid, so it’s never a small refactor.
When Not to Use It
Visitor makes a specific trade, and it’s worth being clear about it:
- Adding a new operation is easy. Write one visitor. No product changes.
- Adding a new type is harder. Every visitor needs a new method.
So use it when your types are stable but your operations keep growing, like CornerStore, where the three product types rarely change, but tax, weight, reporting and future operations keep coming. If you add new types more often than new operations, Visitor works against you, and plain polymorphism (a virtual method on each product) is the better fit.
The modern C# alternative
C# pattern matching makes the original switch much nicer than it used to be:
decimal weight = product switch
{
PhysicalProduct physical => physical.WeightKg * quantity,
DigitalProduct or GiftCardProduct => 0m,
_ => throw new InvalidOperationException("unknown product type"),
};
It’s shorter, and for a single operation it’s often the better choice. But notice that _ => at the end. C# can’t yet check that a class hierarchy is fully covered, so the missing case still shows up at runtime. Visitor moves that check to compile time.
Easily Confused With
- Iterator. Both deal with “every element of a structure”. An iterator decides how you move through the elements. A visitor decides what you do with each one. They pair nicely: iterate, then let each element accept a visitor.
- Strategy. Each visitor is a bit like a strategy, one interchangeable operation. The difference is that a visitor has a method per element type, and the elements take part in choosing which one runs. A strategy has one method and doesn’t care what type it’s handling.
Try It Yourself
dotnet build patterns/behavioral/visitor/Visitor.slnx
dotnet test patterns/behavioral/visitor/Visitor.slnx
dotnet run --project patterns/behavioral/visitor/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json
Exercise: add a ShippingClassVisitor that labels each product as parcel, email or none. No product class changes. Then try adding a Visit(SubscriptionProduct) method to the interface, and let the compiler show you every place that needs updating.
The Visitor README has the participant table and a sequence diagram. The full change is in DIFF.md.
That’s all 23 patterns! Next, let’s put the most confusing pairs side by side.
Previous: Template Method · Next: Commonly Confused Patterns