Observer Pattern — FixIt Pro Series #18
When a job card status changes, the homeowner, supervisor, and billing system all need to know automatically. Learn how the Observer pattern decouples event publishers from subscribers in C# and TypeScript.
Series: Design Patterns with FixIt Pro · Episode 18 / 22 · Behavioral Pattern
Previous: #17 — Memento Pattern
The Scenario
When a job card changes status in FixIt Pro, several things need to happen automatically:
- Homeowner gets a notification — "Your job has been assigned"
- Supervisor gets an alert — "Job JC-501 is now In Progress"
- BillingService starts tracking time — when status hits Completed, invoice is triggered
- AuditLog records every transition with a timestamp
Right now these calls are hardcoded inside the JobCard status methods:
public void Assign(string handyman)
{
_assignee = handyman;
_status = "Assigned";
// Hardcoded — JobCard knows too much
homeownerNotifier.Notify(JobId, "Your job has been assigned.");
supervisorAlert.Send(JobId, "Assigned");
billingService.StartTracking(JobId);
auditLog.Record(JobId, "Assigned");
}
JobCard now depends on four separate services. Add a fifth observer, say, an SlaTracker, and you modify JobCard. That's a violation of the Open/Closed Principle, and it means JobCard can't be tested without all four services present.
The Observer pattern inverts this: JobCard knows nothing about its observers. Observers register themselves and receive updates automatically.
What Is the Observer Pattern?
Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
The subject maintains a list of observers. When state changes, it notifies all of them. Observers can be added or removed at runtime without touching the subject.
The four participants
| Role | FixIt Pro equivalent |
|---|---|
| Subject interface | IJobCardSubject — declares Subscribe, Unsubscribe, Notify |
| Concrete Subject | JobCard — maintains observer list, notifies on state change |
| Observer interface | IJobCardObserver — declares Update(event, jobCard) |
| Concrete Observers | HomeownerNotifier, SupervisorAlert, BillingService, AuditLog |
C# Implementation
// ── Observer Interface ─────────────────────────────────────
public interface IJobCardObserver
{
void Update(string eventName, JobCard jobCard);
}
// ── Subject Interface ──────────────────────────────────────
public interface IJobCardSubject
{
void Subscribe(IJobCardObserver observer);
void Unsubscribe(IJobCardObserver observer);
void NotifyObservers(string eventName);
}
// ── Concrete Subject — JobCard ─────────────────────────────
public class JobCard : IJobCardSubject
{
public string JobId { get; init; } = Guid.NewGuid().ToString()[..8];
public string Title { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty;
public string Status { get; private set; } = "Draft";
public string? Assignee { get; private set; }
private readonly List<IJobCardObserver> _observers = new();
// ── Observer management ────────────────────────────────
public void Subscribe(IJobCardObserver observer)
{
if (!_observers.Contains(observer))
_observers.Add(observer);
}
public void Unsubscribe(IJobCardObserver observer) =>
_observers.Remove(observer);
public void NotifyObservers(string eventName)
{
foreach (var observer in _observers)
observer.Update(eventName, this);
}
// ── State transitions — notify after each change ───────
public void Assign(string handyman)
{
Assignee = handyman;
Status = "Assigned";
NotifyObservers("JobAssigned");
}
public void StartWork()
{
Status = "InProgress";
NotifyObservers("JobStarted");
}
public void PauseWork(string reason)
{
Status = $"Paused ({reason})";
NotifyObservers("JobPaused");
}
public void Complete()
{
Status = "Completed";
NotifyObservers("JobCompleted");
}
public override string ToString() =>
$"Job #{JobId} '{Title}' [{Category}] — {Status}" +
(Assignee is not null ? $" | {Assignee}" : "");
}
// ── Concrete Observers ─────────────────────────────────────
public class HomeownerNotifier : IJobCardObserver
{
private readonly string _homeownerName;
public HomeownerNotifier(string homeownerName)
{
_homeownerName = homeownerName;
}
public void Update(string eventName, JobCard jobCard)
{
var message = eventName switch
{
"JobAssigned" => $"Your job '{jobCard.Title}' has been assigned to {jobCard.Assignee}.",
"JobStarted" => $"Work has started on '{jobCard.Title}'.",
"JobPaused" => $"Your job '{jobCard.Title}' has been paused.",
"JobCompleted" => $"'{jobCard.Title}' is complete! Please review and confirm.",
_ => $"Update on your job: {eventName}"
};
Console.WriteLine($" 📧 [{_homeownerName}] {message}");
}
}
public class SupervisorAlert : IJobCardObserver
{
public void Update(string eventName, JobCard jobCard)
{
Console.WriteLine($" 🔔 [Supervisor] Event '{eventName}' on {jobCard}");
}
}
public class BillingService : IJobCardObserver
{
public void Update(string eventName, JobCard jobCard)
{
if (eventName == "JobCompleted")
Console.WriteLine($" 💰 [Billing] Invoice triggered for Job #{jobCard.JobId}.");
else if (eventName == "JobStarted")
Console.WriteLine($" 💰 [Billing] Time tracking started for Job #{jobCard.JobId}.");
}
}
public class AuditLog : IJobCardObserver
{
private readonly List<string> _entries = new();
public void Update(string eventName, JobCard jobCard)
{
var entry = $"[{DateTime.Now:HH:mm:ss}] {eventName} — {jobCard}";
_entries.Add(entry);
Console.WriteLine($" 📋 [Audit] {entry}");
}
public void PrintLog()
{
Console.WriteLine("\n=== Audit Log ===");
foreach (var entry in _entries)
Console.WriteLine($" {entry}");
}
}
// ── Client Code ────────────────────────────────────────────
class Program
{
static void Main()
{
var job = new JobCard
{
Title = "Replace burst pipe under sink",
Category = "Plumbing"
};
// Observers register themselves — JobCard never references them directly
var homeowner = new HomeownerNotifier("Sarah Ndapewa");
var supervisor = new SupervisorAlert();
var billing = new BillingService();
var audit = new AuditLog();
job.Subscribe(homeowner);
job.Subscribe(supervisor);
job.Subscribe(billing);
job.Subscribe(audit);
Console.WriteLine("=== Job Lifecycle ===\n");
job.Assign("James Nghipandua");
Console.WriteLine();
job.StartWork();
Console.WriteLine();
job.PauseWork("Waiting for replacement valve");
Console.WriteLine();
// Unsubscribe supervisor for the final transition
job.Unsubscribe(supervisor);
Console.WriteLine("(Supervisor unsubscribed)\n");
job.Complete();
audit.PrintLog();
}
}
Output:
=== Job Lifecycle ===
📧 [Sarah Ndapewa] Your job 'Replace burst pipe under sink' has been assigned to James Nghipandua.
🔔 [Supervisor] Event 'JobAssigned' on Job #f3a12b '...' [Plumbing] — Assigned | James Nghipandua
💰 [Billing] Time tracking started for Job #f3a12b. (on JobStarted — shown below)
📋 [Audit] [09:20:01] JobAssigned — Job #f3a12b '...' [Plumbing] — Assigned | James Nghipandua
📧 [Sarah Ndapewa] Work has started on 'Replace burst pipe under sink'.
🔔 [Supervisor] Event 'JobStarted' on Job #f3a12b '...' [Plumbing] — InProgress | James Nghipandua
💰 [Billing] Time tracking started for Job #f3a12b.
📋 [Audit] [09:20:01] JobStarted — ...
📧 [Sarah Ndapewa] Your job 'Replace burst pipe under sink' has been paused.
🔔 [Supervisor] Event 'JobPaused' on Job #f3a12b '...' [Plumbing] — Paused (Waiting for replacement valve)
📋 [Audit] [09:20:01] JobPaused — ...
(Supervisor unsubscribed)
📧 [Sarah Ndapewa] 'Replace burst pipe under sink' is complete! Please review and confirm.
💰 [Billing] Invoice triggered for Job #f3a12b.
📋 [Audit] [09:20:01] JobCompleted — ...
=== Audit Log ===
[09:20:01] JobAssigned — ...
[09:20:01] JobStarted — ...
[09:20:01] JobPaused — ...
[09:20:01] JobCompleted — ...
TypeScript Implementation
// ── Observer/IJobCardObserver.ts ───────────────────────────
import { JobCard } from "../Subject/JobCard";
export interface IJobCardObserver {
update(eventName: string, jobCard: JobCard): void;
}
// ── Subject/JobCard.ts ─────────────────────────────────────
import { IJobCardObserver } from "../Observer/IJobCardObserver";
const shortId = () => Math.random().toString(36).slice(2, 10);
export class JobCard {
readonly jobId: string = shortId();
private _status: string = "Draft";
private _assignee?: string;
private observers: IJobCardObserver[] = [];
constructor(
readonly title: string,
readonly category: string
) {}
get status(): string { return this._status; }
get assignee(): string | undefined { return this._assignee; }
// ── Observer management ────────────────────────────────
subscribe(observer: IJobCardObserver): void {
if (!this.observers.includes(observer))
this.observers.push(observer);
}
unsubscribe(observer: IJobCardObserver): void {
this.observers = this.observers.filter(o => o !== observer);
}
private notifyObservers(eventName: string): void {
for (const observer of this.observers)
observer.update(eventName, this);
}
// ── State transitions ──────────────────────────────────
assign(handyman: string): void {
this._assignee = handyman;
this._status = "Assigned";
this.notifyObservers("JobAssigned");
}
startWork(): void {
this._status = "InProgress";
this.notifyObservers("JobStarted");
}
pauseWork(reason: string): void {
this._status = `Paused (${reason})`;
this.notifyObservers("JobPaused");
}
complete(): void {
this._status = "Completed";
this.notifyObservers("JobCompleted");
}
toString(): string {
return `Job #${this.jobId} '${this.title}' [${this.category}] — ${this._status}` +
(this._assignee ? ` | ${this._assignee}` : "");
}
}
// ── Observer/HomeownerNotifier.ts ──────────────────────────
import { IJobCardObserver } from "./IJobCardObserver";
import { JobCard } from "../Subject/JobCard";
export class HomeownerNotifier implements IJobCardObserver {
constructor(private readonly homeownerName: string) {}
update(eventName: string, jobCard: JobCard): void {
const messages: Record<string, string> = {
JobAssigned: `Your job '${jobCard.title}' has been assigned to ${jobCard.assignee}.`,
JobStarted: `Work has started on '${jobCard.title}'.`,
JobPaused: `Your job '${jobCard.title}' has been paused.`,
JobCompleted: `'${jobCard.title}' is complete! Please review and confirm.`,
};
const message = messages[eventName] ?? `Update on your job: ${eventName}`;
console.log(` 📧 [${this.homeownerName}] ${message}`);
}
}
// ── Observer/SupervisorAlert.ts ────────────────────────────
import { IJobCardObserver } from "./IJobCardObserver";
import { JobCard } from "../Subject/JobCard";
export class SupervisorAlert implements IJobCardObserver {
update(eventName: string, jobCard: JobCard): void {
console.log(` 🔔 [Supervisor] Event '${eventName}' on ${jobCard}`);
}
}
// ── Observer/BillingService.ts ─────────────────────────────
import { IJobCardObserver } from "./IJobCardObserver";
import { JobCard } from "../Subject/JobCard";
export class BillingService implements IJobCardObserver {
update(eventName: string, jobCard: JobCard): void {
if (eventName === "JobCompleted")
console.log(` 💰 [Billing] Invoice triggered for Job #${jobCard.jobId}.`);
else if (eventName === "JobStarted")
console.log(` 💰 [Billing] Time tracking started for Job #${jobCard.jobId}.`);
}
}
// ── Observer/AuditLog.ts ───────────────────────────────────
import { IJobCardObserver } from "./IJobCardObserver";
import { JobCard } from "../Subject/JobCard";
export class AuditLog implements IJobCardObserver {
private entries: string[] = [];
update(eventName: string, jobCard: JobCard): void {
const time = new Date().toTimeString().slice(0, 8);
const entry = `[${time}] ${eventName} — ${jobCard}`;
this.entries.push(entry);
console.log(` 📋 [Audit] ${entry}`);
}
printLog(): void {
console.log("\n=== Audit Log ===");
for (const entry of this.entries)
console.log(` ${entry}`);
}
}
// ── App.ts ─────────────────────────────────────────────────
import { JobCard } from "./Subject/JobCard";
import { HomeownerNotifier } from "./Observer/HomeownerNotifier";
import { SupervisorAlert } from "./Observer/SupervisorAlert";
import { BillingService } from "./Observer/BillingService";
import { AuditLog } from "./Observer/AuditLog";
const job = new JobCard("Replace burst pipe under sink", "Plumbing");
const homeowner = new HomeownerNotifier("Sarah Ndapewa");
const supervisor = new SupervisorAlert();
const billing = new BillingService();
const audit = new AuditLog();
job.subscribe(homeowner);
job.subscribe(supervisor);
job.subscribe(billing);
job.subscribe(audit);
console.log("=== Job Lifecycle ===\n");
job.assign("James Nghipandua");
console.log();
job.startWork();
console.log();
job.pauseWork("Waiting for replacement valve");
console.log();
job.unsubscribe(supervisor);
console.log("(Supervisor unsubscribed)\n");
job.complete();
audit.printLog();
C# vs TypeScript — Key Differences
| Aspect | C# | TypeScript |
|---|---|---|
| Observer list | List<IJobCardObserver> |
IJobCardObserver[] |
| Duplicate check | _observers.Contains(observer) |
this.observers.includes(observer) |
| Unsubscribe | _observers.Remove(observer) |
this.observers.filter(o => o !== observer) |
| Event name routing | switch expression |
Record<string, string> lookup object |
| Private notify | private void NotifyObservers(...) |
private notifyObservers(...) (same) |
| Status setter | public string Status { get; private set; } |
private _status + get status() accessor |
The pattern is structurally identical in both languages. The main syntactic difference is in how event name routing is handled, C#'s switch expression vs TypeScript's record lookup object.
The Observer Is the Foundation of Modern Software
This is the pattern that powers almost everything event-driven:
| Technology | Observer implementation |
|---|---|
C# event keyword |
Language-level Observer: += subscribes, -= unsubscribes |
.NET IObservable<T> / IObserver<T> |
Reactive Observer for async streams |
JavaScript EventEmitter |
Node.js Observer |
addEventListener in the browser |
DOM Observer |
RxJS Observable |
Reactive Observer for TypeScript/JS |
| Redux / NgRx | Store Observer, components subscribe to state slices |
React useEffect |
Subscribes to dependency changes |
| SignalR | Server pushes to subscribed clients |
INotifyPropertyChanged in WPF/MAUI |
UI Observer for data binding |
If you've used any of these, you've used the Observer pattern. The GoF version we implemented today is the conceptual foundation all of them build on.
When to Use the Observer
Use it when:
- A change in one object requires updating others, and you don't know how many
- An object should notify other objects without making assumptions about who they are
- You want to decouple the subject from its dependents so they can vary independently
Avoid it when:
- The observer chain becomes too long and debugging notification order is painful
- Observers hold references to subjects that outlive them and this causes memory leaks in languages without garbage collection, and can cause issues even with GC if references aren't cleaned up
- The update cascade is unpredictable where Observer A updates B which updates C which re-triggers A
Real-World Takeaway
The Observer is the most widely used pattern in the GoF catalogue, arguably the foundation of event-driven programming itself. In C#, the event keyword is a language-level Observer. INotifyPropertyChanged powers WPF and MAUI data binding. IObservable<T> and Reactive Extensions build on it for async streams. In TypeScript, EventEmitter in Node.js, addEventListener in the browser, and RxJS Observable are all Observer implementations at different levels of abstraction.
In FixIt Pro, JobCard knows nothing about HomeownerNotifier, SupervisorAlert, BillingService, or AuditLog. It just notifies whoever is listening. Add an SlaTracker tomorrow, one Subscribe call, zero changes to JobCard. Remove the supervisor during off-hours means one Unsubscribe call. The subject stays stable. The observer list evolves freely.
Repo Structure for This Episode
github.com/antonlungameni/fixit-pro-design-patterns
fixit-pro-design-patterns/
├── csharp/Behavioral/18-Observer/
│ ├── Observer/IJobCardObserver.cs
│ ├── Subject/IJobCardSubject.cs
│ ├── Subject/JobCard.cs
│ ├── Observer/HomeownerNotifier.cs
│ ├── Observer/SupervisorAlert.cs
│ ├── Observer/BillingService.cs
│ ├── Observer/AuditLog.cs
│ └── Program.cs
└── typescript/Behavioral/18-Observer/
├── Observer/IJobCardObserver.ts
├── Subject/JobCard.ts
├── Observer/HomeownerNotifier.ts
├── Observer/SupervisorAlert.ts
├── Observer/BillingService.ts
├── Observer/AuditLog.ts
└── App.ts
Previous: #17 — Memento Pattern
Next up: #19 — State Pattern
A job card moving through states: Draft → Assigned → InProgress → PendingReview → Closed. Each state controls what transitions are valid — and invalid transitions are caught at the state level, not buried in conditionals.