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 metricsElectricalJob— has circuit load and certification requirementsCarpentryJob— has material volumes and finish specifications
Over time, the business asks for new operations across all of them:
- Cost rollup — total the cost of every job in a project
- PDF export — render each job into a printable report line
- Compliance audit — check each job against its regulatory requirements
- 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