Back to Blog
design-patternscsharptypescriptbehavioral-patterns

Visitor Pattern — FixIt Pro Series #22

Audit, PDF export, cost rollup, compliance check, running new operations across a job card structure without modifying a single job card class. The final episode of the FixIt Pro design patterns series.

Series: Design Patterns with FixIt Pro  ·  Episode 22 / 22  ·  Behavioral Pattern
Previous: #21 — Template Method Pattern


The Final Episode

This is the last of 22 episodes. We've built FixIt Pro's job card platform from the ground up using every pattern in the Gang of Four catalogue, five creational, seven structural, and ten behavioral.

We finish with the Visitor: the pattern that lets you add entirely new operations to an existing object structure without touching a single one of those classes.


The Scenario

FixIt Pro has three job card types, each with different data:

  • PlumbingJob — has water pressure readings and pipe metrics
  • ElectricalJob — has circuit load and certification requirements
  • CarpentryJob — has material volumes and finish specifications

Over time, the business asks for new operations across all of them:

  1. Cost rollup — total the cost of every job in a project
  2. PDF export — render each job into a printable report line
  3. Compliance audit — check each job against its regulatory requirements
  4. Materials forecast — aggregate what needs ordering next month

The obvious approach is adding a method to each job class for each operation. Four operations × three job types = twelve methods spread across three classes. Every new operation means editing all three classes, and the job classes end up knowing about PDF rendering, tax rules, and procurement logic that has nothing to do with being a job card.

The Visitor pattern inverts this: the operation lives in its own class, and the job cards simply accept it.


What Is the Visitor Pattern?

Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates.

Each element exposes a single Accept(visitor) method. The visitor has one method per element type. When you call Accept, the element calls back into the visitor's method for its own type. That callback is what makes the whole thing work, it's called double dispatch.

The four participants

Role FixIt Pro equivalent
Visitor interface IJobVisitor — one Visit method per job type
Concrete Visitors CostRollupVisitor, PdfExportVisitor, ComplianceAuditVisitor
Element interface IJobElement — declares Accept(IJobVisitor)
Concrete Elements PlumbingJob, ElectricalJob, CarpentryJob

Understanding Double Dispatch

This is the part that trips people up, so it's worth being explicit.

In a normal method call, the method that runs is chosen by one thing: the runtime type of the object you called it on. That's single dispatch.

The Visitor needs two things to decide what runs: the type of the element and the type of the visitor. Neither language supports that directly, so the pattern fakes it in two steps:

