You've built a document model. It has TextElement, ImageElement, TableElement, and CodeBlockElement. Clean hierarchy. Each element renders itself.
Then someone needs HTML export. Then PDF export. Then word count. Then accessibility validation. Then SEO analysis.
Do you add 5 methods to every element class?
public class TextElement : DocumentElement{ public string Content { get; set; } public string ToHtml() => $"<p>{Content}</p>"; public byte[] ToPdf() => PdfRenderer.RenderText(Content); public int GetWordCount() => Content.Split(' ').Length; public List<string> ValidateAccessibility() => /* ... */; public SeoScore AnalyzeSeo() => /* ... */;}
Every new operation means modifying every element class. TextElement now knows about HTML, PDF, word counting, accessibility rules, and SEO. It should only know about being text.
And when you add a sixth operation? You touch all four element classes again.
The issue is that operations keep accumulating inside the element classes. These classes grow with every new cross-cutting feature. Testing an SEO analyzer requires loading the entire element class. And the dependencies for each operation (PDF library, accessibility rules, SEO configuration) leak into classes that shouldn't know about them.
The Visitor pattern separates algorithms from the objects they operate on. Each operation becomes its own class (a visitor). The object structure stays clean — elements just accept visitors and let them do their work.
Define the visitor interface and the accept method:
// Visitor interface - one method per element typepublic interface IDocumentVisitor{ void Visit(TextElement element); void Visit(ImageElement element); void Visit(TableElement element); void Visit(CodeBlockElement element);} // Elements accept visitorspublic abstract class DocumentElement{ public abstract void Accept(IDocumentVisitor visitor);} public class TextElement : DocumentElement{ public string Content { get; set; } = string.Empty; public string FontSize { get; set; } = "16px"; public override void Accept(IDocumentVisitor visitor) => visitor.Visit(this);} public class ImageElement : DocumentElement{ public string Src { get; set; } = string.Empty; public string AltText { get; set; } = string.Empty; public int Width { get; set; } public int Height { get; set; } public override void Accept(IDocumentVisitor visitor) => visitor.Visit(this);} public class TableElement : DocumentElement{ public List<string> Headers { get; set; } = new(); public List<List<string>> Rows { get; set; } = new(); public override void Accept(IDocumentVisitor visitor) => visitor.Visit(this);} public class CodeBlockElement : DocumentElement{ public string Code { get; set; } = string.Empty; public string Language { get; set; } = "csharp"; public override void Accept(IDocumentVisitor visitor) => visitor.Visit(this);}
Now add operations as visitors — no element classes change:
// HTML export visitorpublic class HtmlExportVisitor : IDocumentVisitor{ private readonly StringBuilder _html = new(); public string GetResult() => _html.ToString(); public void Visit(TextElement element) { _html.AppendLine($"<p style=\"font-size:{element.FontSize}\">{element.Content}</p>"); } public void Visit(ImageElement element) { _html.AppendLine($"<img src=\"{element.Src}\" alt=\"{element.AltText}\" " + $"width=\"{element.Width}\" height=\"{element.Height}\" />"); } public void Visit(TableElement element) { _html.AppendLine("<table>"); _html.AppendLine("<tr>" + string.Join("", element.Headers.Select(h => $"<th>{h}</th>")) + "</tr>"); foreach (var row in element.Rows) { _html.AppendLine("<tr>" + string.Join("", row.Select(c => $"<td>{c}</td>")) + "</tr>"); } _html.AppendLine("</table>"); } public void Visit(CodeBlockElement element) { _html.AppendLine($"<pre><code class=\"language-{element.Language}\">" + $"{element.Code}</code></pre>"); }} // Word count visitorpublic class WordCountVisitor : IDocumentVisitor{ public int TotalWords { get; private set; } public void Visit(TextElement element) { TotalWords += element.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; } public void Visit(ImageElement element) { TotalWords += element.AltText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; } public void Visit(TableElement element) { foreach (var row in element.Rows) foreach (var cell in row) TotalWords += cell.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; } public void Visit(CodeBlockElement element) { // Don't count code as words }} // Accessibility validation visitorpublic class AccessibilityVisitor : IDocumentVisitor{ public List<string> Violations { get; } = new(); public void Visit(TextElement element) { // Check font size accessibility if (element.FontSize == "10px" || element.FontSize == "8px") Violations.Add($"Text font size {element.FontSize} may be too small"); } public void Visit(ImageElement element) { if (string.IsNullOrWhiteSpace(element.AltText)) Violations.Add($"Image '{element.Src}' is missing alt text"); } public void Visit(TableElement element) { if (element.Headers.Count == 0) Violations.Add("Table is missing header row"); } public void Visit(CodeBlockElement element) { if (string.IsNullOrWhiteSpace(element.Language)) Violations.Add("Code block missing language for screen readers"); }}
Usage:
var document = new List<DocumentElement>{ new TextElement { Content = "Welcome to the guide" }, new ImageElement { Src = "diagram.png", AltText = "" }, new TableElement { Headers = new() { "Name", "Score" }, Rows = new() }, new CodeBlockElement { Code = "var x = 42;", Language = "csharp" }}; // Run HTML exportvar htmlVisitor = new HtmlExportVisitor();foreach (var element in document) element.Accept(htmlVisitor);var html = htmlVisitor.GetResult(); // Run accessibility checkvar a11yVisitor = new AccessibilityVisitor();foreach (var element in document) element.Accept(a11yVisitor);// Violations: ["Image 'diagram.png' is missing alt text"] // Run word countvar wordCounter = new WordCountVisitor();foreach (var element in document) element.Accept(wordCounter);// TotalWords: 5
Three different operations. Zero element classes modified.
New operations don't touch element classes. Adding PDF export is creating PdfExportVisitor. No existing code changes.
Each operation is self-contained. The HTML visitor only contains HTML logic. The accessibility visitor only contains accessibility rules. Clean separation.
Gather results across the structure. Visitors accumulate state as they traverse. Word counts, violations, and output all build up naturally.
public interface ICartItemVisitor{ void Visit(PhysicalProduct item); void Visit(DigitalProduct item); void Visit(SubscriptionProduct item);} public class TaxCalculator : ICartItemVisitor{ public decimal TotalTax { get; private set; } public void Visit(PhysicalProduct item) { // Physical goods: 20% VAT TotalTax += item.Price * 0.20m; } public void Visit(DigitalProduct item) { // Digital goods: varies by region TotalTax += item.Price * item.RegionalTaxRate; } public void Visit(SubscriptionProduct item) { // Subscriptions: reduced rate TotalTax += item.MonthlyPrice * 0.10m; }} public class ShippingCalculator : ICartItemVisitor{ public decimal TotalShipping { get; private set; } public void Visit(PhysicalProduct item) { TotalShipping += item.Weight * 0.50m; // weight-based } public void Visit(DigitalProduct item) { // No shipping for digital products } public void Visit(SubscriptionProduct item) { // No shipping for subscriptions }}
Tax, shipping, and any future calculation (loyalty points, carbon footprint) are separate visitors. Product classes stay clean.
When the element hierarchy changes frequently. Adding a new element type (e.g., VideoElement) requires adding a Visit(VideoElement) method to every existing visitor. If you add elements often, this becomes a burden.
When there are few operations. If you only need HTML export and nothing else, putting ToHtml() directly in each element is simpler.
When double dispatch feels confusing. The Accept(visitor) → visitor.Visit(this) indirection confuses developers who aren't familiar with the pattern. On small teams, this overhead in understanding may not be worth it.
visitor.Visit(this) to route to the correct overloadThe Visitor pattern lets you add new operations to a class hierarchy without modifying the classes. You create a "visitor" class for each operation. Each class in the hierarchy accepts the visitor and lets it perform the operation.
When you have a stable set of classes (elements) but frequently need new operations on them. Document processing, AST traversal, and tax/pricing calculations are typical use cases.
For one or two operations on a small hierarchy, yes. The double dispatch mechanism adds complexity. It pays off when you have 4+ operations and the element hierarchy rarely changes.
Pattern matching with switch expressions in C# can handle type-based dispatch without the Visitor infrastructure. Extension methods can add behavior without modifying classes. For simple cases, virtual methods on the base class work fine.
The Visitor pattern trades one kind of change for another. Adding operations is easy. Adding element types is hard. That's the tradeoff.
If your class hierarchy is stable and operations keep growing, Visitor keeps your element classes clean and your operations organized. If elements change often, use pattern matching or virtual methods instead.
That's all from me today.
P.S. Follow me on YouTube.
If you made it this far, you're clearly serious about writing better .NET code. Here's a 20% discount code: DEEP20 for Design Patterns that Deliver. Consider it a thank-you for actually reading the whole thing.
Here are 2 ebooks I have about design patterns:
Design Patterns that Deliver — 5 essential patterns (Builder, Decorator, Strategy, Adapter, Mediator) with production-ready C# code and real-world examples. Or try a free chapter on the Builder Pattern first.
Design Patterns Simplified — A beginner-friendly guide to understanding design patterns without the academic fluff.
Stop arguing about code style. In this course you get a production-proven setup with analyzers, CI quality gates, and architecture tests — the exact system I use in real projects. Join here.
Not sure yet? Grab the free Starter Kit — a drop-in setup with the essentials from Module 01.
Design Patterns that Deliver — Solve real problems with 5 battle-tested patterns (Builder, Decorator, Strategy, Adapter, Mediator) using practical, real-world examples. Trusted by 650+ developers.
Just getting started? Design Patterns Simplified covers 10 essential patterns in a beginner-friendly, 30-page guide for just $9.95.
Every Monday morning, I share 1 actionable tip on C#, .NET & Architecture that you can use right away. Join here.
Join 20,000+ subscribers who mass-improve their .NET skills with actionable tips on C#, Software Architecture & Best Practices.
Subscribe to the TheCodeMan.net and be among the 20,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.