Visitor Design Pattern in C#: Complete Guide with Examples
When you need to add new operations to an object structure without modifying the classes that make up that structure, the visitor design pattern in C# is the behavioral pattern designed for exactly that job. It separates algorithms from the objects they operate on by letting you define new operations in standalone visitor classes -- all without touching the element hierarchy. The result is cleaner code that respects the Open/Closed Principle and keeps your object model stable as requirements grow.
In this complete guide, we'll work through everything you need to know about the visitor pattern -- from the core participants and the double dispatch mechanism to practical C# implementations, real-world use cases, and common pitfalls. By the end, you'll have multiple working code examples and a clear understanding of when the visitor pattern is the right tool for the job.
Understanding the Visitor Pattern
The visitor design pattern is a behavioral pattern from the Gang of Four (GoF) catalog that lets you define new operations on an object structure without changing the classes of the elements in that structure. The core idea is simple: instead of putting every possible operation directly inside your element classes, you move each operation into its own visitor class. Elements accept a visitor, and the visitor performs the operation based on the element's concrete type.
Think about a compiler. You have an abstract syntax tree full of different node types -- assignments, function calls, binary expressions, literals. You need to perform many different operations on that tree: type checking, optimization, code generation, pretty printing. If you bake each of those operations into the node classes themselves, those classes become enormous and tangled. The visitor pattern lets you write a separate class for each operation while the node classes stay focused on representing the syntax tree.
This separation is the visitor pattern's fundamental contribution. Your object hierarchy stays stable. Need a new operation? Write a new visitor. The element classes don't change at all.
Core Components of the Visitor Pattern
The visitor pattern involves five key participants that work together to separate algorithms from object structures. Understanding each role is essential before looking at code.
The Visitor is the interface or abstract class that declares a Visit method for each concrete element type in the hierarchy. Each overload takes a specific element type as its parameter, which is what enables the double dispatch mechanism.
The ConcreteVisitor implements the visitor interface and defines the actual algorithm for each element type. An AreaCalculatorVisitor might compute areas, while a PerimeterCalculatorVisitor computes perimeters -- both implement the same visitor interface but perform entirely different operations.
The Element is the base interface or abstract class for elements in the object structure. It declares an Accept method that takes a visitor as its parameter. This is the hook that lets visitors interact with elements.
The ConcreteElement implements the element interface. Each concrete element's Accept method calls the appropriate Visit method on the visitor, passing this as the argument. This call is what completes the double dispatch.
The ObjectStructure is the collection or composite that holds elements and provides a way to iterate over them. It allows visitors to traverse the structure and visit each element. The composite pattern and the iterator pattern often work hand-in-hand with the visitor pattern in this role.
Double Dispatch: The Key Mechanism
The visitor pattern relies on a technique called double dispatch. In most object-oriented languages -- C# included -- method calls dispatch based on the runtime type of the object the method is called on. That's single dispatch. But the visitor pattern needs dispatch based on two types: the visitor's type and the element's type.
Here's how it works. When you call element.Accept(visitor), the runtime dispatches based on the concrete type of element. Inside that Accept method, the element calls visitor.Visit(this). Because this inside a Circle is of type Circle, the compiler resolves the call to the Visit(Circle) overload. The runtime then dispatches based on the concrete type of visitor. Two dispatches, two types resolved -- that's double dispatch.
This is different from simply overloading methods. If you had a visitor with overloaded Visit methods and passed an element through a base-type variable, the compiler would resolve to the base overload at compile time. Double dispatch through Accept ensures the correct overload is selected at runtime, every time.
Basic Implementation: Shape Hierarchy
Let's build a classic visitor pattern example with shapes. We'll create visitors that calculate area and perimeter without adding those operations to the shape classes themselves.
Defining the Element and Visitor Interfaces
public interface IShapeVisitor
{
void Visit(Circle circle);
void Visit(Rectangle rectangle);
void Visit(Triangle triangle);
}
public interface IShape
{
void Accept(IShapeVisitor visitor);
}
Creating the Concrete Elements
Each shape holds its geometric data and implements Accept by calling the correct visitor overload:
public sealed class Circle : IShape
{
public double Radius { get; }
public Circle(double radius)
{
Radius = radius;
}
public void Accept(IShapeVisitor visitor) =>
visitor.Visit(this);
}
public sealed class Rectangle : IShape
{
public double Width { get; }
public double Height { get; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public void Accept(IShapeVisitor visitor) =>
visitor.Visit(this);
}
public sealed class Triangle : IShape
{
public double SideA { get; }
public double SideB { get; }
public double SideC { get; }
public Triangle(
double sideA,
double sideB,
double sideC)
{
SideA = sideA;
SideB = sideB;
SideC = sideC;
}
public void Accept(IShapeVisitor visitor) =>
visitor.Visit(this);
}
Creating the Concrete Visitors
The area calculator and perimeter calculator are two separate visitors -- two separate algorithms that operate on the same shape hierarchy:
using System;
public sealed class AreaCalculatorVisitor : IShapeVisitor
{
public double TotalArea { get; private set; }
public void Visit(Circle circle) =>
TotalArea += Math.PI * circle.Radius * circle.Radius;
public void Visit(Rectangle rectangle) =>
TotalArea += rectangle.Width * rectangle.Height;
public void Visit(Triangle triangle)
{
double s = (triangle.SideA + triangle.SideB
+ triangle.SideC) / 2;
TotalArea += Math.Sqrt(
s * (s - triangle.SideA)
* (s - triangle.SideB)
* (s - triangle.SideC));
}
}
public sealed class PerimeterCalculatorVisitor : IShapeVisitor
{
public double TotalPerimeter { get; private set; }
public void Visit(Circle circle) =>
TotalPerimeter += 2 * Math.PI * circle.Radius;
public void Visit(Rectangle rectangle) =>
TotalPerimeter += 2 * (rectangle.Width
+ rectangle.Height);
public void Visit(Triangle triangle) =>
TotalPerimeter += triangle.SideA + triangle.SideB
+ triangle.SideC;
}
Using the Visitors
var shapes = new List<IShape>
{
new Circle(5),
new Rectangle(4, 6),
new Triangle(3, 4, 5)
};
var areaVisitor = new AreaCalculatorVisitor();
var perimeterVisitor = new PerimeterCalculatorVisitor();
foreach (IShape shape in shapes)
{
shape.Accept(areaVisitor);
shape.Accept(perimeterVisitor);
}
Console.WriteLine(
$"Total Area: {areaVisitor.TotalArea:F2}");
Console.WriteLine(
$"Total Perimeter: {perimeterVisitor.TotalPerimeter:F2}");
Notice how adding a new operation -- say, a DrawVisitor -- requires zero changes to Circle, Rectangle, or Triangle. You write a new visitor class and you're done. The shape hierarchy stays untouched. Compare this to putting CalculateArea(), CalculatePerimeter(), and Draw() methods directly on each shape -- every new operation would force changes across the entire hierarchy.
Document Element Visitor: Rendering Content
The visitor pattern shines when you need multiple output formats from the same data structure. Consider a document with different element types that you need to render as both HTML and plain text.
public interface IDocumentVisitor
{
void Visit(Heading heading);
void Visit(Paragraph paragraph);
void Visit(CodeBlock codeBlock);
}
public interface IDocumentElement
{
void Accept(IDocumentVisitor visitor);
}
public sealed class Heading : IDocumentElement
{
public string Text { get; }
public int Level { get; }
public Heading(string text, int level)
{
Text = text;
Level = level;
}
public void Accept(IDocumentVisitor visitor) =>
visitor.Visit(this);
}
public sealed class Paragraph : IDocumentElement
{
public string Text { get; }
public Paragraph(string text)
{
Text = text;
}
public void Accept(IDocumentVisitor visitor) =>
visitor.Visit(this);
}
public sealed class CodeBlock : IDocumentElement
{
public string Code { get; }
public string Language { get; }
public CodeBlock(string code, string language)
{
Code = code;
Language = language;
}
public void Accept(IDocumentVisitor visitor) =>
visitor.Visit(this);
}
Now two visitors that render the same document structure in completely different formats:
using System.Text;
public sealed class HtmlRenderVisitor : IDocumentVisitor
{
private readonly StringBuilder _builder = new();
public string GetResult() => _builder.ToString();
public void Visit(Heading heading) =>
_builder.AppendLine(
$"<h{heading.Level}>{heading.Text}" +
$"</h{heading.Level}>");
public void Visit(Paragraph paragraph) =>
_builder.AppendLine($"<p>{paragraph.Text}</p>");
public void Visit(CodeBlock codeBlock) =>
_builder.AppendLine(
$"<pre><code class="{codeBlock.Language}">" +
$"{codeBlock.Code}</code></pre>");
}
public sealed class PlainTextRenderVisitor : IDocumentVisitor
{
private readonly StringBuilder _builder = new();
public string GetResult() => _builder.ToString();
public void Visit(Heading heading)
{
_builder.AppendLine(heading.Text.ToUpperInvariant());
_builder.AppendLine(
new string('=', heading.Text.Length));
}
public void Visit(Paragraph paragraph) =>
_builder.AppendLine(paragraph.Text);
public void Visit(CodeBlock codeBlock)
{
_builder.AppendLine($"[{codeBlock.Language}]");
_builder.AppendLine(codeBlock.Code);
}
}
This is the same principle that the strategy pattern applies to algorithms -- but the visitor pattern extends it across an entire hierarchy of types rather than parameterizing a single behavior.
Expression Tree Visitor: Evaluate and Pretty-Print
Expression trees are one of the most natural fits for the visitor pattern. Each node type represents a different syntactic element, and you need multiple traversal operations -- evaluation, printing, optimization -- that shouldn't live inside the nodes. The interpreter pattern is closely related, but the visitor pattern keeps each operation in a dedicated class rather than distributing evaluation logic across the node types.
public interface IExpressionVisitor
{
void Visit(NumberExpression number);
void Visit(AddExpression add);
void Visit(MultiplyExpression multiply);
}
public interface IExpression
{
void Accept(IExpressionVisitor visitor);
}
public sealed class NumberExpression : IExpression
{
public double Value { get; }
public NumberExpression(double value)
{
Value = value;
}
public void Accept(IExpressionVisitor visitor) =>
visitor.Visit(this);
}
public sealed class AddExpression : IExpression
{
public IExpression Left { get; }
public IExpression Right { get; }
public AddExpression(
IExpression left,
IExpression right)
{
Left = left;
Right = right;
}
public void Accept(IExpressionVisitor visitor) =>
visitor.Visit(this);
}
public sealed class MultiplyExpression : IExpression
{
public IExpression Left { get; }
public IExpression Right { get; }
public MultiplyExpression(
IExpression left,
IExpression right)
{
Left = left;
Right = right;
}
public void Accept(IExpressionVisitor visitor) =>
visitor.Visit(this);
}
The evaluation visitor recursively traverses the tree to compute a result:
using System.Collections.Generic;
public sealed class EvaluateVisitor : IExpressionVisitor
{
private readonly Stack<double> _results = new();
public double Result => _results.Peek();
public void Visit(NumberExpression number) =>
_results.Push(number.Value);
public void Visit(AddExpression add)
{
add.Left.Accept(this);
add.Right.Accept(this);
double right = _results.Pop();
double left = _results.Pop();
_results.Push(left + right);
}
public void Visit(MultiplyExpression multiply)
{
multiply.Left.Accept(this);
multiply.Right.Accept(this);
double right = _results.Pop();
double left = _results.Pop();
_results.Push(left * right);
}
}
The pretty-print visitor produces a human-readable representation:
using System.Collections.Generic;
public sealed class PrettyPrintVisitor : IExpressionVisitor
{
private readonly Stack<string> _output = new();
public string Result => _output.Peek();
public void Visit(NumberExpression number) =>
_output.Push(number.Value.ToString());
public void Visit(AddExpression add)
{
add.Left.Accept(this);
add.Right.Accept(this);
string right = _output.Pop();
string left = _output.Pop();
_output.Push($"({left} + {right})");
}
public void Visit(MultiplyExpression multiply)
{
multiply.Left.Accept(this);
multiply.Right.Accept(this);
string right = _output.Pop();
string left = _output.Pop();
_output.Push($"({left} * {right})");
}
}
Usage ties it all together:
// Represents: (3 + 4) * 2
IExpression expression = new MultiplyExpression(
new AddExpression(
new NumberExpression(3),
new NumberExpression(4)),
new NumberExpression(2));
var evaluator = new EvaluateVisitor();
expression.Accept(evaluator);
Console.WriteLine($"Result: {evaluator.Result}");
var printer = new PrettyPrintVisitor();
expression.Accept(printer);
Console.WriteLine($"Expression: {printer.Result}");
This produces:
Result: 14
Expression: ((3 + 4) * 2)
Two completely different traversal operations, zero changes to the expression node classes. That's the visitor pattern doing its job.
Generic Visitor for Type Safety
The examples above use void return types and accumulate results in mutable state. A generic visitor interface lets you return typed results directly, which improves type safety and eliminates the need for result stacks or accumulator properties.
public interface IShapeVisitor<TResult>
{
TResult Visit(Circle circle);
TResult Visit(Rectangle rectangle);
TResult Visit(Triangle triangle);
}
public interface IVisitableShape
{
TResult Accept<TResult>(
IShapeVisitor<TResult> visitor);
}
public sealed class CircleV2 : IVisitableShape
{
public double Radius { get; }
public CircleV2(double radius)
{
Radius = radius;
}
public TResult Accept<TResult>(
IShapeVisitor<TResult> visitor) =>
visitor.Visit(
new Circle(Radius));
}
Wait -- we don't want to allocate new shapes inside Accept. Let's refine the approach so that the generic visitor methods take the concrete types directly:
public interface IShapeVisitor<out TResult>
{
TResult Visit(Circle circle);
TResult Visit(Rectangle rectangle);
TResult Visit(Triangle triangle);
}
public interface IShape<out TResult>
{
TResult Accept(IShapeVisitor<TResult> visitor);
}
A cleaner approach uses a non-generic IShape with a generic Accept:
public interface IGenericShapeVisitor<out TResult>
{
TResult Visit(Circle circle);
TResult Visit(Rectangle rectangle);
TResult Visit(Triangle triangle);
}
public interface IGenericShape
{
TResult Accept<TResult>(
IGenericShapeVisitor<TResult> visitor);
}
public sealed class CircleG : IGenericShape
{
public double Radius { get; }
public CircleG(double radius)
{
Radius = radius;
}
public TResult Accept<TResult>(
IGenericShapeVisitor<TResult> visitor) =>
visitor.Visit(new Circle(Radius));
}
Here's a complete, self-contained generic visitor example:
public interface IExprVisitor<out T>
{
T Visit(NumExpr num);
T Visit(AddExpr add);
}
public interface IExpr
{
T Accept<T>(IExprVisitor<T> visitor);
}
public sealed class NumExpr : IExpr
{
public double Value { get; }
public NumExpr(double value)
{
Value = value;
}
public T Accept<T>(IExprVisitor<T> visitor) =>
visitor.Visit(this);
}
public sealed class AddExpr : IExpr
{
public IExpr Left { get; }
public IExpr Right { get; }
public AddExpr(IExpr left, IExpr right)
{
Left = left;
Right = right;
}
public T Accept<T>(IExprVisitor<T> visitor) =>
visitor.Visit(this);
}
public sealed class EvalVisitor : IExprVisitor<double>
{
public double Visit(NumExpr num) =>
num.Value;
public double Visit(AddExpr add) =>
add.Left.Accept(this)
+ add.Right.Accept(this);
}
public sealed class PrintVisitor : IExprVisitor<string>
{
public string Visit(NumExpr num) =>
num.Value.ToString();
public string Visit(AddExpr add) =>
$"({add.Left.Accept(this)} + " +
$"{add.Right.Accept(this)})";
}
The generic approach removes mutable accumulator fields and makes each visitor's return type explicit at the interface level. The compiler enforces that every Visit overload returns the correct type, catching errors at compile time rather than runtime. This approach works well when each visit operation produces a single value. For visitors that perform side effects -- like writing to a stream or logging -- the void-returning style from the earlier examples is a better fit.
When to Use the Visitor Pattern
The visitor pattern is a strong choice when the element hierarchy is stable but you expect a growing number of operations. If you rarely add new element types but frequently add new algorithms or traversals, the visitor pattern minimizes churn. Each new operation is a single new class rather than changes scattered across every element type.
The pattern is also valuable when operations don't conceptually belong to the element classes. A shape doesn't need to know how to serialize itself to JSON -- that's a serialization concern, not a geometry concern. A visitor keeps those responsibilities separated, which aligns with the Single Responsibility Principle.
However, if your hierarchy changes frequently -- if you're adding new element types all the time -- the visitor pattern becomes painful. Every new element type requires updating every visitor interface and every concrete visitor. That's the fundamental trade-off. The visitor pattern trades ease of adding operations for difficulty of adding element types. If your situation is the reverse, consider the command pattern or simple polymorphism with virtual methods instead.
Benefits and Drawbacks
Benefits
Separating algorithms from data structures is the visitor pattern's primary strength. Your element classes remain focused on representing data, while visitors encapsulate the operations. This is a clean application of the Single Responsibility Principle.
Adding new operations is easy. Each new visitor is an independent class. You don't modify existing elements or visitors -- you just write a new one. This aligns with the Open/Closed Principle for operations.
Accumulating state across a traversal is natural. A visitor can maintain state as it visits multiple elements, aggregating results across an entire object structure. The area calculator example demonstrated this -- the visitor accumulated the total area across all shapes.
Related behavior lives together. Instead of a CalculateArea method spread across Circle, Rectangle, and Triangle, all area logic lives in AreaCalculatorVisitor. This makes it easy to understand and modify a single operation without hunting across multiple files.
Drawbacks
Adding new element types is expensive. Every new element type means a new Visit overload on the visitor interface and a new implementation in every concrete visitor. If your hierarchy changes often, this creates significant maintenance overhead.
Breaking encapsulation is a risk. Visitors need access to element data to do useful work. If element classes need to expose internal state to visitors, you may weaken encapsulation. Design your elements with meaningful public properties rather than exposing raw implementation details.
Increased complexity for simple cases. If you only have one or two operations, the visitor pattern adds interfaces and indirection that a simple virtual method call would handle just fine. The pattern earns its keep when you have many operations -- not just one or two. Simple virtual methods or a straightforward decorator may be simpler alternatives for limited operation sets.
Integrating with Dependency Injection
In production .NET applications, you can register visitors with the DI container so that consuming code doesn't need to construct them directly. This works well when visitors have their own dependencies -- like loggers, formatters, or configuration objects.
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddTransient<IShapeVisitor, AreaCalculatorVisitor>();
var provider = services.BuildServiceProvider();
var visitor = provider.GetRequiredService<IShapeVisitor>();
When you need multiple visitor implementations and want to select among them, you can register named or keyed visitors, or use a factory pattern. This integrates naturally with inversion of control and IServiceCollection -- the consuming code receives whichever visitor implementation the container provides, keeping the client decoupled from concrete visitor classes.
Common Mistakes to Avoid
Several mistakes come up repeatedly when developers implement the visitor pattern. Knowing these pitfalls upfront helps you avoid unnecessary debugging.
Forgetting to call Accept. Calling visitor.Visit(element) directly through a base-type variable bypasses double dispatch. Always call element.Accept(visitor) so the correct Visit overload is resolved at runtime. This is the most common mistake and it silently produces wrong results.
Making the visitor interface too broad. If your visitor interface has twenty Visit overloads, it's a sign that your element hierarchy may be too large for the visitor pattern. Consider breaking the hierarchy into smaller groups, each with its own visitor interface.
Ignoring thread safety. Visitors that accumulate state -- like the AreaCalculatorVisitor with its TotalArea property -- are not thread-safe by default. If you're visiting elements in parallel, either use thread-local visitors or synchronize access to shared state.
Mixing concerns in a single visitor. A visitor should perform one cohesive operation. If your visitor is both calculating areas and generating reports, split it into two visitors. A coordinating class can provide a simplified interface if clients need to invoke multiple visitors together.
Frequently Asked Questions
What is the visitor design pattern in C#?
The visitor design pattern in C# is a behavioral design pattern that lets you define new operations on an object structure without modifying the element classes themselves. You create visitor classes that implement an operation for each element type, and elements expose an Accept method that delegates to the visitor. This separates algorithms from the data structures they operate on, making it easy to add new operations as standalone classes.
How does double dispatch work in the visitor pattern?
Double dispatch is a two-step method resolution process. First, calling element.Accept(visitor) dispatches based on the element's runtime type. Inside Accept, the element calls visitor.Visit(this), which dispatches based on the visitor's runtime type and the compile-time type of this (which is the concrete element type inside each class). These two dispatches together ensure the correct visitor method handles the correct element type -- even when both are referenced through base-type variables.
When should I use the visitor pattern instead of virtual methods?
Use the visitor pattern when your element hierarchy is stable but you expect many different operations to be performed on it. Virtual methods work well when you have a small, fixed set of operations but might add new element types frequently. The visitor pattern inverts this trade-off -- it makes adding operations easy but adding element types expensive. If you're adding both frequently, neither approach is ideal and you may need to reconsider your design.
Can the visitor pattern work with sealed classes in C#?
Yes -- and sealed classes are a good fit. Since elements in the visitor pattern don't need to be subclassed further (each concrete element is a terminal type), marking them sealed prevents unintended inheritance and allows the compiler to make optimizations. The visitor interface already defines the full set of element types through its Visit overloads, so sealing elements reinforces the pattern's expectation of a fixed element hierarchy.
What is the difference between the visitor pattern and the strategy pattern?
The strategy pattern encapsulates a single interchangeable algorithm behind a uniform interface. The visitor pattern encapsulates an operation that varies across multiple element types. A strategy replaces one behavior on one object. A visitor defines behavior for an entire family of element types. If you need to vary a single algorithm, use strategy. If you need to perform different operations across a type hierarchy, use visitor.
How do I handle a new element type without breaking existing visitors?
Adding a new element type requires adding a new Visit overload to the visitor interface, which forces changes in every concrete visitor. To mitigate this, you can provide a default base visitor class with no-op implementations for each Visit method. Concrete visitors override only the methods they care about. When a new element type is added, the base class provides a default implementation, and only visitors that need special handling for the new type require updates.
Does the visitor pattern violate encapsulation?
The visitor pattern can weaken encapsulation if elements expose internal implementation details to support visitor operations. The best practice is to expose only meaningful, well-defined public properties on element classes -- the same properties you'd make public regardless of whether visitors exist. If a visitor forces you to expose internal state that should be private, that's a design smell. Consider whether the operation belongs inside the element class instead, or whether a wrapper could provide the necessary data without exposing internals.
Wrapping Up the Visitor Design Pattern in C#
The visitor design pattern in C# is a powerful behavioral pattern for separating algorithms from the object structures they operate on. By defining operations in standalone visitor classes and using double dispatch through Accept and Visit, you keep your element hierarchy stable while freely adding new operations as independent classes. The pattern respects the Open/Closed Principle for operations and naturally supports accumulating results across complex object structures.
The pattern works best when your element types are stable but your operations keep growing. Compilers, document processors, expression evaluators, and report generators are all scenarios where the visitor pattern eliminates scattered logic and keeps each operation cohesive. Combined with generics for type safety and dependency injection for flexible wiring, the visitor pattern scales to real production code.
Start by identifying places in your codebase where you're adding methods to a class hierarchy every time a new requirement arrives. That's a strong signal the visitor pattern could help. Keep your visitor interfaces focused, use sealed classes for elements, and remember the fundamental trade-off -- the visitor pattern makes adding operations easy at the cost of making adding element types harder. Choose it when the math works in your favor.

