Behavioral Advanced 8 min read

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
  1. What problem does the Visitor pattern solve in C#?
  2. How does the textbook Visitor with double dispatch look?
  3. How does pattern matching replace Visitor in C# 9+?
  4. When is classical Visitor still the right answer?
  5. How does Visitor compare to Iterator and Composite?
  6. When does the Visitor pattern misfire?
  7. What does a real .NET 10 example look like?
  8. 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 ICartNodeComputeTax(), 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:

  1. AST traversal. A compiler's syntax tree has fixed node types but operations evolve: type-check, optimise, lower, pretty-print, generate code.
  2. Domain trees. Cart trees, document trees, organisation charts. Tax computation, audit, formatting are all separate walks.
  3. Plug-in points. Tools that walk a third-party type hierarchy (Roslyn SyntaxNode, EF Core IEntityType) 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:

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:

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:

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.

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?
Use a 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?
Single dispatch picks a method based on the receiver's runtime type — that is what virtual methods do. Visitor needs to pick both the operation (the visitor) and the node type (the element). Without language support for double dispatch, the GoF pattern simulates it: the node calls 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?
Two reasons. The pattern requires every node class to know about the visitor interface (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?
When the operation has many methods rather than one — 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.