Architecture & Patterns110 min total · 26 parts
ACID, SOLID, and Design Patterns: A Complete Software Design Reference
Part 23 of 26 · ~2 min
Behavioral Patterns: Visitor
Visitor is how you keep adding new operations to a fixed family of classes without ever cracking those classes back open to do it. It earns its keep when the object structure — here, the different kinds of shift records ShiftDesk keeps — is the stable side of the equation, while the operations run against it keep multiplying: payroll-equivalent hours for a grant report this month, a compliance sweep next month, a CSV export after that.
public interface IShiftRecordVisitor
{
void Visit(RegularShiftRecord record);
void Visit(EmergencyCalloutRecord record);
}
public interface IShiftRecord
{
void Accept(IShiftRecordVisitor visitor);
}
public class RegularShiftRecord : IShiftRecord
{
public double Hours { get; set; }
public bool BackgroundCheckOnFile { get; set; }
public void Accept(IShiftRecordVisitor visitor) => visitor.Visit(this); // double-dispatch
}
public class EmergencyCalloutRecord : IShiftRecord
{
public double Hours { get; set; }
public bool BackgroundCheckOnFile { get; set; }
public void Accept(IShiftRecordVisitor visitor) => visitor.Visit(this);
}
// A new operation — added without touching RegularShiftRecord or EmergencyCalloutRecord at all.
public class ComplianceVisitor : IShiftRecordVisitor
{
public List<string> Flags { get; } = new();
public void Visit(RegularShiftRecord record) { if (!record.BackgroundCheckOnFile) Flags.Add("missing check"); }
public void Visit(EmergencyCalloutRecord record) { if (!record.BackgroundCheckOnFile) Flags.Add("missing check"); }
}
Give that one line — visitor.Visit(this) — its proper name: double dispatch. A single call site somehow lands on the correct overload for two different types at once, the concrete record and the concrete visitor, and neither one was known until the program was actually running. Ordinary C# overload resolution can't do that by itself; it locks in an overload once, at compile time, using whatever the variable's declared type happens to be — which is exactly why looping over a plain List<IShiftRecord> and calling an overloaded method directly would only ever hit the IShiftRecord-shaped overload, never the specific one each concrete record actually needs. Routing the call through Accept first is what forces that second, later dispatch on the visitor's side.
Worth being honest about the trade Visitor makes, because it's easy to present this pattern as a free win and it isn't one: it makes adding a new operation cheap, at the direct cost of making adding a new record type expensive. Add a TrainingShiftRecord next quarter, and every existing visitor — ComplianceVisitor, a payroll visitor, anything else implementing IShiftRecordVisitor — needs a new Visit(TrainingShiftRecord) overload, or the interface won't compile. Visitor is the right tool specifically when you're confident the type list is the stable side and the operations are the side that keeps growing. Bet on it the other way around and every new type becomes a scavenger hunt through every visitor in the codebase.