How to Implement Visitor Pattern in C#: Step-by-Step Guide
Understanding a design pattern conceptually is only half the battle -- the real value comes when you can wire it into production code. If you want to implement visitor pattern in C#, you need to understand double dispatch, element hierarchies, and the separation of algorithms from the objects they operate on. This guide walks you through the entire process with complete, compilable code examples.
The visitor pattern lets you define new operations on a set of classes without modifying those classes. Instead of scattering algorithm logic across every element type, you centralize it in visitor objects. Each visitor encapsulates a single operation, and the elements accept visitors through a well-defined protocol. If you've worked with the strategy pattern, you'll recognize a similar goal -- decoupling algorithms from the code that uses them -- but the visitor pattern takes it further by dispatching based on the element's concrete type.
In this step-by-step guide, we'll build an employee hierarchy, implement visitors for salary calculation and report generation, demonstrate the double dispatch mechanism, and write unit tests to verify everything works. By the end, you'll have a thorough understanding of how to implement visitor pattern in C# for real-world scenarios.
Step 1: Understand Double Dispatch
Before writing any code, you need to understand the core mechanism that makes the visitor pattern work: double dispatch. In C#, a standard virtual method call is single dispatch -- the runtime selects the method based on the actual type of the object the method is called on. Double dispatch extends this so that the method selected depends on the types of two objects: the element being visited and the visitor doing the visiting.
Here's the sequence:
- Client code calls
element.Accept(visitor). - The element's
Acceptmethod callsvisitor.Visit(this). - Because
thisinside a concrete element class has the concrete type, the compiler resolves the correctVisitoverload at compile time. - At runtime, the combination of the element's virtual
Acceptdispatch and the visitor's overloadedVisitmethod produces behavior that depends on both types.
This two-step dispatch is what separates the visitor pattern from simpler approaches like if/else chains or switch statements on type. It's type-safe, extensible for new visitors, and avoids the fragile casting that litters many codebases.
A common alternative is pattern matching with switch expressions, which C# supports well. However, pattern matching puts the operation and the type knowledge in the same place, while the visitor pattern separates them. When you have many operations that need to run across the same type hierarchy, the visitor pattern scales better because adding a new operation means adding a new visitor class rather than modifying every switch block in the codebase.
Step 2: Define the IVisitor Interface
The visitor interface declares a Visit method for each concrete element type in the hierarchy. This is the contract that every visitor must fulfill. If you add a new element type later, every existing visitor must be updated -- this is a deliberate trade-off of the visitor pattern.
public interface IEmployeeVisitor
{
void Visit(Employee employee);
void Visit(Manager manager);
void Visit(Director director);
}
A few things to notice. Each overload accepts a different concrete element type, not the base interface. This is what enables the compiler to resolve the correct overload during double dispatch. The interface uses void return types here, but you can also use a generic visitor interface if your visitors need to return values:
public interface IEmployeeVisitor<TResult>
{
TResult Visit(Employee employee);
TResult Visit(Manager manager);
TResult Visit(Director director);
}
The generic variant is useful when you want visitors to compute and return a value -- for instance, calculating a bonus amount or generating a formatted string. We'll use both variants in this guide.
Step 3: Define the IElement Interface with Accept
The element interface declares a single method: Accept. This is the entry point for any visitor to interact with an element. Every concrete element implements Accept by calling the appropriate Visit method on the visitor, passing this as the argument.
public interface IEmployee
{
string Name { get; }
decimal BaseSalary { get; }
void Accept(IEmployeeVisitor visitor);
TResult Accept<TResult>(
IEmployeeVisitor<TResult> visitor);
}
The interface includes both a void and a generic Accept overload so that elements work with both visitor variants. The Name and BaseSalary properties expose the data that visitors need to perform their operations. This is important -- visitors shouldn't need to cast or use reflection to extract data from elements. The element's public API should provide everything a visitor might need.
Step 4: Create Concrete Element Classes
Now let's build the concrete elements. Each class implements IEmployee and provides its own Accept method that calls visitor.Visit(this). The this keyword is the key -- inside Employee, this is of type Employee, so the compiler binds to Visit(Employee). Inside Manager, this is of type Manager, binding to Visit(Manager).
public sealed class Employee : IEmployee
{
public Employee(string name, decimal baseSalary)
{
Name = name;
BaseSalary = baseSalary;
}
public string Name { get; }
public decimal BaseSalary { get; }
public void Accept(IEmployeeVisitor visitor)
{
visitor.Visit(this);
}
public TResult Accept<TResult>(
IEmployeeVisitor<TResult> visitor)
{
return visitor.Visit(this);
}
}
public sealed class Manager : IEmployee
{
public Manager(
string name,
decimal baseSalary,
int teamSize)
{
Name = name;
BaseSalary = baseSalary;
TeamSize = teamSize;
}
public string Name { get; }
public decimal BaseSalary { get; }
public int TeamSize { get; }
public void Accept(IEmployeeVisitor visitor)
{
visitor.Visit(this);
}
public TResult Accept<TResult>(
IEmployeeVisitor<TResult> visitor)
{
return visitor.Visit(this);
}
}
public sealed class Director : IEmployee
{
public Director(
string name,
decimal baseSalary,
string department)
{
Name = name;
BaseSalary = baseSalary;
Department = department;
}
public string Name { get; }
public decimal BaseSalary { get; }
public string Department { get; }
public void Accept(IEmployeeVisitor visitor)
{
visitor.Visit(this);
}
public TResult Accept<TResult>(
IEmployeeVisitor<TResult> visitor)
{
return visitor.Visit(this);
}
}
Each element is sealed because the visitor pattern relies on a fixed set of concrete types. The Accept methods look identical across all three classes, but they are not -- each one passes a differently-typed this reference, which is what drives the double dispatch. This is the piece that trips up many developers the first time they implement visitor pattern in C#.
Notice that Manager has a TeamSize property and Director has a Department property. These type-specific properties are what make the visitor pattern valuable -- visitors can access them through the strongly-typed Visit overloads without any casting.
Step 5: Implement Concrete Visitors
This is where the visitor pattern pays off. Each visitor encapsulates a complete algorithm that operates across the entire element hierarchy. Adding a new algorithm means adding a new visitor class -- no existing element code changes. If you've worked with inversion of control, you'll recognize a similar principle at work: the elements don't control which operations run on them.
SalaryCalculatorVisitor
This visitor calculates total compensation by applying different multipliers based on employee type:
public sealed class SalaryCalculatorVisitor
: IEmployeeVisitor<decimal>
{
public decimal Visit(Employee employee)
{
return employee.BaseSalary;
}
public decimal Visit(Manager manager)
{
decimal teamBonus = manager.TeamSize * 500m;
return manager.BaseSalary * 1.2m + teamBonus;
}
public decimal Visit(Director director)
{
return director.BaseSalary * 1.5m;
}
}
The Employee gets their base salary with no adjustments. The Manager receives a 20% multiplier plus a per-person team bonus. The Director gets a 50% multiplier. Each Visit overload has access to the type-specific properties it needs -- TeamSize for managers, Department for directors -- without any downcasting.
ReportGeneratorVisitor
This visitor produces formatted report lines for each employee:
public sealed class ReportGeneratorVisitor
: IEmployeeVisitor
{
private readonly List<string> _lines = new();
public IReadOnlyList<string> Lines => _lines;
public void Visit(Employee employee)
{
_lines.Add(
$"Employee: {employee.Name} " +
$"(Base: {employee.BaseSalary:C})");
}
public void Visit(Manager manager)
{
_lines.Add(
$"Manager: {manager.Name} " +
$"(Base: {manager.BaseSalary:C}, " +
$"Team: {manager.TeamSize})");
}
public void Visit(Director director)
{
_lines.Add(
$"Director: {director.Name} " +
$"(Base: {director.BaseSalary:C}, " +
$"Dept: {director.Department})");
}
}
This visitor uses the void variant of IEmployeeVisitor because it accumulates state internally rather than returning a value per visit. The Lines property exposes the collected report data after traversal. This is a common pattern -- the visitor walks the structure, gathers information, and then exposes the results through its own API.
The strength of this approach becomes clear when you compare it to the alternative: adding CalculateSalary() and GenerateReport() methods directly to each element class. That approach scatters unrelated logic across the element hierarchy and forces you to modify every element class every time you add a new operation. The visitor pattern consolidates each operation in a single class.
Step 6: Build the Object Structure
The object structure is the collection of elements that visitors traverse. For our employee hierarchy, this is a simple list -- but it could be a tree, a graph, or any composite structure. The structure is responsible for accepting a visitor and propagating it to each element.
public sealed class Department
{
private readonly List<IEmployee> _employees = new();
public string Name { get; }
public Department(string name)
{
Name = name;
}
public void Add(IEmployee employee)
{
_employees.Add(employee);
}
public void Accept(IEmployeeVisitor visitor)
{
foreach (IEmployee employee in _employees)
{
employee.Accept(visitor);
}
}
public IReadOnlyList<TResult> Accept<TResult>(
IEmployeeVisitor<TResult> visitor)
{
var results = new List<TResult>();
foreach (IEmployee employee in _employees)
{
results.Add(employee.Accept(visitor));
}
return results;
}
}
The Department class holds a collection of IEmployee instances and provides Accept methods that iterate over every employee. When you call department.Accept(visitor), each element dispatches to the correct Visit overload based on its concrete type. This is the full visitor pattern in action -- the structure drives traversal, the elements drive dispatch, and the visitors provide the operations. If you've used the iterator pattern for custom collections, you'll see a parallel: the structure defines what gets traversed while external code defines what happens at each step.
Step 7: See Double Dispatch in Action
Let's wire everything together and trace the double dispatch mechanism through a complete example:
var engineering = new Department("Engineering");
engineering.Add(
new Employee("Alice", 75_000m));
engineering.Add(
new Manager("Bob", 95_000m, teamSize: 8));
engineering.Add(
new Director("Carol", 120_000m, "Engineering"));
engineering.Add(
new Employee("Dave", 70_000m));
// Use the salary calculator visitor
var salaryVisitor = new SalaryCalculatorVisitor();
IReadOnlyList<decimal> salaries =
engineering.Accept(salaryVisitor);
for (int i = 0; i < salaries.Count; i++)
{
Console.WriteLine($"Salary: {salaries[i]:C}");
}
// Use the report generator visitor
var reportVisitor = new ReportGeneratorVisitor();
engineering.Accept(reportVisitor);
foreach (string line in reportVisitor.Lines)
{
Console.WriteLine(line);
}
Here's the dispatch trace for Bob (a Manager):
engineering.Accept(salaryVisitor)iterates over employees and callsemployee.Accept(salaryVisitor)whereemployeeis theIEmployeereference to Bob.- At runtime, Bob's actual type is
Manager, soManager.Acceptexecutes. - Inside
Manager.Accept, the callvisitor.Visit(this)passesthisas typeManager. - The compiler resolved this to
IEmployeeVisitor<decimal>.Visit(Manager)at compile time. SalaryCalculatorVisitor.Visit(Manager)runs, applying the 20% multiplier and team bonus.
The double dispatch is complete -- the first dispatch happened on Accept (selecting Manager's implementation), and the second happened on Visit (selecting the Manager overload). No if/else, no switch, no casting. This is what makes it possible to implement visitor pattern in C# in a type-safe and extensible way.
Step 8: Unit Testing the Visitor Pattern
The visitor pattern is highly testable because each visitor is an isolated unit with clear inputs and outputs. You can test visitors independently of the object structure. If you use dependency injection through IServiceCollection, visitors are straightforward to register and resolve in your DI container.
using Xunit;
public sealed class SalaryCalculatorVisitorTests
{
private readonly SalaryCalculatorVisitor _visitor = new();
[Fact]
public void Visit_Employee_ReturnsBaseSalary()
{
var employee = new Employee("Alice", 75_000m);
decimal result = employee.Accept(_visitor);
Assert.Equal(75_000m, result);
}
[Fact]
public void Visit_Manager_AppliesMultiplierAndTeamBonus()
{
var manager = new Manager("Bob", 95_000m, teamSize: 8);
decimal result = manager.Accept(_visitor);
decimal expected = 95_000m * 1.2m + 8 * 500m;
Assert.Equal(expected, result);
}
[Fact]
public void Visit_Director_AppliesDirectorMultiplier()
{
var director = new Director(
"Carol", 120_000m, "Engineering");
decimal result = director.Accept(_visitor);
Assert.Equal(120_000m * 1.5m, result);
}
}
public sealed class ReportGeneratorVisitorTests
{
[Fact]
public void Visit_AllTypes_GeneratesCorrectLineCount()
{
var department = new Department("Engineering");
department.Add(new Employee("Alice", 75_000m));
department.Add(
new Manager("Bob", 95_000m, teamSize: 5));
department.Add(
new Director("Carol", 120_000m, "Eng"));
var visitor = new ReportGeneratorVisitor();
department.Accept(visitor);
Assert.Equal(3, visitor.Lines.Count);
}
[Fact]
public void Visit_Manager_IncludesTeamSize()
{
var visitor = new ReportGeneratorVisitor();
var manager = new Manager("Bob", 95_000m, teamSize: 5);
manager.Accept(visitor);
Assert.Contains("Team: 5", visitor.Lines[0]);
}
[Fact]
public void Visit_Director_IncludesDepartment()
{
var visitor = new ReportGeneratorVisitor();
var director = new Director(
"Carol", 120_000m, "Engineering");
director.Accept(visitor);
Assert.Contains(
"Dept: Engineering", visitor.Lines[0]);
}
}
These tests verify each visitor's behavior for each element type in isolation. The key testing strategies for the visitor pattern are:
- Test each
Visitoverload independently: Create a single element, pass it to the visitor, and assert the result. This gives you precise control over inputs and clear expectations. - Test the object structure traversal separately: Create a
Departmentwith multiple elements, run a visitor, and verify that all elements were visited. This tests the traversal logic without coupling it to any specific visitor's behavior. - Test visitor state accumulation: For stateful visitors like
ReportGeneratorVisitor, verify that state accumulates correctly across multiple visits.
Because each visitor is a plain class with no hidden dependencies, you don't need mocking frameworks for basic tests. If a visitor depends on external services, you can inject those through the constructor and mock them as needed. The facade pattern shows a similar approach to simplifying complex subsystem interactions behind a clean interface.
Common Implementation Mistakes
Even with a clear understanding of double dispatch, several pitfalls catch developers when they implement visitor pattern in C# for the first time.
Calling Visit on the interface type instead of this: The most critical mistake is implementing Accept as visitor.Visit((IEmployee)this) instead of visitor.Visit(this). Casting to the interface defeats the entire purpose of double dispatch because the compiler resolves to the base type's overload, not the concrete type's. Always let the concrete class pass this directly.
Forgetting to update all visitors when adding a new element type: When you add a new concrete element, the IEmployeeVisitor interface needs a new Visit overload, and every existing visitor must implement it. This is a compile-time error, which is actually a feature -- the compiler forces you to handle the new type everywhere. If you want to avoid this, consider providing a default implementation in an abstract base visitor class.
Making the visitor interface too granular: If your element hierarchy has dozens of types, the visitor interface becomes unwieldy. In that case, consider grouping related types or using a hierarchical visitor approach where base visitors handle common cases and specialized visitors override specific types.
Storing mutable state without resetting: Stateful visitors like ReportGeneratorVisitor accumulate data across visits. If you reuse a visitor instance across multiple traversals without resetting its state, you get stale data mixed in. Either create a new visitor per traversal or provide an explicit Reset method.
Overusing the visitor pattern: Not every type hierarchy needs visitors. If you have a small hierarchy with infrequent new operations, simple polymorphic methods are clearer. The visitor pattern pays off when you have a stable set of types but frequently add new operations. The command pattern is often a better fit when operations are dynamic and don't need to dispatch on element types.
Frequently Asked Questions
What is double dispatch and why does the visitor pattern need it?
Double dispatch is a mechanism where the method that gets called depends on the runtime types of two objects, not just one. Standard virtual method calls in C# use single dispatch -- only the receiver's type matters. The visitor pattern needs double dispatch because it must select behavior based on both the element type (which Accept is called) and the visitor type (which Visit overload runs). The Accept/Visit two-step handshake achieves this without requiring runtime type checks.
Can I use the visitor pattern with sealed class hierarchies?
Yes, and sealed classes are actually the ideal fit. The visitor pattern works best with a stable, fixed set of element types. Sealing the classes signals that no new subtypes will be introduced, which aligns with the visitor pattern's trade-off: easy to add new visitors, hard to add new element types. If your hierarchy is frequently extended with new types, the visitor pattern becomes painful because every new type forces changes to every visitor.
How does the visitor pattern compare to C# pattern matching?
Both solve the problem of executing type-specific logic. Pattern matching with switch expressions is more concise and doesn't require the Accept/Visit infrastructure. However, pattern matching scatters the type dispatch across every location that needs it. The visitor pattern centralizes each operation in a single class, making it easier to maintain when you have many operations. If you have one or two operations, pattern matching wins. If you have five or more operations across a stable type hierarchy, the visitor pattern scales better.
Should I use a generic visitor interface or a void visitor interface?
Use the generic IVisitor<TResult> variant when each visit produces a value that the caller needs -- salary calculations, validation results, or transformed representations. Use the void variant when the visitor accumulates state internally, like building a report or collecting statistics. You can support both by having your elements implement Accept for each variant, as shown in this guide.
How do I add 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 all visitor implementations to handle it. To minimize disruption, create an abstract base visitor class that provides default implementations (for example, throwing NotSupportedException or doing nothing). Concrete visitors then override only the types they care about. This is the same principle behind providing default interface methods, which C# supports starting with version 8.0.
Can the visitor pattern work with composite structures?
Absolutely. The visitor pattern and the composite pattern are natural partners. In a composite tree, each leaf and composite node implements Accept. Leaf nodes call visitor.Visit(this) directly, while composite nodes iterate over their children, calling child.Accept(visitor) for each one before or after visiting themselves. This recursive accept/visit traversal lets a single visitor walk an entire tree structure without knowing anything about the tree's shape.
Is the visitor pattern compatible with dependency injection?
Yes. Visitors are plain classes that can accept constructor dependencies. You can register them in your DI container and resolve them when needed. For example, if a visitor needs a database connection or a logger, inject those through the constructor. The elements themselves remain dependency-free -- they just call Accept. This clean separation of concerns makes the visitor pattern a natural fit for applications that use dependency injection through IServiceCollection or similar containers.