1. Client calls  job.Accept(visitor)
   → resolved by the runtime type of 'job'      (dispatch #1)

2. Inside PlumbingJob.Accept, it calls visitor.Visit(this)
   → 'this' is statically known to be PlumbingJob
   → resolved by the runtime type of 'visitor'  (dispatch #2)

Two virtual calls chained together give you dispatch on both types. That's the entire trick.


C# Implementation

// ── Element Interface ──────────────────────────────────────

public interface IJobElement
{
    string  JobId { get; }
    string  Title { get; }
    decimal Cost  { get; }

    void Accept(IJobVisitor visitor);
}

// ── Concrete Elements ──────────────────────────────────────

public class PlumbingJob : IJobElement
{
    public string  JobId          { get; } = Guid.NewGuid().ToString()[..8];
    public string  Title          { get; }
    public decimal Cost           { get; }
    public decimal PressureBar    { get; }
    public decimal PipeMetres     { get; }

    public PlumbingJob(string title, decimal cost, decimal pressureBar, decimal pipeMetres)
    {
        Title       = title;
        Cost        = cost;
        PressureBar = pressureBar;
        PipeMetres  = pipeMetres;
    }

    // Double dispatch — calls back into the visitor's PlumbingJob overload
    public void Accept(IJobVisitor visitor) => visitor.Visit(this);
}

public class ElectricalJob : IJobElement
{
    public string  JobId              { get; } = Guid.NewGuid().ToString()[..8];
    public string  Title              { get; }
    public decimal Cost               { get; }
    public decimal CircuitLoadAmps    { get; }
    public bool    RequiresCertificate { get; }

    public ElectricalJob(
        string title, decimal cost,
        decimal circuitLoadAmps, bool requiresCertificate)
    {
        Title               = title;
        Cost                = cost;
        CircuitLoadAmps     = circuitLoadAmps;
        RequiresCertificate = requiresCertificate;
    }

    public void Accept(IJobVisitor visitor) => visitor.Visit(this);
}

public class CarpentryJob : IJobElement
{
    public string  JobId          { get; } = Guid.NewGuid().ToString()[..8];
    public string  Title          { get; }
    public decimal Cost           { get; }
    public decimal TimberMetres   { get; }
    public string  FinishType     { get; }

    public CarpentryJob(string title, decimal cost, decimal timberMetres, string finishType)
    {
        Title        = title;
        Cost         = cost;
        TimberMetres = timberMetres;
        FinishType   = finishType;
    }

    public void Accept(IJobVisitor visitor) => visitor.Visit(this);
}

// ── Visitor Interface ──────────────────────────────────────

public interface IJobVisitor
{
    void Visit(PlumbingJob   job);
    void Visit(ElectricalJob job);
    void Visit(CarpentryJob  job);
}

// ── Concrete Visitor 1 — Cost Rollup ──────────────────────

public class CostRollupVisitor : IJobVisitor
{
    public decimal PlumbingTotal   { get; private set; }
    public decimal ElectricalTotal { get; private set; }
    public decimal CarpentryTotal  { get; private set; }

    public decimal GrandTotal =>
        PlumbingTotal + ElectricalTotal + CarpentryTotal;

    public void Visit(PlumbingJob   job) => PlumbingTotal   += job.Cost;
    public void Visit(ElectricalJob job) => ElectricalTotal += job.Cost;
    public void Visit(CarpentryJob  job) => CarpentryTotal  += job.Cost;

    public void PrintSummary()
    {
        Console.WriteLine("\n=== Cost Rollup ===");
        Console.WriteLine($"  Plumbing   : N${PlumbingTotal:N2}");
        Console.WriteLine($"  Electrical : N${ElectricalTotal:N2}");
        Console.WriteLine($"  Carpentry  : N${CarpentryTotal:N2}");
        Console.WriteLine($"  ─────────────────────────");
        Console.WriteLine($"  TOTAL      : N${GrandTotal:N2}");
    }
}

// ── Concrete Visitor 2 — PDF Export ───────────────────────

public class PdfExportVisitor : IJobVisitor
{
    private readonly List<string> _lines = new();

    public void Visit(PlumbingJob job) =>
        _lines.Add($"  [PLUMBING]   #{job.JobId} | {job.Title,-32} | " +
                   $"{job.PipeMetres}m pipe @ {job.PressureBar} bar | N${job.Cost:N2}");

    public void Visit(ElectricalJob job) =>
        _lines.Add($"  [ELECTRICAL] #{job.JobId} | {job.Title,-32} | " +
                   $"{job.CircuitLoadAmps}A load" +
                   $"{(job.RequiresCertificate ? " | CoC required" : "")} | N${job.Cost:N2}");

    public void Visit(CarpentryJob job) =>
        _lines.Add($"  [CARPENTRY]  #{job.JobId} | {job.Title,-32} | " +
                   $"{job.TimberMetres}m timber, {job.FinishType} | N${job.Cost:N2}");

    public void Render()
    {
        Console.WriteLine("\n=== PDF Export Preview ===");
        foreach (var line in _lines)
            Console.WriteLine(line);
    }
}

// ── Concrete Visitor 3 — Compliance Audit ─────────────────

public class ComplianceAuditVisitor : IJobVisitor
{
    private readonly List<string> _issues  = new();
    private int                   _checked = 0;

    public void Visit(PlumbingJob job)
    {
        _checked++;
        if (job.PressureBar > 4m)
            _issues.Add($"#{job.JobId} — pressure {job.PressureBar} bar exceeds " +
                        $"SANS 10400-W maximum of 4 bar");
    }

    public void Visit(ElectricalJob job)
    {
        _checked++;
        if (job.CircuitLoadAmps > 60m && !job.RequiresCertificate)
            _issues.Add($"#{job.JobId} — {job.CircuitLoadAmps}A load requires a " +
                        $"Certificate of Compliance, none flagged");
    }

    public void Visit(CarpentryJob job)
    {
        _checked++;
        if (job.FinishType == "untreated")
            _issues.Add($"#{job.JobId} — untreated timber not permitted in wet areas");
    }

    public void PrintReport()
    {
        Console.WriteLine("\n=== Compliance Audit ===");
        Console.WriteLine($"  Jobs checked : {_checked}");
        Console.WriteLine($"  Issues found : {_issues.Count}");

        foreach (var issue in _issues)
            Console.WriteLine($"  ⚠️  {issue}");

        if (_issues.Count == 0)
            Console.WriteLine("  ✅ All jobs compliant.");
    }
}

// ── Object Structure ───────────────────────────────────────

public class ProjectJobList
{
    private readonly List<IJobElement> _jobs = new();

    public void Add(IJobElement job) => _jobs.Add(job);

    // One traversal method serves every visitor
    public void Accept(IJobVisitor visitor)
    {
        foreach (var job in _jobs)
            job.Accept(visitor);
    }
}

// ── Client Code ────────────────────────────────────────────

class Program
{
    static void Main()
    {
        var project = new ProjectJobList();

        project.Add(new PlumbingJob("Replace burst pipe under sink",  850m,  3.0m,  4.5m));
        project.Add(new PlumbingJob("Install outdoor tap line",       1_200m, 4.5m, 12.0m));
        project.Add(new ElectricalJob("Rewire kitchen sockets",       3_200m, 32m,  true));
        project.Add(new ElectricalJob("Upgrade main DB board",        8_500m, 80m,  false));
        project.Add(new CarpentryJob("Fit new cabinet frames",        2_400m, 18.0m, "sealed"));
        project.Add(new CarpentryJob("Build bathroom shelving",        900m,  6.0m,  "untreated"));

        // Visitor 1 — cost rollup
        var costs = new CostRollupVisitor();
        project.Accept(costs);
        costs.PrintSummary();

        // Visitor 2 — PDF export
        var pdf = new PdfExportVisitor();
        project.Accept(pdf);
        pdf.Render();

        // Visitor 3 — compliance audit
        var audit = new ComplianceAuditVisitor();
        project.Accept(audit);
        audit.PrintReport();

        // Note: adding a fourth operation means writing one new visitor class.
        // PlumbingJob, ElectricalJob, and CarpentryJob are never touched.
    }
}

Output:

=== Cost Rollup ===
  Plumbing   : N$2,050.00
  Electrical : N$11,700.00
  Carpentry  : N$3,300.00
  ─────────────────────────
  TOTAL      : N$17,050.00

=== PDF Export Preview ===
  [PLUMBING]   #f3a12b | Replace burst pipe under sink    | 4.5m pipe @ 3.0 bar | N$850.00
  [PLUMBING]   #a7d45e | Install outdoor tap line         | 12.0m pipe @ 4.5 bar | N$1,200.00
  [ELECTRICAL] #c2f89d | Rewire kitchen sockets           | 32A load | CoC required | N$3,200.00
  [ELECTRICAL] #e9b14c | Upgrade main DB board            | 80A load | N$8,500.00
  [CARPENTRY]  #d7e02b | Fit new cabinet frames           | 18.0m timber, sealed | N$2,400.00
  [CARPENTRY]  #b4c81a | Build bathroom shelving          | 6.0m timber, untreated | N$900.00

=== Compliance Audit ===
  Jobs checked : 6
  Issues found : 3
  ⚠️  #a7d45e — pressure 4.5 bar exceeds SANS 10400-W maximum of 4 bar
  ⚠️  #e9b14c — 80A load requires a Certificate of Compliance, none flagged
  ⚠️  #b4c81a — untreated timber not permitted in wet areas

Three completely different operations. Zero changes to the job classes.


TypeScript Implementation

TypeScript has no method overloading on parameter type at runtime, so the visitor methods get distinct names — visitPlumbing, visitElectrical, visitCarpentry. The double dispatch mechanism is otherwise identical.

// ── Visitor/IJobVisitor.ts ─────────────────────────────────

import type { PlumbingJob   } from "../Element/PlumbingJob";
import type { ElectricalJob } from "../Element/ElectricalJob";
import type { CarpentryJob  } from "../Element/CarpentryJob";

export interface IJobVisitor {
  visitPlumbing(job:   PlumbingJob):   void;
  visitElectrical(job: ElectricalJob): void;
  visitCarpentry(job:  CarpentryJob):  void;
}

// ── Element/IJobElement.ts ─────────────────────────────────

import type { IJobVisitor } from "../Visitor/IJobVisitor";

export interface IJobElement {
  readonly jobId: string;
  readonly title: string;
  readonly cost:  number;

  accept(visitor: IJobVisitor): void;
}

// ── Element/PlumbingJob.ts ─────────────────────────────────

import type { IJobElement } from "./IJobElement";
import type { IJobVisitor } from "../Visitor/IJobVisitor";

const shortId = () => Math.random().toString(36).slice(2, 10);

export class PlumbingJob implements IJobElement {
  readonly jobId: string = shortId();

  constructor(
    readonly title:       string,
    readonly cost:        number,
    readonly pressureBar: number,
    readonly pipeMetres:  number
  ) {}

  // Double dispatch
  accept(visitor: IJobVisitor): void { visitor.visitPlumbing(this); }
}

// ── Element/ElectricalJob.ts ───────────────────────────────

import type { IJobElement } from "./IJobElement";
import type { IJobVisitor } from "../Visitor/IJobVisitor";

const shortId = () => Math.random().toString(36).slice(2, 10);

export class ElectricalJob implements IJobElement {
  readonly jobId: string = shortId();

  constructor(
    readonly title:               string,
    readonly cost:                number,
    readonly circuitLoadAmps:     number,
    readonly requiresCertificate: boolean
  ) {}

  accept(visitor: IJobVisitor): void { visitor.visitElectrical(this); }
}

// ── Element/CarpentryJob.ts ────────────────────────────────

import type { IJobElement } from "./IJobElement";
import type { IJobVisitor } from "../Visitor/IJobVisitor";

const shortId = () => Math.random().toString(36).slice(2, 10);

export class CarpentryJob implements IJobElement {
  readonly jobId: string = shortId();

  constructor(
    readonly title:        string,
    readonly cost:         number,
    readonly timberMetres: number,
    readonly finishType:   string
  ) {}

  accept(visitor: IJobVisitor): void { visitor.visitCarpentry(this); }
}

// ── Visitor/CostRollupVisitor.ts ───────────────────────────

import type { IJobVisitor   } from "./IJobVisitor";
import type { PlumbingJob   } from "../Element/PlumbingJob";
import type { ElectricalJob } from "../Element/ElectricalJob";
import type { CarpentryJob  } from "../Element/CarpentryJob";

export class CostRollupVisitor implements IJobVisitor {
  private plumbingTotal   = 0;
  private electricalTotal = 0;
  private carpentryTotal  = 0;

  get grandTotal(): number {
    return this.plumbingTotal + this.electricalTotal + this.carpentryTotal;
  }

  visitPlumbing(job:   PlumbingJob):   void { this.plumbingTotal   += job.cost; }
  visitElectrical(job: ElectricalJob): void { this.electricalTotal += job.cost; }
  visitCarpentry(job:  CarpentryJob):  void { this.carpentryTotal  += job.cost; }

  printSummary(): void {
    console.log("\n=== Cost Rollup ===");
    console.log(`  Plumbing   : N$${this.plumbingTotal.toFixed(2)}`);
    console.log(`  Electrical : N$${this.electricalTotal.toFixed(2)}`);
    console.log(`  Carpentry  : N$${this.carpentryTotal.toFixed(2)}`);
    console.log(`  ─────────────────────────`);
    console.log(`  TOTAL      : N$${this.grandTotal.toFixed(2)}`);
  }
}

// ── Visitor/PdfExportVisitor.ts ────────────────────────────

import type { IJobVisitor   } from "./IJobVisitor";
import type { PlumbingJob   } from "../Element/PlumbingJob";
import type { ElectricalJob } from "../Element/ElectricalJob";
import type { CarpentryJob  } from "../Element/CarpentryJob";

export class PdfExportVisitor implements IJobVisitor {
  private lines: string[] = [];

  visitPlumbing(job: PlumbingJob): void {
    this.lines.push(
      `  [PLUMBING]   #${job.jobId} | ${job.title.padEnd(32)} | ` +
      `${job.pipeMetres}m pipe @ ${job.pressureBar} bar | N$${job.cost.toFixed(2)}`
    );
  }

  visitElectrical(job: ElectricalJob): void {
    const coc = job.requiresCertificate ? " | CoC required" : "";
    this.lines.push(
      `  [ELECTRICAL] #${job.jobId} | ${job.title.padEnd(32)} | ` +
      `${job.circuitLoadAmps}A load${coc} | N$${job.cost.toFixed(2)}`
    );
  }

  visitCarpentry(job: CarpentryJob): void {
    this.lines.push(
      `  [CARPENTRY]  #${job.jobId} | ${job.title.padEnd(32)} | ` +
      `${job.timberMetres}m timber, ${job.finishType} | N$${job.cost.toFixed(2)}`
    );
  }

  render(): void {
    console.log("\n=== PDF Export Preview ===");
    for (const line of this.lines) console.log(line);
  }
}

// ── Visitor/ComplianceAuditVisitor.ts ──────────────────────

import type { IJobVisitor   } from "./IJobVisitor";
import type { PlumbingJob   } from "../Element/PlumbingJob";
import type { ElectricalJob } from "../Element/ElectricalJob";
import type { CarpentryJob  } from "../Element/CarpentryJob";

export class ComplianceAuditVisitor implements IJobVisitor {
  private issues:  string[] = [];
  private checked: number   = 0;

  visitPlumbing(job: PlumbingJob): void {
    this.checked++;
    if (job.pressureBar > 4)
      this.issues.push(
        `#${job.jobId} — pressure ${job.pressureBar} bar exceeds ` +
        `SANS 10400-W maximum of 4 bar`
      );
  }

  visitElectrical(job: ElectricalJob): void {
    this.checked++;
    if (job.circuitLoadAmps > 60 && !job.requiresCertificate)
      this.issues.push(
        `#${job.jobId} — ${job.circuitLoadAmps}A load requires a ` +
        `Certificate of Compliance, none flagged`
      );
  }

  visitCarpentry(job: CarpentryJob): void {
    this.checked++;
    if (job.finishType === "untreated")
      this.issues.push(
        `#${job.jobId} — untreated timber not permitted in wet areas`
      );
  }

  printReport(): void {
    console.log("\n=== Compliance Audit ===");
    console.log(`  Jobs checked : ${this.checked}`);
    console.log(`  Issues found : ${this.issues.length}`);

    for (const issue of this.issues) console.log(`  ⚠️  ${issue}`);

    if (this.issues.length === 0) console.log("  ✅ All jobs compliant.");
  }
}

// ── Structure/ProjectJobList.ts ────────────────────────────

import type { IJobElement } from "../Element/IJobElement";
import type { IJobVisitor } from "../Visitor/IJobVisitor";

export class ProjectJobList {
  private jobs: IJobElement[] = [];

  add(job: IJobElement): void { this.jobs.push(job); }

  accept(visitor: IJobVisitor): void {
    for (const job of this.jobs) job.accept(visitor);
  }
}

// ── App.ts ─────────────────────────────────────────────────

import { PlumbingJob            } from "./Element/PlumbingJob";
import { ElectricalJob          } from "./Element/ElectricalJob";
import { CarpentryJob           } from "./Element/CarpentryJob";
import { ProjectJobList         } from "./Structure/ProjectJobList";
import { CostRollupVisitor      } from "./Visitor/CostRollupVisitor";
import { PdfExportVisitor       } from "./Visitor/PdfExportVisitor";
import { ComplianceAuditVisitor } from "./Visitor/ComplianceAuditVisitor";

const project = new ProjectJobList();

project.add(new PlumbingJob("Replace burst pipe under sink",  850,  3.0,  4.5));
project.add(new PlumbingJob("Install outdoor tap line",       1_200, 4.5, 12.0));
project.add(new ElectricalJob("Rewire kitchen sockets",       3_200, 32,  true));
project.add(new ElectricalJob("Upgrade main DB board",        8_500, 80,  false));
project.add(new CarpentryJob("Fit new cabinet frames",        2_400, 18.0, "sealed"));
project.add(new CarpentryJob("Build bathroom shelving",        900,  6.0,  "untreated"));

const costs = new CostRollupVisitor();
project.accept(costs);
costs.printSummary();

const pdf = new PdfExportVisitor();
project.accept(pdf);
pdf.render();

const audit = new ComplianceAuditVisitor();
project.accept(audit);
audit.printReport();

C# vs TypeScript — Key Differences

Aspect C# TypeScript
Visitor methods Overloaded Visit(PlumbingJob), Visit(ElectricalJob) — resolved by parameter type Distinct names: visitPlumbing, visitElectrical — no runtime overloading
String padding {job.Title,-32} alignment specifier in interpolation job.title.padEnd(32)
Circular imports Not an issue Use import type — elements and visitors reference each other
Discriminated unions Not idiomatic A realistic alternative to Visitor entirely (see below)

C#'s method overloading makes the Visitor read more naturally where every method is called Visit, and the compiler picks the right one from the argument type. TypeScript requires distinct names, which is more verbose but arguably clearer about what each method handles.


The Honest Trade-off

The Visitor is the most controversial pattern in the catalogue, and it's worth being direct about why.

What it gives you: adding a new operation is trivial. Write one visitor class. Zero changes to the element classes.

What it costs you: adding a new element type is painful. Add HvacJob and you must update IJobVisitor and every single visitor that implements it. The compiler will find them all, but that's still N files changed.

This is the expression problem, and every language forces a trade-off on it:

Approach Easy to add operations Easy to add types
Methods on classes ❌ Edit every class ✅ New class, done
Visitor pattern ✅ New visitor, done ❌ Edit every visitor

Use the Visitor when your element hierarchy is stable and you expect many operations over it. That's exactly the situation for compilers walking an AST, document formats, and, as here, a fixed set of job types with an ever-growing list of reports and audits.

If your element types change often, don't use the Visitor. You'll fight it constantly.

The TypeScript alternative worth knowing

In TypeScript, a discriminated union with an exhaustive switch gets you most of the Visitor's benefit with far less ceremony:

type Job =
  | { kind: "plumbing";   cost: number; pressureBar: number }
  | { kind: "electrical"; cost: number; circuitLoadAmps: number }
  | { kind: "carpentry";  cost: number; timberMetres: number };

function auditJob(job: Job): string | null {
  switch (job.kind) {
    case "plumbing":   return job.pressureBar > 4 ? "Pressure too high" : null;
    case "electrical": return job.circuitLoadAmps > 60 ? "CoC required" : null;
    case "carpentry":  return null;
  }
}

If you add a fourth variant to Job, TypeScript flags every switch that no longer handles all cases, the same compile-time safety the Visitor gives you, without the double dispatch machinery. For most TypeScript codebases, this is the better choice.


When to Use the Visitor

Use it when:

  • An object structure contains many classes with differing interfaces, and you need operations that depend on their concrete types
  • Many distinct, unrelated operations need to run over a structure, and you don't want to pollute the element classes
  • The element hierarchy rarely changes but operations are added frequently

Avoid it when:

  • Element types change often and every change ripples through every visitor
  • The structure is simple and a discriminated union or plain polymorphism is clearer
  • The visitor needs access to private element state where the Visitor forces you to widen the element's public surface

Real-World Takeaway

The Visitor is the pattern behind compilers and static analysis tools. Roslyn, the C# compiler platform, uses CSharpSyntaxWalker and CSharpSyntaxVisitor to walk syntax trees; every analyser and code fix you've used in Visual Studio is a Visitor over the AST. ESLint rules are visitors over the JavaScript AST. Babel plugins are visitors. IVisitor implementations appear throughout document object models and serialisation libraries.

In FixIt Pro, the operations team can ask for a materials forecast next month, a tax breakdown the month after, and an insurance risk score after that. Each one is a single new visitor class. PlumbingJob, ElectricalJob, and CarpentryJob, the classes that model the actual business domain, are never touched again.


Repo Structure for This Episode

github.com/antonlungameni/fixit-pro-design-patterns

fixit-pro-design-patterns/
├── csharp/Behavioral/22-Visitor/
│   ├── Element/IJobElement.cs
│   ├── Element/PlumbingJob.cs
│   ├── Element/ElectricalJob.cs
│   ├── Element/CarpentryJob.cs
│   ├── Visitor/IJobVisitor.cs
│   ├── Visitor/CostRollupVisitor.cs
│   ├── Visitor/PdfExportVisitor.cs
│   ├── Visitor/ComplianceAuditVisitor.cs
│   ├── Structure/ProjectJobList.cs
│   └── Program.cs
└── typescript/Behavioral/22-Visitor/
    ├── Element/IJobElement.ts
    ├── Element/PlumbingJob.ts
    ├── Element/ElectricalJob.ts
    ├── Element/CarpentryJob.ts
    ├── Visitor/IJobVisitor.ts
    ├── Visitor/CostRollupVisitor.ts
    ├── Visitor/PdfExportVisitor.ts
    ├── Visitor/ComplianceAuditVisitor.ts
    ├── Structure/ProjectJobList.ts
    └── App.ts

The Complete Series

That's all 22 patterns. Here's the full index.

Creational — how objects are created

# Pattern FixIt Pro scenario
01 Factory Method Typed job cards without hardcoded constructors
02 Abstract Factory Matched notification kits per client type
03 Builder Complex job cards without 12-argument constructors
04 Prototype Cloning recurring job card templates
05 Singleton One global job card registry, thread-safe

Structural — how objects are composed

# Pattern FixIt Pro scenario
06 Adapter Legacy SMS gateway behind a modern interface
07 Bridge Urgency levels × notification channels
08 Composite Compound job trees treated as single jobs
09 Decorator Urgent, insured, warrantied — stacked at runtime
10 Facade Five subsystems behind one DispatchJob() call
11 Flyweight 100,000 job cards, 3 shared metadata objects
12 Proxy Access control, logging, and lazy loading

Behavioral — how objects communicate

# Pattern FixIt Pro scenario
13 Chain of Responsibility Approval escalation by cost threshold
14 Command Undoable, queueable job actions
15 Iterator Priority traversal without exposing internals
16 Mediator DispatchCenter routing between all actors
17 Memento State snapshots without breaking encapsulation
18 Observer Automatic notification on status change
19 State Job lifecycle with enforced valid transitions
20 Strategy Swappable pricing algorithms
21 Template Method Fixed inspection sequence, variable steps
22 Visitor New operations without touching job classes

What I Learned Writing This

Patterns are language-agnostic, syntax is not. Every single pattern translated structurally between C# and TypeScript. What changed was ergonomics: C#'s Lazy<T>, ??=, method overloading, and decimal type made some patterns cleaner. TypeScript's Symbol, structural typing, discriminated unions, and native iterator protocol made others cleaner. Neither language won; they won different rounds.

Several patterns are already in the language. The Iterator is foreach and for...of. The Observer is the event keyword and addEventListener. The Strategy is a lambda. Recognising when the language already gives you the pattern is as valuable as knowing how to implement it manually.

The comparisons mattered more than the implementations. Bridge vs Adapter, State vs Strategy, Proxy vs Decorator, Mediator vs Facade, Command vs Memento, Template Method vs Strategy. The patterns are individually simple; knowing which one fits a given problem is the actual skill.

Naming a pattern you already use changes how you use it. Most developers reading this series have written middleware, Redux actions, and Git commits. Attaching the names Chain of Responsibility, Command, and Memento to those doesn't make the code better on its own, but it makes the next design conversation faster.

Thanks for reading. All 22 episodes and both full implementations are on GitHub.

github.com/antonlungameni/fixit-pro-design-patterns


Previous: #21 — Template Method Pattern
Series complete — start from Episode 01