Visitor Pattern in C#: switch Expressions and Double Dispatch
Visitor pattern in C# / .NET 10: when switch expressions with pattern matching beat the GoF double-dispatch hierarchy, and when classical Visitor still wins.
Table of contents
- What problem does the Visitor pattern solve in C#?
- How does the textbook Visitor with double dispatch look?
- How does pattern matching replace Visitor in C# 9+?
- When is classical Visitor still the right answer?
- How does Visitor compare to Iterator and Composite?
- When does the Visitor pattern misfire?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
The cart from the Composite chapter
is a tree of LineItem, Bundle, and FreeGift nodes. The
business asks for three new operations: compute taxes per
jurisdiction, audit-log every line, and decide whether each line
qualifies for free gift wrapping. Each operation is a recursive
walk; each requires different state.
The wrong instinct is to add three methods to ICartNode —
ComputeTax(), AuditDump(), IsGiftWrapEligible(). Two
problems. First, a node should not know about taxes; that is
domain logic that does not belong on a value type. Second,
adding a fourth operation tomorrow forces another method on
every node class.
The Visitor pattern is the answer the GoF gave for this.
Each operation becomes its own class that walks the tree. The
node classes stay closed. In modern C# pattern matching has
absorbed most of this responsibility, but the pattern is still
the right tool when the operations are heavy. This article shows
both the textbook double-dispatch version and the modern
switch-expression replacement, with honest trade-offs.
What problem does the Visitor pattern solve in C#?
The pattern earns its place when a closed set of typed nodes must support a growing set of operations, and you do not want to keep editing the node classes. Three concrete shapes:
- AST traversal. A compiler's syntax tree has fixed node types but operations evolve: type-check, optimise, lower, pretty-print, generate code.
- Domain trees. Cart trees, document trees, organisation charts. Tax computation, audit, formatting are all separate walks.
- Plug-in points. Tools that walk a third-party type
hierarchy (Roslyn
SyntaxNode, EF CoreIEntityType) without modifying it.
What is not a Visitor problem: "I want one operation across the tree" — just write a recursive method. "I want to traverse in order" — that is Iterator. Visitor is specifically about many operations on one closed node hierarchy.
How does the textbook Visitor with double dispatch look?
The classical pattern: a Visit* method per node type on the
visitor, and an Accept(visitor) method on each node:
public interface ICartVisitor<TResult>
{
TResult VisitLine (LineItem line);
TResult VisitBundle (Bundle bundle);
TResult VisitFreeGift(FreeGift gift);
}
public abstract record CartNode
{
public abstract TResult Accept<TResult>(ICartVisitor<TResult> visitor);
}
public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : CartNode
{
public override TResult Accept<TResult>(ICartVisitor<TResult> visitor)
=> visitor.VisitLine(this);
}
public sealed record Bundle(string Name, decimal DiscountFactor, IReadOnlyList<CartNode> Children) : CartNode
{
public override TResult Accept<TResult>(ICartVisitor<TResult> visitor)
=> visitor.VisitBundle(this);
}
public sealed record FreeGift(string Sku) : CartNode
{
public override TResult Accept<TResult>(ICartVisitor<TResult> visitor)
=> visitor.VisitFreeGift(this);
}
public sealed class TaxVisitor : ICartVisitor<decimal>
{
private readonly decimal _rate;
public TaxVisitor(decimal rate) => _rate = rate;
public decimal VisitLine(LineItem l) => l.UnitPrice * l.Quantity * _rate;
public decimal VisitFreeGift(FreeGift g) => 0m;
public decimal VisitBundle(Bundle b)
=> b.Children.Sum(c => c.Accept(this)) * b.DiscountFactor * _rate;
}
The "double dispatch" lives in Accept: each node calls the
visitor's type-specific method, picked by the static type of
this. The caller writes:
decimal tax = root.Accept(new TaxVisitor(0.08m));
Adding a fourth operation — AuditVisitor — is one new class,
zero edits to node types.
The structural picture:
classDiagram
class CartNode {
<<abstract>>
+Accept(visitor)
}
class LineItem
class Bundle
class FreeGift
CartNode <|-- LineItem
CartNode <|-- Bundle
CartNode <|-- FreeGift
class ICartVisitor {
<<interface>>
+VisitLine(line)
+VisitBundle(b)
+VisitFreeGift(g)
}
class TaxVisitor
class AuditVisitor
ICartVisitor <|.. TaxVisitor
ICartVisitor <|.. AuditVisitor
The trade-off: every node knows about ICartVisitor. Adding a
node class (Coupon?) is now a breaking change to every visitor.
How does pattern matching replace Visitor in C# 9+?
C# 9 introduced sealed types and exhaustive switch with type
patterns. C# 11 made required and primary constructors better.
Together they let you write Visitor-style operations without
the Accept plumbing:
// Nodes — sealed; no Accept method.
public abstract record CartNode;
public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : CartNode;
public sealed record Bundle(string Name, decimal DiscountFactor, IReadOnlyList<CartNode> Children) : CartNode;
public sealed record FreeGift(string Sku) : CartNode;
// Operation 1 — tax
public static decimal ComputeTax(CartNode node, decimal rate) => node switch
{
LineItem l => l.UnitPrice * l.Quantity * rate,
FreeGift => 0m,
Bundle b => b.Children.Sum(c => ComputeTax(c, rate)) * b.DiscountFactor * rate,
_ => throw new InvalidOperationException("Unknown CartNode")
};
// Operation 2 — audit (new file, no edits to nodes)
public static IEnumerable<string> AuditLines(CartNode node, int indent = 0) => node switch
{
LineItem l => new[] { $"{new string(' ', indent)}{l.Sku} x{l.Quantity}" },
FreeGift g => new[] { $"{new string(' ', indent)}🎁 {g.Sku}" },
Bundle b => new[] { $"{new string(' ', indent)}{b.Name}" }
.Concat(b.Children.SelectMany(c => AuditLines(c, indent + 2))),
_ => Array.Empty<string>()
};
What you get:
- No
Acceptmethod on nodes. The data model is pure data. - Compiler-warned exhaustiveness. With sealed nodes, the
compiler can flag missing cases (warning level varies by C#
version; Roslyn analysers like
IDE0072help). - Operations are functions. Easier to test, easier to compose with LINQ.
The trade-off: the abstraction "an operation on the cart tree"
no longer has a type — it is just a method. Tooling that wants
to enumerate available operations cannot do so by reflection over
ICartVisitor implementations.
When is classical Visitor still the right answer?
Three situations where the GoF version still wins:
- Operations have many methods, not one. A
PrintVisitorwithIndent,EmitToken,Wrap, plus per-visitor state. Functions cannot share state cleanly across recursive calls; a class can. - Tooling enumerates operations. A plug-in system loads
visitor implementations from external assemblies; reflection
finds
ICartVisitortypes.switchcannot be reflected. - The node tree is from a third party. Roslyn's
CSharpSyntaxWalker,CSharpSyntaxVisitor<TResult>— Roslyn is itself a Visitor pattern, and your code plugs in by subclassing those visitors. You cannot replace it with aswitchbecause you do not own the node hierarchy.
For application code with a closed domain tree, pattern matching wins. For deeply extensible systems, Visitor still earns its place.
How does Visitor compare to Iterator and Composite?
| Pattern | Purpose | Direction |
|---|---|---|
| Visitor | Add new operations to a closed tree | Operation pulls; tree pushes via Accept |
| Iterator | Walk a collection one element at a time | Caller pulls one element |
| Composite | Treat leaves and groups uniformly | Tree shape only |
The cleanest split: Composite gives you the tree shape; Iterator yields one element; Visitor performs operations across the whole structure. A typical .NET app uses all three: a Composite cart, an Iterator that flattens it, and a Visitor that computes taxes recursively.
When does the Visitor pattern misfire?
Three traps:
- Open node hierarchies. If new node classes can be added
by callers, every visitor breaks on the next addition. Use
switchwith a_default plus a logged warning, or do not use Visitor. - Visitor with state that leaks across calls. A
TaxVisitoraccumulating into a private field works for one walk and breaks on the second. Make visitors stateless or pass state through the recursion explicitly. - Async visitors awkwardly written sync. The interface is
TResult Visit*; turning it async is awkward. Either defineTask<TResult>-returning interfaces from the start, or use pattern matching where async is natural.
What does a real .NET 10 example look like?
A pragmatic shape: pattern matching for the application code, with a small classical Visitor where it is genuinely needed (plug-in architecture). The cart from the Composite chapter gets two new operations:
public abstract record CartNode;
public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : CartNode;
public sealed record Bundle(string Name, decimal DiscountFactor, IReadOnlyList<CartNode> Children) : CartNode;
public sealed record FreeGift(string Sku) : CartNode;
public static class CartOperations
{
public static decimal ComputeTax(CartNode node, decimal rate) => node switch
{
LineItem l => l.UnitPrice * l.Quantity * rate,
FreeGift => 0m,
Bundle b => b.Children.Sum(c => ComputeTax(c, rate)) * b.DiscountFactor * rate,
_ => throw new InvalidOperationException(nameof(node)),
};
public static IEnumerable<string> Audit(CartNode node, int indent = 0) => node switch
{
LineItem l => new[] { $"{new string(' ', indent)}{l.Sku} x{l.Quantity}" },
FreeGift g => new[] { $"{new string(' ', indent)}🎁 {g.Sku}" },
Bundle b => new[] { $"{new string(' ', indent)}{b.Name}" }
.Concat(b.Children.SelectMany(c => Audit(c, indent + 2))),
_ => Array.Empty<string>(),
};
public static bool IsGiftWrapEligible(CartNode node) => node switch
{
LineItem l => l.UnitPrice >= 50m,
FreeGift => true,
Bundle b => b.Children.All(IsGiftWrapEligible),
_ => false,
};
}
// Caller
var cart = new Bundle("Cart", 1.0m, new CartNode[]
{
new LineItem("BOOK", 9.99m, 2),
new Bundle("Gift Box", 0.9m, new CartNode[]
{
new LineItem("MUG", 12m, 1),
new FreeGift("CARD"),
}),
});
var tax = CartOperations.ComputeTax(cart, 0.08m);
var lines = CartOperations.Audit(cart).ToList();
var canWrap = CartOperations.IsGiftWrapEligible(cart);
[Fact]
public void ComputeTax_excludes_free_gifts()
{
var tree = new Bundle("X", 1m, new CartNode[]
{
new LineItem("A", 10m, 1),
new FreeGift("B"),
});
Assert.Equal(0.8m, CartOperations.ComputeTax(tree, 0.08m));
}
What you do not see: an Accept method on any node. Adding a
fourth operation (SerializeToJson) is one new method, zero
edits to nodes. Adding a new node type (Coupon) requires
updating each operation's switch — the same maintenance cost,
but the compiler points to every place you missed.
Where should you read next in this series?
- Previous: Template Method — inheritance hooks for shared skeletons.
- Next: How to choose the right design pattern — the decision tree that ties all 23 patterns together.
- Cross-reference: Composite — the tree your visitor walks.
- Cross-reference: Iterator — pulling one element at a time rather than performing an operation across the whole tree.
- Series map: Introduction.
A pragmatic closer for the behavioural group: modern C# has
made several behavioural patterns nearly invisible — Strategy
became Func<>, Iterator became yield, Memento became record with. Visitor is the most striking example, because pattern
matching gave us a better tool for most cases. The lesson
across the whole series is the same: the patterns survive
because the intents are durable; the implementations
change with the language.
Frequently asked questions
When should I use a switch expression instead of a Visitor class?
switch expression with type patterns when the node types are closed (you control all variants), the operations are short, and you want each operation in one place. The compiler can warn on missing cases when nodes are sealed, giving you Visitor's exhaustiveness without the boilerplate. Reach for classical Visitor when operations are large, stateful, or numerous.What is double dispatch and why does Visitor need it?
visitor.Visit(this), and overload resolution picks the right overload because this is statically the concrete node type. C# pattern matching solves the same problem differently.Why is Visitor controversial in modern C# code?
Accept(visitor)), coupling the data model to its operations. And switch with type patterns achieves the same separation of operations from nodes without that coupling, plus the compiler tells you when you missed a case. Visitor is sometimes the right tool, but defaults shifted in C# 9–11.Where does Visitor still beat pattern matching?
IEvaluator.VisitAdd, VisitMul, VisitConst plus shared per-visitor state. When the node tree allows third-party extensions and the visitor must be open to them. When you want the operation (a PrintVisitor, OptimiseVisitor) to be the unit of testing rather than each case arm.