Design Patterns

Two engineers who have never met sit down to review each other's code. One says, "I made the logger a Singleton, the connections come from a Factory, and the UI widgets are Observers of the data model." The other nods — they understand the whole architecture from three words, before reading a single line. That shared shorthand is the real power of design patterns: they are the vocabulary that professional software engineers use to talk about the shape of a solution.

A design pattern is a named, reusable solution to a problem that keeps coming up when you design software. It is not a finished class you copy and paste, and it is not a library you install. It is a template — a proven arrangement of objects and responsibilities — that you adapt to your own situation. Think of it like a recipe or a chess opening: the idea is general, but you cook the actual meal (write the actual code) yourself.

The idea was made famous in 1994 by four authors — Gamma, Helm, Johnson and Vlissides, forever known as the "Gang of Four" — whose book catalogued 23 patterns they had seen again and again in real object-oriented systems. They did not invent these solutions; they noticed and named them, the way a botanist names species that were always growing in the wild. Naming them turned private cleverness into a language everyone could share.

Three families of pattern

The Gang of Four grouped their patterns into three families, according to what kind of problem each one addresses:

We will now meet four canonical patterns — two creational (Singleton, Factory) and two behavioural (Observer, Strategy). For each, ask the same three questions: what problem does it solve?, what shape is the solution?, and what does it look like in code?

Singleton — exactly one, shared everywhere

The problem. Some things should exist only once in a whole program: a single configuration object, one logging service, one connection pool. If different parts of the code each made their own copy, they would disagree with one another and waste resources.

The solution shape. Make the class responsible for guaranteeing its own uniqueness. The constructor is hidden (private), and a single access point — usually getInstance() — creates the one object the first time it is asked for, then hands back that same object forever after.

