Visitor Pattern in C#: Add Operations to Objects

The Visitor pattern is a behavioral design pattern that allows developers to add new operations to an existing object structure without altering the underlying classes. In C#, this pattern enables the separation of algorithms from the objects on which they operate by using a technique called double dispatch. This article explains the fundamentals of the Visitor pattern, the core problems it solves, and how to implement it cleanly in C# to adhere to the Open/Closed Principle.

What is the Visitor Pattern?

The Visitor pattern decouples an algorithm from the object structure it acts upon. Instead of adding new methods to multiple domain classes whenever business requirements change, you encapsulate the new behavior inside a separate "visitor" class.

This approach enforces the Open/Closed Principle: classes remain open for extension (by accepting new visitors) but closed for modification (their internal structure does not need to change when a new operation is introduced).

How the Visitor Pattern Works: Double Dispatch

In standard single-dispatch languages like C#, the method invoked depends solely on the runtime type of the object receiving the call. The Visitor pattern uses double dispatch to select the correct execution logic based on both:

  1. The type of the operation (the visitor).
  2. The type of the receiver (the element).

The workflow involves two distinct calls:

  1. The client calls element.Accept(visitor).
  2. The concrete element calls visitor.Visit(this), passing its own concrete type into the visitor.

Implementing the Visitor Pattern in C

Consider an e-commerce application with different types of cart items (Book and Electronics). If you need to calculate shipping costs or export items to JSON without modifying the product classes, you can apply the Visitor pattern.

1. Define the Visitor and Element Interfaces

public interface IVisitor
{
    void Visit(Book book);
    void Visit(Electronics electronics);
}

public interface ICartItem
{
    void Accept(IVisitor visitor);
}

2. Implement the Concrete Elements

Each concrete class implements the Accept method, routing the call back to the visitor.

public class Book : ICartItem
{
    public string Title { get; }
    public decimal Price { get; }
    public double WeightInKg { get; }

    public Book(string title, decimal price, double weightInKg)
    {
        Title = title;
        Price = price;
        WeightInKg = weightInKg;
    }

    public void Accept(IVisitor visitor)
    {
        visitor.Visit(this);
    }
}

public class Electronics : ICartItem
{
    public string Name { get; }
    public decimal Price { get; }
    public bool RequiresSpecialHandling { get; }

    public Electronics(string name, decimal price, bool requiresSpecialHandling)
    {
        Name = name;
        Price = price;
        RequiresSpecialHandling = requiresSpecialHandling;
    }

    public void Accept(IVisitor visitor)
    {
        visitor.Visit(this);
    }
}

3. Implement Concrete Visitors

Now, create operations independently without modifying Book or Electronics.

Operation 1: Shipping Cost Calculator

public class ShippingCalculatorVisitor : IVisitor
{
    public decimal TotalShippingCost { get; private set; }

    public void Visit(Book book)
    {
        // Standard rate based on weight
        TotalShippingCost += (decimal)book.WeightInKg * 2.5m;
    }

    public void Visit(Electronics electronics)
    {
        // Electronics have a flat fee, plus a surcharge for fragile handling
        decimal fee = 10.0m;
        if (electronics.RequiresSpecialHandling)
        {
            fee += 15.0m;
        }
        TotalShippingCost += fee;
    }
}

Operation 2: JSON Export

public class JsonExporterVisitor : IVisitor
{
    public void Visit(Book book)
    {
        Console.WriteLine($"{{\"type\": \"book\", \"title\": \"{book.Title}\", \"price\": {book.Price}}}");
    }

    public void Visit(Electronics electronics)
    {
        Console.WriteLine($"{{\"type\": \"electronics\", \"name\": \"{electronics.Name}\", \"price\": {electronics.Price}}}");
    }
}

4. Executing Operations on the Object Structure

var cart = new List<ICartItem>
{
    new Book("Design Patterns", 45.00m, 1.2),
    new Electronics("Smartphone", 799.99m, true),
    new Book("C# in Depth", 50.00m, 0.9)
};

// Execute Shipping Calculation
var shippingCalculator = new ShippingCalculatorVisitor();
foreach (var item in cart)
{
    item.Accept(shippingCalculator);
}
Console.WriteLine($"Total Shipping: ${shippingCalculator.TotalShippingCost}");

// Execute JSON Export
var jsonExporter = new JsonExporterVisitor();
foreach (var item in cart)
{
    item.Accept(jsonExporter);
}

Advantages and Disadvantages

Advantages

  • Clean Code Architecture: Group related behaviors into a single visitor class rather than scattering them across many element classes.
  • Open/Closed Principle: Add new operations (such as tax calculations, discounts, or serialization) simply by adding a new IVisitor implementation.
  • Accumulating State: Visitors can maintain internal state as they traverse diverse collections of objects (e.g., tallying costs or building reports).

Disadvantages

  • Fragile to Schema Changes: Adding a new element type (e.g., Clothing) requires updating the IVisitor interface and every existing concrete visitor.
  • Encapsulation Risks: Visitors often require access to the internal data of the elements, potentially leading to public exposure of properties that would otherwise remain private or internal.