class Logger { private static instance: Logger; private constructor() {} // no one else can call `new Logger()` static getInstance(): Logger { if (!Logger.instance) { Logger.instance = new Logger(); // built lazily, only once } return Logger.instance; } log(message: string): void { console.log("[LOG] " + message); } } // Everywhere in the program, this is literally the SAME object: Logger.getInstance().log("started");

Whenever anyone calls Logger.getInstance(), they receive the one and only Logger. The pattern enforces "there can be only one".

Factory — decide which subclass to build

The problem. Your code needs an object, but you do not want it to hard-wire which concrete class to create. Maybe the choice depends on a setting, a file type, or the user's platform. Scattering new WindowsButton() / new MacButton() decisions throughout the code makes it brittle — add a new platform and you must hunt down every spot.

The solution shape. Put the "which class do I build?" decision in one place — a factory method or function — that returns objects through a common interface. The caller asks the factory for "a button" and works with the interface; only the factory knows the concrete type.

interface Button { render(): void; } class WindowsButton implements Button { render() { console.log("Rendering a Windows button"); } } class MacButton implements Button { render() { console.log("Rendering a Mac button"); } } // The factory owns the decision — the rest of the app never says `new`. function createButton(os: string): Button { if (os === "mac") return new MacButton(); return new WindowsButton(); } const button = createButton("mac"); // caller gets a `Button`, not a `MacButton` button.render();

Adding a Linux button later means editing one function, not the whole codebase. The caller never changes, because it only ever knew about the Button interface.

Observer — subscribers notified of changes

The problem. When one object changes, several others need to react — but you do not want the changing object to know the exact list of who cares. A spreadsheet cell changes and a chart, a total, and a formula all need updating. Hard-coding those dependencies makes the cell impossible to reuse.

The solution shape. The changing object is a subject. Interested objects subscribe to it as observers. When the subject changes, it walks its list of subscribers and notifies each one — without knowing or caring what they do with the news. The subject depends only on a small observer interface, not on the concrete observers.

Below is the whole pattern working end to end. A WeatherStation (the subject) holds a temperature; a phone display and a logger both subscribe. When the temperature changes, both are notified automatically — the station never mentions either of them by name. Press Run and read the output.

// The observer interface: anything that wants to be told about updates. interface Observer { update(temperature: number): void; } // The subject: keeps a list of observers and notifies them on change. class WeatherStation { private observers: Observer[] = []; private temperature = 0; subscribe(o: Observer): void { this.observers.push(o); } setTemperature(t: number): void { this.temperature = t; console.log("Station: temperature is now " + t + "C"); for (const o of this.observers) { o.update(t); // notify every subscriber } } } // Two different observers, reacting in their own way. class PhoneDisplay implements Observer { update(t: number): void { console.log(" Phone shows: " + t + "C"); } } class Logger implements Observer { update(t: number): void { console.log(" Logger records: " + t); } } const station = new WeatherStation(); station.subscribe(new PhoneDisplay()); station.subscribe(new Logger()); station.setTemperature(21); // both observers react station.setTemperature(19); // ...and again, automatically

Notice the station has no idea what a PhoneDisplay or a Logger is. Add a third observer and the station code does not change at all. This decoupling is exactly why Observer underlies event systems, UI frameworks, and the "publish/subscribe" pattern you meet everywhere.

Strategy — swap the algorithm at runtime

The problem. You have several ways to do the same job — sort by price or by rating, pay by card or by voucher, compress with zip or gzip — and you want to choose which at runtime, without a sprawling if/else that has to be edited every time a new option appears.

The solution shape. Define a common interface for the interchangeable behaviours — each concrete strategy is one implementation. A context object holds a strategy and delegates the work to it. Swap the strategy object and the context behaves differently, with no change to the context's own code.

interface PayStrategy { pay(amount: number): void; } class CardPayment implements PayStrategy { pay(amount: number) { console.log("Paid " + amount + " by card"); } } class VoucherPayment implements PayStrategy { pay(amount: number) { console.log("Paid " + amount + " with a voucher"); } } class Checkout { constructor(private strategy: PayStrategy) {} setStrategy(s: PayStrategy) { this.strategy = s; } // swap at runtime complete(amount: number) { this.strategy.pay(amount); } } const checkout = new Checkout(new CardPayment()); checkout.complete(50); // Paid 50 by card checkout.setStrategy(new VoucherPayment()); checkout.complete(30); // Paid 30 with a voucher

Strategy turns a chain of if branches into a set of small, testable classes you can plug in and out. Observer and Strategy are both behavioural patterns, but note the difference: Observer is about notification (one-to-many), while Strategy is about substitutable behaviour (swap one algorithm for another).

In a language with first-class functions — TypeScript included — you often can collapse a simple Strategy into "just pass a function": checkout.complete(50, cardPayFn). That is not cheating; it is the same idea in lighter clothing. The full object-based Strategy earns its keep when a strategy needs its own state or several related methods (not just one), or when you want to name and register a family of them. Patterns describe intent, and the intent — "make the algorithm interchangeable" — is identical whether you use a class or a lambda.

A worked scenario: a game's difficulty setting

Suppose you are building a game with three difficulty levels, and the enemy AI should behave differently at each. A first instinct is one giant method full of if (difficulty === "easy") … else if …. But new difficulties, and testing each one in isolation, become a nightmare. This is a textbook fit for Strategy.

  1. Define an AIStrategy interface with a method chooseMove(state).
  2. Write three concrete strategies — EasyAI (moves randomly), NormalAI (blocks obvious threats), HardAI (searches ahead) — each implementing that interface.
  3. Give the Enemy (the context) a current AIStrategy and have it call strategy.chooseMove(state) on its turn.
  4. When the player picks a difficulty on the menu, hand the enemy the matching strategy object. Selecting which strategy to construct is itself a small Factory.

Adding a "Nightmare" mode later means writing one new class and one new line in the factory — the enemy, the menu, and every other strategy stay untouched. That is the whole promise of patterns: the system bends to change instead of breaking under it. And notice how patterns combine — a real design usually weaves several together.

The classic beginner mistake is treating patterns as goals rather than tools — learning the catalogue and then reaching for a pattern everywhere, wrapping a two-line problem in five interfaces and three factories. That is called over-engineering, and it makes code harder to read, not easier. A pattern only pays off when it solves a real, present problem; if the simple version is clear and correct, keep it.

Singleton deserves a special warning. It looks harmless — "just one shared object" — but it is essentially global mutable state dressed up in a class. It creates hidden dependencies (any method can quietly reach in and grab it), makes code hard to test (you cannot easily swap in a fake), and can cause trouble with multiple threads. Many experienced engineers now regard Singleton as an anti-pattern and prefer to pass the shared object in explicitly (this is "dependency injection"). Know the pattern — but reach for it rarely, and with your eyes open.