Library
00/08 · ~55 min
GUIDEDECK · for building software that survives

Object-Oriented
Design & the Patterns
that scale it.

A 55-minute working session on writing code that's easy to change — from the four pillars of OOP, through SOLID, all 20 classic design patterns (with runnable TypeScript and Python samples to take home), and the architectures they grow into.

~55 MINMIXED TEAMLANGUAGE-AGNOSTIC
SCROLL
01 · Why this matters 3 min

Code is read and changed
far more than it's written.

Good design isn't about looking clever. It comes down to one very practical question: when the next person — often future-you — needs to change this code, how easily can they do it without breaking something else? Everything ahead is a tool for making that change cheaper and safer.

~70%

of a system's lifetime cost is maintenance, not initial build.

1×

written once…

10×

…read and reasoned about ten times more.

∞

requirements will change. Design for change, not perfection.

The two enemies

Strip away the jargon and almost every principle ahead is really one instruction: loosen the coupling, raise the cohesion.

02 · The four pillars of OOP 7 min

An object bundles state with the
behavior that acts on it.

A class is the blueprint; an object is one thing built from that blueprint. The whole idea is to keep a piece of data and the rules that protect it together in one place, so the rest of your code asks the object to do things instead of poking at its raw values. The four pillars below are how you do that well.

OOP — Object-Oriented Programming — is a way of organizing code around objects: small, self-contained units that keep related data together with the actions that work on it. Instead of one long script of steps, you describe the problem as a few objects that talk to each other — think a Cart that knows how to add an item and total itself. The four pillars below are the ideas that make this work.

Class vs. object

  • A class is the blueprint — it defines what data and methods every instance will have.
  • An object is one concrete instance, stamped from that blueprint with its own values.
  • One class → many independent objects.

One class Car blueprint stamps out many independent Car objects.

Words you'll see in the code

interface

A contract

A named list of methods a thing must have — just their names and shapes (the signature), with no actual code inside. Other code relies on the contract, so any class that fulfils it can be dropped in.

interface Notifier {
  send(m): void  // no body
}
implements

Fulfils a contract

A class promises to provide every method an interface lists, supplying the real code. One interface, many implementers.

class Email implements Notifier {
  send(m) { /* … */ }
}
constructor

Builds an object

The special method that runs when you write new. It takes the starting values and sets up the object's initial state.

class User {
  constructor(name) {
    this.name = name
  }
}
new User("Dani")
extends

Inheritance — "is-a"

Makes a class a kind of another, inheriting its fields and methods, then adding or overriding what differs. Use sparingly (Part 3).

class Manager extends Employee {
  approve(r) {}
}
Account · object
PUBLIC INTERFACE
PRIVATE STATE
balance · owner · currency
deposit()
caller
(other code)
withdraw()
balance()
can't reach state directly ✕

An object = guarded state + a public interface. Callers use the methods; they never touch the fields.

Read the schema

  • State lives inside, private — the object's data (balance, owner).
  • Behavior is the public interface — the only way in (deposit(), withdraw()).
  • Outside code calls methods; it can't corrupt the data because it can't reach it.
  • That single boundary is what the four pillars build on.

One area() call; each shape answers in its own way.

Polymorphism — the payoff

  • Calling code depends on the Shape idea, not on any concrete shape.
  • Each type supplies its own area() — the runtime picks the right one.
  • Add a new shape and the calling loop never changes. That's the whole win.

The four pillars

Pillar 1 · Encapsulation

Hide the internals, guard the rules

Bundle data with the methods that protect it, and keep that data private. The object becomes the only place its rules can be enforced — so they can't be broken from outside.

class Account {
  private balance = 0            // state — sealed off
  deposit(amt) {
    if (amt <= 0) throw Error("invalid")
    this.balance += amt     // the rule lives in ONE place
  }
  getBalance() { return this.balance }
}
// acc.balance = -999  →  not possible from outside
Account
PRIVATE
balance
deposit()
caller
balance()
can't reach the state directly ✕

Callers go through the public methods; the private state can't be touched from outside.

Like  an ATM — you press buttons; you can't reach into the cash drawer.

Pillar 2 · Abstraction

Expose the "what", hide the "how"

Give callers a simple, stable idea to depend on — send(message) — and hide the messy mechanism behind it. Swap the implementation and nothing upstream changes.

interface Notifier {
  send(msg: string): void   // the WHAT
}
class EmailNotifier implements Notifier {
  send(msg) { /* SMTP, retries, TLS… the HOW */ }
}
// callers know only Notifier — never SMTP details

Callers know only the Notifier interface (the what); the SMTP details (the how) stay hidden.

Like  a steering wheel — you turn it; you ignore the rack-and-pinion.

Pillar 3 · Inheritance

Define a general type, then specialize

A subclass is a kind of its parent and reuses its behavior, adding or overriding what differs. Powerful — but the most over-used pillar (see Part 3: prefer composition).

abstract class Account {
  protected balance = 0
  deposit(a) { this.balance += a }  // shared
}
class SavingsAccount extends Account {
  addInterest(r) { this.balance *= 1 + r }  // extra
}
// a SavingsAccount IS-A Account

SavingsAccount is-a Account: it inherits deposit() and adds its own addInterest().

Like  "a savings account is a (kind of) bank account."

Pillar 4 · Polymorphism

One call, many forms

Write code against the general type; let each subtype answer in its own way. The dispatch happens at runtime — so you can add new types without touching the calling code.

interface Shape { area(): number }
class Circle    implements Shape { area() {...} }
class Rectangle implements Shape { area() {...} }

for (const s of shapes) total += s.area()
// add Triangle later — this loop never changes
Circle
π r²
shape.area()
one call
Square
s²
Triangle
½ b h

One area() call; the runtime picks each shape's own version — add a shape, the caller never changes.

Like  a "play" button — works on a song, a video, a podcast.

03 · The inheritance trap 4 min

Favor composition over inheritance.

When a class inherits from a parent, it's tied to that parent's inner workings for good — change the parent and every child feels it. Stack several levels deep and the whole tree turns stiff: one tweak at the top ripples down everywhere, and the tidy "a Contractor is an Employee" story tends to fall apart as real-world requirements pile up.

Inheritance — rigid & leaky
class Employee { takeVacation() {...} }
class Contractor extends Employee {
  takeVacation() {           // contractors get no leave…
    throw Error("not allowed") // the hierarchy lied
  }
}
// every Employee caller can now blow up.
Composition — flexible & honest
class Worker {
  constructor(private leave: LeavePolicy) {}
  requestLeave() { this.leave.apply() }
}
new Worker(new PaidLeave())
new Worker(new NoLeave())  // policy plugged in
Inheritance — locked hierarchy

Contractor inherits a takeVacation() it can't honor — the chain is rigid.

Composition — plug-in behavior

Worker has-a LeavePolicy; swap PaidLeave ↔ NoLeave freely.

04 · SOLID — the core 9 min

Five principles for classes
that bend instead of break.

Click each letter to expand a before → after example. If your team takes only one slide from this whole session to heart, make it this one.

SOLID is an acronym for five design principles that keep classes easy to change and extend. Each letter targets one cause of fragile code; together they push you toward low coupling and high cohesion.
S
Single Responsibility
one reason to change
O
Open / Closed
extend, don't modify
L
Liskov Substitution
subtypes swap in safely
I
Interface Segregation
small, focused interfaces
D
Dependency Inversion
depend on abstractions
Rigid — tight coupling

Every module knows every other — one change ripples everywhere.

Flexible — via abstractions

Modules depend on shared interfaces — change one in isolation.

What SOLID buys you

  • Less coupling: edits stay local instead of rippling outward.
  • More cohesion: each class does one well-named job.
  • Easier testing & extension — new behavior plugs in.
  • That shift, applied five ways, is SOLID.
S
Single Responsibility
A class should have one reason to change.
+

Split jobs that change for different reasons. An Invoice shouldn't also render PDFs and talk to the database.

does three jobs
class Report {
  calculate() {...}
  toPDF() {...}     // formatting
  save() {...}      // persistence
}
one reason each
class Report { calculate() {...} }
class PdfRenderer { render(r) {...} }
class ReportRepo { save(r) {...} }
O
Open / Closed
Open for extension, closed for modification.
+

Add new behavior by writing new code, not by editing code that already works and is tested. The warning sign is a switch or if/else that grows a new branch every time a new case shows up; letting each type bring its own version of the method (polymorphism) is the fix.

edit on every new type
fee(p) {
  switch(p.type) {
    case "card": return ...
    case "paypal": return ...
  } // touch this for every method
}
extend by adding a class
interface Payment { fee(): number }
class Card implements Payment {...}
class Crypto implements Payment {...}
// new method = new file, zero edits
L
Liskov Substitution
A subtype must be usable anywhere its base type is.
+

In plain words: if your code works with a base type, it must keep working when handed any subclass — no surprises. Hand a function an Employee or a Contractor and it should behave correctly either way.

Formally: if S is a subtype of T, you can substitute an S wherever a T is expected and the program stays correct. A subclass breaks Liskov when it does any of these:

  • Throws where the parent didn't (Contractor's takeVacation() throwing).
  • Demands stricter inputs than the parent accepted.
  • Returns weaker guarantees than the parent promised.
breaks the contract
// a Square "is-a" Rectangle… until you resize it
class Square extends Rectangle {
  setWidth(w){ this.w = this.h = w }  // also changes height!
}
resizeTo(Rectangle r){ r.setWidth(5); r.setHeight(4)
  assert(r.area() === 20) }   // fails for Square → 16
model the real relationship
// they share a capability, not a hierarchy
interface Shape { area(): number }
class Square    implements Shape {...}
class Rectangle implements Shape {...}
// no false "is-a" → nothing to break

Smell test: if you override a method just to disable it (throw "not supported"), the "is-a" is a lie — use composition or a narrower interface instead.

I
Interface Segregation
No client forced to depend on methods it ignores.
+

Many small, role-based interfaces beat one fat one. A Robot shouldn't implement eat() just to satisfy Worker.

fat interface
interface Worker {
  work(); eat(); sleep()
}
class Robot implements Worker {
  eat(){ /* …meaningless */ }
}
focused roles
interface Workable { work() }
interface Feedable { eat() }
class Robot implements Workable {...}
class Human implements Workable, Feedable {}
D
Dependency Inversion
Depend on abstractions, not concretions.
+

Your important business logic shouldn't reach down and grab a specific tool, like a particular database. Instead, both the logic and the tool agree on an interface in the middle — so you can swap MySQL for Postgres, or plug in a fake database when running tests, without touching the logic.

glued to a detail
class OrderService {
  db = new MySqlDatabase()  // hard-wired
}
// can't test without a real MySQL
inject the abstraction
class OrderService {
  constructor(private db: Database) {}
}
new OrderService(new Postgres())
new OrderService(new FakeDb())  // testable
05 · Design patterns 20 min

Named solutions to problems
you'll meet again and again.

Patterns are vocabulary, not law. Reach for them when the problem appears — not to show off. Here are the 20 classic patterns grouped by the job they do, each with a runnable sample you can take home.

A design pattern is a named, reusable solution to a problem that keeps coming up in object-oriented code. It isn't a library or a framework — it's a shape you learn to recognise and a word your whole team can use. Say "let's put an adapter in front of that" and everyone knows what you mean.
Reusability

The same shape solves the same problem in the next project — you stop re-inventing it.

Cleaner code

Each pattern separates a concern (creation, composition, coordination) so classes stay small.

Easier maintenance

Change lands in one place instead of rippling through a pile of if/else.

Scalability

New payment types, new renderers, new handlers — added by writing new classes, not editing old ones.

Shared vocabulary

“Observer” or “Facade” says in one word what a paragraph would.

Better collaboration

Reviews, design docs and hand-overs get shorter when the names are shared.

Three families, one question each

The classic catalogue (the “Gang of Four” book, 1994) sorts patterns by the problem they solve. Ask which question you're stuck on and the family narrows the choice to a handful.

Creational

How do objects get made?

  • Singleton
  • Factory Method
  • Abstract Factory
  • Builder
  • Prototype
Structural

How do objects fit together?

  • Adapter
  • Bridge
  • Composite
  • Decorator
  • Facade
  • Flyweight
  • Proxy
Behavioral

How do objects talk?

  • Chain of Responsibility
  • Command
  • Iterator
  • Mediator
  • Observer
  • State
  • Strategy
  • Template Method

Start from the pain, not the pattern: the symptom tells you the family, the family tells you the two or three candidates.

Creational — how objects get made

These patterns take the new keyword out of the code that shouldn't care which class it gets. Open each one for a plain-language definition, a side-by-side TypeScript/Python snippet with its diagram, and where you've already met it.

1
Singleton
One instance, reachable from anywhere
+

Singleton guarantees a class has one instance and gives the whole app a single way to reach it. The class hides its constructor and exposes a static getInstance() that creates the object on the first call and hands back the same one afterwards. In the sample: a DBConnection is opened once; GetUsers and GetProductDetails both call getInstance() and share it instead of opening two connections.

class DBConnection {
  private static instance?: DBConnection;
  private readonly info: string;

  private constructor() {                 // nobody else can call new
    this.info = 'Connected at ' + new Date().toISOString();
  }

  static getInstance(): DBConnection {    // the one door in
    if (!DBConnection.instance) {
      DBConnection.instance = new DBConnection();
    }
    return DBConnection.instance;
  }

  getConnectionInfo(): string {
    return this.info;
  }
}

// Usage: two modules, one connection
const users = DBConnection.getInstance();
const products = DBConnection.getInstance();
console.log(users === products);          // true

Two modules call getInstance() and receive the very same connection object.

Use when
Exactly one of something must exist and be shared: a database pool, a logger, loaded app config. Creating it twice would be wasteful or wrong.
Watch for
It is a global in disguise: hidden dependencies, awkward tests, and surprises with threads or multiple processes. Often it's cleaner to create one instance and pass it in through a DI container.
Seen in
Module-level instances in Node/ES modules (every import gets the same object), the singleton scope of DI containers (NestJS, Angular, Spring), Python's logging.getLogger().

Take it home — a runnable project with a README and exercises: TypeScript sample (4 KB) · Python sample (4 KB).

2
Factory Method
Subclasses decide which object to create
+

Factory Method puts object creation behind a method that subclasses override. The creator (an abstract class) runs the shared workflow and calls its own factory method; each concrete creator decides which product class comes out. In the sample: PaymentGateway.makePayment() always calls createPayment() and then process(); CreditCardGateway returns a CreditCardPayment, UpiGateway a UpiPayment.

interface Payment { process(amount: number): void; }

class CreditCardPayment implements Payment {
  process(amount: number): void { console.log('Card: ' + amount); }
}
class UpiPayment implements Payment {
  process(amount: number): void { console.log('UPI: ' + amount); }
}

abstract class PaymentGateway {
  abstract createPayment(): Payment;      // the factory method
  makePayment(amount: number): void {     // shared flow, product varies
    this.createPayment().process(amount);
  }
}

class CreditCardGateway extends PaymentGateway {
  createPayment(): Payment { return new CreditCardPayment(); }
}
class UpiGateway extends PaymentGateway {
  createPayment(): Payment { return new UpiPayment(); }
}

// Usage: the caller never writes new UpiPayment()
const gateway: PaymentGateway = new UpiGateway();
gateway.makePayment(100);                 // UPI: 100

The base gateway owns the flow; each subclass decides which payment class it creates.

Use when
A class needs to create objects but shouldn't hard-code which class, and you expect more variants later. New variant = new subclass, the shared workflow stays untouched.
Watch for
One subclass per product can balloon into a deep hierarchy. For two or three fixed choices, a plain function with a switch is simpler and just as clear.
Seen in
document.createElement(tag), useFactory providers in NestJS and Angular, Django class-based views overriding get_form_class() or get_queryset().

Take it home — a runnable project with a README and exercises: TypeScript sample (5 KB) · Python sample (5 KB).

3
Abstract Factory
Create matching families without naming classes
+

Abstract Factory creates whole families of related objects that must go together. The abstract factory interface lists one create-method per part; each concrete factory returns parts from a single family, and the client only ever talks to the interface. In the sample: exportReport() asks a DocumentExporter for a header, content and footer; PDFExporterFactory hands back PDF parts and ExcelExporterFactory Excel parts, so a PDF header can never end up on an Excel footer.

interface Header { render(): string; }
interface Footer { render(): string; }     // Content omitted for brevity
interface DocumentExporter {              // one factory = one family
  createHeader(): Header;
  createFooter(): Footer;
}

class PdfHeader implements Header { render() { return 'PDF header'; } }
class PdfFooter implements Footer { render() { return 'PDF footer'; } }
class ExcelHeader implements Header { render() { return 'Excel header'; } }
class ExcelFooter implements Footer { render() { return 'Excel footer'; } }
class PdfExporterFactory implements DocumentExporter {
  createHeader(): Header { return new PdfHeader(); }
  createFooter(): Footer { return new PdfFooter(); }
}
class ExcelExporterFactory implements DocumentExporter {
  createHeader(): Header { return new ExcelHeader(); }
  createFooter(): Footer { return new ExcelFooter(); }
}

function exportReport(factory: DocumentExporter): void {
  console.log(factory.createHeader().render());  // never names PDF or Excel
  console.log(factory.createFooter().render());
}

exportReport(new PdfExporterFactory());   // swap in ExcelExporterFactory for .xlsx

The client asks the factory interface for parts; each concrete factory returns one consistent family.

Use when
Several objects must be consistent with each other (same format, theme, or vendor) and you want to swap the whole set by changing one line.
Watch for
Adding a new part (say, a watermark) means touching the interface and every concrete factory. For a single product, Factory Method is enough; this one earns its weight only with real families.
Seen in
Themed UI toolkits (Swing look-and-feel, MUI/Chakra theme providers), database dialect factories in Knex and SQLAlchemy, cloud SDK client factories that build matching request/response types.

Take it home — a runnable project with a README and exercises: TypeScript sample (5 KB) · Python sample (5 KB).

4
Builder
Assemble complex objects one step at a time
+

Builder assembles a complex object step by step instead of through one giant constructor. The builder exposes small, chainable steps that each return itself; build() validates the whole thing and returns the finished product. In the sample: a PostBuilder accepts setContent(), addImage(), addVideo() and addPoll() in any order and combination, then build() returns a SocialMediaPost, with no constructor taking eight optional arguments.

class SocialMediaPost {
  content = '';
  images: string[] = [];
  video?: string;
  poll?: string[];
}

class PostBuilder {
  private post = new SocialMediaPost();
  setContent(text: string): this { this.post.content = text; return this; }
  addImage(url: string): this { this.post.images.push(url); return this; }
  addVideo(url: string): this { this.post.video = url; return this; }
  addPoll(options: string[]): this { this.post.poll = options; return this; }
  build(): SocialMediaPost {              // validate, then hand over
    if (!this.post.content) throw new Error('A post needs content');
    return this.post;
  }
}

// Usage: only the parts this post needs, in any order
const post = new PostBuilder()
  .setContent('Launch day!')
  .addImage('hero.png')
  .addPoll(['Love it', 'Meh'])
  .build();
console.log(post.images.length, post.poll);   // 1 [ 'Love it', 'Meh' ]

Each chained step returns the builder; build() checks the result and hands over the finished post.

Use when
An object has many optional parts, or must be checked as a whole before it exists. Call sites read like a sentence instead of a list of nulls.
Watch for
An extra class per product, and a builder reused after build() can leak state between objects. In TS and Python, an options object or keyword arguments with defaults often does the job.
Seen in
Lombok's @Builder, query builders like Knex and SQLAlchemy (.where().orderBy().limit()), Java's StringBuilder, Zod schema chains.

Like ordering at a sandwich counter: bread, then fillings, then sauce, one step at a time. You only get the sandwich when you say that's it.

Take it home — a runnable project with a README and exercises: TypeScript sample (3 KB) · Python sample (3 KB).

5
Prototype
Clone a configured template instead of rebuilding
+

Prototype creates new objects by cloning an existing one instead of constructing from scratch. The prototype interface declares clone(); each class knows how to copy itself, including any nested data it owns. In the sample: one promo SocialMediaPost serves as a template, every post is clone()d from it, and only the fields that differ are edited.

interface Prototype<T> {
  clone(): T;
}

class SocialMediaPost implements Prototype<SocialMediaPost> {
  constructor(
    public content: string,
    public hashtags: string[],
    public media: string,
    public scheduledTime: string,
  ) {}

  clone(): SocialMediaPost {              // copy, don't rebuild
    return new SocialMediaPost(
      this.content, [...this.hashtags], this.media, this.scheduledTime,
    );                                    // [...] copies the array too
  }
}

// One template, many posts
const promoTemplate = new SocialMediaPost('Big sale!', ['#deal'], 'promo.png', '10:00');
const post1 = promoTemplate.clone();
post1.content = 'Big sale - shoes!';      // tweak only what differs
post1.hashtags.push('#shoes');
console.log(promoTemplate.hashtags, '|', post1.hashtags);   // ['#deal'] | ['#deal','#shoes']

One configured template is cloned per post; only the fields that differ are edited afterwards.

Use when
Building an object is expensive or fiddly (lots of setup, loaded data), or you want many near-identical copies of one carefully configured template.
Watch for
Shallow vs deep copies: an array or nested object shared between the template and its clones will bite later. Be explicit about what gets duplicated.
Seen in
Object.create(proto) and structuredClone() in JavaScript, Python's copy.deepcopy, Java's Cloneable, config or document templates duplicated per project.

Like photocopying a filled-in form and changing only the name: faster than filling every field again, as long as you remember the copy shares nothing with the original.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

Structural — how objects fit together

These patterns are about shape: making mismatched things fit, wrapping without editing, hiding a messy subsystem, or sharing what's common between thousands of objects.

1
Adapter
Make an incompatible interface fit
+

An adapter lets a class you can't (or don't want to) change plug into code that expects a different interface. The client talks to a target interface; the adapter implements that interface and translates each call onto the adaptee — the existing class. Unlike a decorator, an adapter changes the shape of the calls, not what they do.
In the sample: checkout expects PaymentProcessor.processPayment(), but UPI has makePayment() and the card provider has initiatePayment(), so each gets a small adapter.

interface PaymentProcessor {            // what checkout expects
  processPayment(amount: number): void;
}

class UPIPayment {                      // existing, different method name
  makePayment(amount: number): void { console.log('UPI paid ' + amount); }
}
class CreditCardPayment {
  initiatePayment(amount: number): void { console.log('Card paid ' + amount); }
}

class UPIPaymentAdapter implements PaymentProcessor {
  constructor(private upi: UPIPayment) {}
  processPayment(amount: number): void {
    this.upi.makePayment(amount);       // translate the call
  }
}
class CreditCardPaymentAdapter implements PaymentProcessor {
  constructor(private card: CreditCardPayment) {}
  processPayment(amount: number): void {
    this.card.initiatePayment(amount);
  }
}

const upi: PaymentProcessor = new UPIPaymentAdapter(new UPIPayment());
upi.processPayment(500);                // checkout never sees makePayment

Checkout only knows PaymentProcessor; each adapter translates that one call onto a provider class that was never written for it.

Use when
You need to reuse an existing class (a vendor SDK, a legacy module, a third-party library) whose method names or argument shapes don't match what your code expects, and changing either side is off the table.
Watch for
Adapters that quietly do extra work — currency conversion, retries, logging — belong elsewhere; keep the adapter a thin translation layer. Too many adapters stacked on each other usually means the target interface itself is wrong.
Seen in
Node's util.promisify (callback API → promise API), per-vendor database drivers behind Knex or TypeORM, Java's InputStreamReader (bytes → characters), and almost every payment or email provider wrapper you have written.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

2
Bridge
Let two hierarchies vary on their own
+

A bridge splits one big class family into two smaller ones that grow independently: an abstraction (what the user sees) that holds a reference to an implementation (how it gets done). Without it, every new theme times every new platform means another subclass. Adapter fixes a mismatch after the fact; bridge is designed in up front.
In the sample: DarkTheme and LightTheme are the abstraction side, ReactRenderer and ReactNativeRenderer the implementation side, joined by one renderer field.

type Colors = { background: string; text: string };

interface ThemeRenderer {               // implementation side
  renderTheme(colors: Colors): void;
}
class ReactRenderer implements ThemeRenderer {
  renderTheme(c: Colors): void { console.log('React CSS vars', c); }
}
class ReactNativeRenderer implements ThemeRenderer {
  renderTheme(c: Colors): void { console.log('RN StyleSheet', c); }
}

abstract class Theme {                  // abstraction side
  constructor(protected renderer: ThemeRenderer) {}   // the bridge
  abstract applyTheme(): void;
}
class DarkTheme extends Theme {
  applyTheme(): void { this.renderer.renderTheme({ background: '#121212', text: '#fff' }); }
}
class LightTheme extends Theme {
  applyTheme(): void { this.renderer.renderTheme({ background: '#fff', text: '#121212' }); }
}

new DarkTheme(new ReactRenderer()).applyTheme();
new DarkTheme(new ReactNativeRenderer()).applyTheme();   // same theme, other platform
new LightTheme(new ReactRenderer()).applyTheme();

Two families joined by one reference: add a theme on the left or a platform on the right and nothing on the other side changes.

Use when
One concept varies along two axes (theme × platform, shape × rendering API, message × transport) and you can feel the subclass explosion coming — or you want to swap the implementation at runtime.
Watch for
Applying it to a single axis adds an indirection nobody needs. And if the abstraction has to know which implementation it holds (if (renderer instanceof …)), the bridge has already leaked.
Seen in
JDBC and PDO (one query API, many vendor drivers), React's reconciler with react-dom and react-native as interchangeable renderers, and logging APIs like SLF4J or PSR-3 that sit over any backend.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

3
Composite
Treat one item and a group the same
+

A composite builds a tree where a single object (a leaf) and a group of objects (a composite) share one component interface. The composite answers each call by asking its children, so the caller never checks whether it holds an item or a whole subtree.
In the sample: Product is the leaf, ProductBundle the composite, and both expose getPrice(); a bundle's price is the sum of its children, even when a child is itself a bundle.

interface CartItem {                    // one interface for leaf and group
  getName(): string;
  getPrice(): number;
}

class Product implements CartItem {     // leaf
  constructor(private name: string, private price: number) {}
  getName(): string { return this.name; }
  getPrice(): number { return this.price; }
}

class ProductBundle implements CartItem {   // composite
  private items: CartItem[] = [];
  constructor(private name: string) {}
  add(item: CartItem): this { this.items.push(item); return this; }
  getName(): string { return this.name; }
  getPrice(): number {                  // sums children, bundles included
    return this.items.reduce((sum, item) => sum + item.getPrice(), 0);
  }
}

const tShirt = new Product('T-Shirt', 500);
const stationery = new ProductBundle('Stationery')
  .add(new Product('Pen', 40)).add(new Product('Notebook', 60));
const bundle = new ProductBundle('Back to school').add(tShirt).add(stationery);
console.log(bundle.getPrice());         // 600 - a bundle inside a bundle

Every node answers getPrice(); bundles (highlighted) add up their children, so the cart never asks what kind of item it is holding.

Use when
Your data is naturally a tree — folders and files, UI containers and widgets, bundles and products, org charts — and you want one operation (size, price, render, validate) to work the same at every level.
Watch for
Leaf-only or composite-only methods (add() on a leaf makes no sense) force awkward no-ops or type checks. Deep trees also make it easy to write quadratic walks — cache totals if the tree is large or hot.
Seen in
The browser DOM (an element's children are elements), React and Vue component trees, file systems reporting folder sizes, and groups inside groups in Figma or any drawing tool.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

4
Decorator
Add behaviour by wrapping, not editing
+

A decorator adds behaviour to an object by wrapping it in another object that has the same interface. Each decorator holds a component, does its bit, and forwards the call inward — so you can stack as many as you like at runtime, with no subclass per combination. Same interface in and out is the tell: an adapter changes the interface, a proxy controls access, a decorator adds features.
In the sample: PlainText is wrapped by BoldDecorator, UnderlineDecorator and ItalicDecorator, and render() unwinds through the layers.

interface TextComponent {
  render(): string;
}

class PlainText implements TextComponent {      // the core object
  constructor(private content: string) {}
  render(): string { return this.content; }
}

class TextDecorator implements TextComponent {  // same interface, wraps one
  constructor(protected inner: TextComponent) {}
  render(): string { return this.inner.render(); }
}

class BoldDecorator extends TextDecorator {
  render(): string { return '<b>' + super.render() + '</b>'; }
}
class ItalicDecorator extends TextDecorator {
  render(): string { return '<i>' + super.render() + '</i>'; }
}
class UnderlineDecorator extends TextDecorator {
  render(): string { return '<u>' + super.render() + '</u>'; }
}

const text = new ItalicDecorator(new UnderlineDecorator(new BoldDecorator(new PlainText('Hello!'))));
console.log(text.render());             // <i><u><b>Hello!</b></u></i>

One call travels inward through the layers and the result travels back out; because every layer is a TextComponent, any wrapped object can be wrapped again.

Use when
You want to add optional, combinable behaviour (logging, caching, retries, formatting, auth checks) to an object without touching its class or creating a subclass for every combination.
Watch for
Order matters and it is invisible at the call site — Bold(Italic(x)) and Italic(Bold(x)) can differ. Long chains are also painful to debug, and a decorator that needs the concrete inner type has stopped being a decorator.
Seen in
Express and Koa middleware, NestJS guards and interceptors, Python's @functools.lru_cache and @wraps, and Java's new BufferedReader(new FileReader(f)). TypeScript's @decorator syntax borrows the name but is a metaprogramming hook, not this pattern.

Like wrapping a gift: the present is unchanged, but each layer — box, paper, ribbon — adds something, and you can keep adding layers in any order.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

5
Facade
One simple door to a complex subsystem
+

A facade is a single, friendly class that sits in front of a messy subsystem and exposes only the few calls most clients need. The facade knows which subsystem classes to call and in what order; the client knows only the facade. It does not add features or hide the subsystem — you can still go around it when you need the detail.
In the sample: APIGatewayFacade.getDashboard(userId) stitches together UserService, OrderService, InventoryService and PaymentService so the client makes one call instead of four.

class UserService {
  getUser(id: string) { return { id, name: 'Ada' }; }
}
class OrderService {
  getOrders(userId: string) { return [{ orderId: 'ORD1', item: 'Laptop', userId }]; }
}
class InventoryService {
  checkStock(item: string) { return { item, inStock: true }; }
}
class PaymentService {
  getStatus(orderId: string) { return { orderId, status: 'Paid' }; }
}

class APIGatewayFacade {                // one door to four services
  private users = new UserService();
  private orders = new OrderService();
  private stock = new InventoryService();
  private payments = new PaymentService();
  getDashboard(userId: string) {
    const orders = this.orders.getOrders(userId).map((o) =>
      ({ ...o, payment: this.payments.getStatus(o.orderId), stock: this.stock.checkStock(o.item) }));
    return { user: this.users.getUser(userId), orders };
  }
}

console.log(new APIGatewayFacade().getDashboard('USR001'));   // client makes one call

The client sees one method; the facade fans out to four services and folds their answers into a single dashboard.

Use when
A subsystem has many moving parts but most callers need the same two or three high-level operations — or you want a stable, simple surface so the subsystem can be refactored underneath without breaking clients.
Watch for
A facade that keeps growing becomes a god object everything depends on. Keep it thin (orchestration only, no business rules) and don't let it become the only way in — power users still need the subsystem.
Seen in
API gateways (Kong, AWS API Gateway, a BFF layer), vendor SDK clients like stripe.checkout.sessions.create() over a dozen HTTP calls, jQuery's $ over raw DOM APIs, and Laravel's aptly named Facades.

Like a single remote control for a machine full of wiring: one button on the front, a tangle of circuits behind it that you never have to see.

Take it home — a runnable project with a README and exercises: TypeScript sample (3 KB) · Python sample (2 KB).

6
Flyweight
Share the heavy part, keep the rest small
+

A flyweight cuts memory when you have thousands of similar objects. Split each object's state in two: the intrinsic part that is identical across copies (share one instance, handed out by a factory that caches it) and the extrinsic part that differs per use (kept small, in the object that uses the flyweight).
In the sample: ProductInfo (name, brand, image) is the shared flyweight cached by ProductFactory; each ProductDisplay holds only its own price and stock and points at the shared info.

class ProductInfo {                     // intrinsic: shared, never changes
  constructor(
    readonly id: string, readonly name: string,
    readonly brand: string, readonly imageUrl: string,
  ) {}
}

class ProductFactory {                  // hands out one instance per id
  private static cache = new Map<string, ProductInfo>();
  static get(id: string, name: string, brand: string, img: string): ProductInfo {
    if (!this.cache.has(id)) this.cache.set(id, new ProductInfo(id, name, brand, img));
    return this.cache.get(id)!;         // same object every time
  }
}

class ProductDisplay {                  // extrinsic: per view, tiny
  constructor(private price: number, private inStock: boolean, private info: ProductInfo) {}
  render(): string {
    return this.info.name + ' by ' + this.info.brand + ' - ' + this.price + (this.inStock ? '' : ' (out)');
  }
}

const phone = ProductFactory.get('SM123', 'Samsung F23', 'Samsung', 'https://img.jpg');
const listing = [new ProductDisplay(500, true, phone), new ProductDisplay(450, false, phone)];
listing.forEach((d) => console.log(d.render()));   // two views, one ProductInfo

One ProductInfo lives in memory no matter how many views show it; each view carries only the few fields that differ.

Use when
You create huge numbers of objects that repeat the same bulky data — product cards in a listing, glyphs in a document, particles or tiles in a game — and memory or allocation time is the actual bottleneck.
Watch for
Shared state must be immutable; one mutation shows up everywhere. The split adds complexity, so measure first — for a few hundred objects it is not worth it, and a cache that never evicts is a memory leak with extra steps.
Seen in
String interning in Java and Python, glyph caches in every text renderer, Docker image layers shared between containers, and browsers sharing computed styles between identical DOM nodes.

Take it home — a runnable project with a README and exercises: TypeScript sample (3 KB) · Python sample (2 KB).

7
Proxy
A stand-in that controls access to the real thing
+

A proxy implements the same interface as a real subject and sits in front of it, deciding when and how calls reach it: cache the answer, load lazily, check permissions, or talk to a remote machine. The client can't tell the difference. It looks like a decorator, but the intent is control (caching, access, laziness), not new features.
In the sample: CDNProxy stands in for OriginServer; the first getProducts() hits the origin, later ones within the TTL are answered from the proxy's cache.

interface ProductSource {
  getProducts(): string[];
}
class OriginServer implements ProductSource {   // the real, slow thing
  getProducts(): string[] {
    console.log('origin hit');          // imagine 300 ms of network here
    return ['Laptop', 'Smartphone', 'Keyboard'];
  }
}

class CDNProxy implements ProductSource {       // same interface, stands in front
  private cache: string[] | null = null;
  private fetchedAt = 0;
  constructor(private origin: ProductSource, private ttlMs = 10_000) {}
  getProducts(): string[] {
    const fresh = Date.now() - this.fetchedAt < this.ttlMs;
    if (this.cache && fresh) return this.cache;   // serve from cache, skip origin
    this.cache = this.origin.getProducts();
    this.fetchedAt = Date.now();
    return this.cache;
  }
}

const cdn: ProductSource = new CDNProxy(new OriginServer());
cdn.getProducts();                      // origin hit
cdn.getProducts();                      // served from cache, origin untouched

Both classes implement ProductSource, so the browser talks to the proxy exactly as it would to the origin — which now only sees cache misses.

Use when
Reaching the real object is expensive, slow, remote, or sensitive: cache results (caching proxy), defer creation until first use (virtual proxy), enforce permissions (protection proxy), or hide the network (remote proxy).
Watch for
Stale caches and surprising latency on the first call are the classic bugs. If the proxy starts adding behaviour instead of controlling access, you have written a decorator — name it honestly.
Seen in
CDNs and reverse proxies (Cloudflare, nginx, Varnish), ORM lazy loading in Hibernate and Doctrine, JavaScript's built-in Proxy object that powers Vue 3 reactivity and MobX, and gRPC client stubs.

Like a smart middleman at the door: they answer the easy questions themselves and only bother the person inside when they really have to.

Take it home — a runnable project with a README and exercises: TypeScript sample (3 KB) · Python sample (2 KB).

Behavioral — how objects talk

These patterns move the decision about who does what next out of tangled method bodies and into small, swappable objects.

1
Chain of Responsibility
Pass a request down a line of handlers
+

A request travels along a line of handlers. Each one either deals with it, changes it, or hands it to the next handler — and the sender never knows which one did the work. You can add, remove, or reorder handlers without touching the others. In the sample: a logging pipeline where a MetadataEnricher stamps a trace id, a LogLevelFilter drops noisy entries, and a RemoteLogger ships what survives to ELK or Splunk.

interface LogEntry { level: 'DEBUG' | 'INFO' | 'ERROR'; message: string; traceId?: string; }

abstract class LogHandler {
  private next?: LogHandler;
  setNext(handler: LogHandler): LogHandler { this.next = handler; return handler; }
  handle(entry: LogEntry): void { this.next?.handle(entry); }   // default: pass it on
}

class MetadataEnricher extends LogHandler {
  handle(entry: LogEntry): void { entry.traceId = 'trace-' + Date.now(); super.handle(entry); }
}
class LogLevelFilter extends LogHandler {
  handle(entry: LogEntry): void { if (entry.level !== 'DEBUG') super.handle(entry); }  // else: chain stops
}
class RemoteLogger extends LogHandler {
  handle(entry: LogEntry): void {
    console.log('[Remote] ' + entry.level + ' ' + entry.message + ' (' + entry.traceId + ')');
    super.handle(entry);
  }
}

const chain = new MetadataEnricher();
chain.setNext(new LogLevelFilter()).setNext(new RemoteLogger());
chain.handle({ level: 'ERROR', message: 'Database connection failed' });

The entry flows left to right; every handler extends LogHandler (dashed) and decides whether to call next.

Use when
Several steps may each want a say in a request — validation, enrichment, filtering, auth — and you want to add or reorder steps without rewriting a big if/else.
Watch for
A request can silently fall off the end of the chain with nobody handling it, and long chains make it hard to see which handler actually did what.
Seen in
Express and Koa middleware (next()), DOM event bubbling, ASP.NET Core and Java Servlet filter pipelines, Python logging handlers and filters.

Take it home — a runnable project with a README and exercises: TypeScript sample (3 KB) · Python sample (3 KB).

2
Command
Turn a request into an object you can pass around
+

A command wraps one action — what to do and on which object — in a small object with an execute() method. An invoker holds commands and runs them without knowing what they do; the receiver is the thing that actually does the work. Because a request is now data, you can queue it, log it, replay it, or keep it on an undo stack. In the sample: a Remote (invoker) holds a TurnOnCommand or TurnOffCommand, and pressing the button drives a LightBulb (receiver).

interface Command { execute(): void; }

class LightBulb {                                   // receiver: does the real work
  turnOn(): void { console.log('Bulb is on'); }
  turnOff(): void { console.log('Bulb is off'); }
}

class TurnOnCommand implements Command {
  constructor(private bulb: LightBulb) {}
  execute(): void { this.bulb.turnOn(); }
}
class TurnOffCommand implements Command {
  constructor(private bulb: LightBulb) {}
  execute(): void { this.bulb.turnOff(); }
}

class Remote {                                      // invoker: knows only Command
  private command?: Command;
  setCommand(command: Command): void { this.command = command; }
  pressButton(): void { this.command?.execute(); }
}

const remote = new Remote();
const bulb = new LightBulb();
remote.setCommand(new TurnOnCommand(bulb));  remote.pressButton();   // Bulb is on
remote.setCommand(new TurnOffCommand(bulb)); remote.pressButton();   // Bulb is off

The invoker only knows Command; each concrete command carries a reference to the receiver it drives.

Use when
You need to queue, schedule, log, or undo actions, or you want the thing that triggers an action (a button, a shortcut, an API call) fully decoupled from the thing that performs it.
Watch for
One tiny class per action adds up fast. If you never queue, undo, or log, a plain callback does the same job with less ceremony.
Seen in
Undo/redo stacks in editors, job queues like BullMQ, Celery and Sidekiq (a job is a serialised command), Redux actions, and CQRS command handlers (MediatR in .NET).

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

3
Iterator
Walk a collection without knowing how it is stored
+

An iterator hands out the items of a collection one at a time through a tiny interface — hasNext() and next() — so the client never touches the underlying array, tree, or database cursor. The collection stays free to change its storage without breaking anyone who loops over it. In the sample: a ShoppingCart creates an iterator, and a DiscountEngine walks the items to apply discounts without knowing how the cart stores them.

interface CartItem { name: string; price: number; }
interface CartIterator { hasNext(): boolean; next(): CartItem; }

class ShoppingCart {
  private items: CartItem[] = [];                       // storage stays private
  add(item: CartItem): void { this.items.push(item); }
  createIterator(): CartIterator {
    let index = 0;
    return { hasNext: () => index < this.items.length, next: () => this.items[index++] };
  }
}

class DiscountEngine {
  static apply(it: CartIterator): void {                // never sees the array
    while (it.hasNext()) {
      const item = it.next();
      console.log(item.price > 1000 ? '10% off ' + item.name : 'No discount for ' + item.name);
    }
  }
}

const cart = new ShoppingCart();
cart.add({ name: 'Laptop', price: 1500 });
cart.add({ name: 'Notebook', price: 50 });
DiscountEngine.apply(cart.createIterator());

The engine asks the cart for an iterator and talks only to that; the private array is reachable only through it.

Use when
Code needs to loop over something whose internal shape may change, or is expensive to load at once — a paged API, a tree, a stream, a database cursor.
Watch for
Hand-rolled hasNext()/next() is rarely needed today — most languages ship the protocol. Also decide what happens if the collection changes mid-walk.
Seen in
JavaScript iterables and for…of, generators (function*), Python generators and __iter__, Java Iterator, and database cursors or paginated API clients.

Like a librarian who fetches the next book for you — you never need to learn the shelving system.

Take it home — a runnable project with a README and exercises: TypeScript sample (3 KB) · Python sample (2 KB).

4
Mediator
Objects talk through one coordinator, not each other
+

Instead of every object holding a reference to every other object, they all talk to one mediator that knows the routing rules. Each colleague only knows the mediator, so adding a new participant touches one place. Don't confuse it with Observer: an observer subject broadcasts one-to-many and lets subscribers decide, while a mediator orchestrates many-to-many traffic from the centre. In the sample: users call send() on a ChatRoom, and the room delivers the message to every other registered user.

interface ChatMediator { send(message: string, sender: User): void; }

class User {
  constructor(public name: string, private room: ChatMediator) {}
  send(message: string): void { this.room.send(message, this); }   // talks to the room only
  receive(message: string, from: string): void {
    console.log(this.name + ' got "' + message + '" from ' + from);
  }
}

class ChatRoom implements ChatMediator {                              // the mediator
  private users: User[] = [];
  register(user: User): void { this.users.push(user); }
  send(message: string, sender: User): void {
    for (const user of this.users) {
      if (user !== sender) user.receive(message, sender.name);       // routing lives here
    }
  }
}

const room = new ChatRoom();
const alice = new User('Alice', room);
for (const user of [alice, new User('Bob', room), new User('Charlie', room)]) room.register(user);
alice.send('Hello everyone!');

Users never reference each other — every message goes up to the room, which decides who hears it.

Use when
A group of objects would otherwise need references to each other — chat rooms, form fields that enable or disable each other, dialogs, workflow steps — and the web of links is getting hard to follow.
Watch for
The mediator can grow into a god object that knows everything; keep its rules small and named, and reach for Observer instead when you only need one-way broadcast.
Seen in
Chat and game servers (Socket.IO rooms), MediatR in .NET, the Redux store as the single dispatcher, and air-traffic-style schedulers that sequence many workers.

Air-traffic control: pilots never negotiate with each other — every plane talks to the tower, and the tower sequences them all.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

5
Observer
When one thing changes, everyone subscribed hears about it
+

A subject keeps a list of observers and calls their update() whenever its state changes. The subject doesn't know what observers do with the news, and observers can subscribe or unsubscribe at any time — a one-to-many link with no hard-coded wiring. In the sample: a WeatherStation subject notifies a TemperatureDisplay and a Fan every time setTemperature() is called.

interface Observer { update(temperature: number): void; }

class WeatherStation {                                    // the subject
  private observers: Observer[] = [];
  private temperature = 0;
  addObserver(observer: Observer): void { this.observers.push(observer); }
  removeObserver(observer: Observer): void {
    this.observers = this.observers.filter((o) => o !== observer);
  }
  setTemperature(temp: number): void {
    this.temperature = temp;
    for (const o of this.observers) o.update(this.temperature);   // notify everyone
  }
}

class TemperatureDisplay implements Observer {
  update(temperature: number): void { console.log('Display: ' + temperature + ' C'); }
}
class Fan implements Observer {
  update(temperature: number): void { console.log(temperature > 25 ? 'Fan on' : 'Fan off'); }
}

const station = new WeatherStation();
station.addObserver(new TemperatureDisplay());
station.addObserver(new Fan());
station.setTemperature(30);                               // Display: 30 C · Fan on

The subject only knows the Observer interface; every subscriber implements it (dashed) and reacts in its own way.

Use when
Several parts of a system must react to one change — UI updating from a model, caches invalidating, emails going out on an order event — without the source knowing who is listening.
Watch for
Forgotten unsubscribes leak memory, and long chains of observers triggering observers make the order of updates hard to reason about.
Seen in
Node's EventEmitter, DOM addEventListener, RxJS observables, Django signals, and React state subscriptions that re-render on change.

A YouTube channel: the creator uploads once, and every subscriber gets the notification without the creator knowing who they are.

Take it home — a runnable project with a README and exercises: TypeScript sample (3 KB) · Python sample (2 KB).

6
State
Behaviour lives in state objects, not in if/else
+

An object that behaves differently depending on its current mode — playing, paused, stopped — keeps that behaviour in separate state classes instead of a status flag checked all over the code. The context holds the current state object and delegates every call to it; each state can hand the context the next state. In the sample: a MediaPlayer delegates play() and pause() to PlayingState, PausedState or StoppedState objects.

interface State { play(player: MediaPlayer): void; pause(player: MediaPlayer): void; }

class PlayingState implements State {
  play(): void { console.log('Already playing'); }
  pause(player: MediaPlayer): void { console.log('Pausing'); player.setState(new PausedState()); }
}
class PausedState implements State {
  play(player: MediaPlayer): void { console.log('Resuming'); player.setState(new PlayingState()); }
  pause(): void { console.log('Already paused'); }
}
class StoppedState implements State {
  play(player: MediaPlayer): void { console.log('Starting'); player.setState(new PlayingState()); }
  pause(): void { console.log('Nothing to pause'); }
}

class MediaPlayer {                                     // the context
  private state: State = new StoppedState();
  setState(state: State): void { this.state = state; }
  play(): void { this.state.play(this); }               // no if/else on a status flag
  pause(): void { this.state.pause(this); }
}

const player = new MediaPlayer();
player.play();   // Starting
player.pause();  // Pausing
player.play();   // Resuming

The player forwards every call to its current State; each state object answers and may swap in the next one.

Use when
An object has a handful of clearly named modes and the same method must do different things in each — order lifecycles, connections, players, editors — and the switch (status) blocks are multiplying.
Watch for
Overkill for two states or a single flag. Transitions scattered across state classes can also be hard to see as a whole — a diagram or a state-machine library helps.
Seen in
State-machine libraries like XState and Spring Statemachine, e-commerce order lifecycles (placed → paid → shipped), TCP connection states, and parsers.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

7
Strategy
Swap the algorithm without changing the caller
+

A family of interchangeable algorithms sits behind one strategy interface. The context holds a strategy and calls it, so choosing a different algorithm means passing in a different object — no branching inside the context. It looks like State, but here the caller picks the strategy; in State the object switches its own state as it runs. In the sample: a ShippingCalculator prices a parcel with whichever FedExStrategy, UPSStrategy or DTDCStrategy it is given.

interface ShippingStrategy { calculateCost(weight: number, distance: number): number; }

class FedExStrategy implements ShippingStrategy {
  calculateCost(weight: number, distance: number): number { return 50 + weight * 0.8 + distance * 0.5; }
}
class UPSStrategy implements ShippingStrategy {
  calculateCost(weight: number, distance: number): number { return 40 + weight * 0.9 + distance * 0.6; }
}
class DTDCStrategy implements ShippingStrategy {
  calculateCost(weight: number, distance: number): number { return 30 + weight * 1.0 + distance * 0.4; }
}

class ShippingCalculator {                                   // the context
  constructor(private strategy: ShippingStrategy) {}
  setStrategy(strategy: ShippingStrategy): void { this.strategy = strategy; }
  getCost(weight: number, distance: number): number {
    return this.strategy.calculateCost(weight, distance);    // no switch on carrier
  }
}

const calculator = new ShippingCalculator(new FedExStrategy());
console.log('FedEx:', calculator.getCost(10, 100));         // 108
calculator.setStrategy(new UPSStrategy());
console.log('UPS:', calculator.getCost(10, 100));           // 109

The calculator depends only on the ShippingStrategy interface; the caller decides which carrier's formula to plug in.

Use when
There are several ways to do the same job — pricing, sorting, compressing, authenticating — and you want to choose one at runtime or add a new one without editing the caller.
Watch for
Clients now have to know the strategies exist to choose one. For a couple of one-liners, a plain function argument is lighter than a class per algorithm.
Seen in
Passport.js authentication strategies, payment providers behind one checkout (Stripe, PayPal), comparator functions in Array.sort(), and pluggable compression or caching backends.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

8
Template Method
Fix the skeleton, let subclasses fill in steps
+

A base class defines an algorithm as a fixed sequence of steps — the template method — and leaves some of those steps abstract or overridable. Subclasses fill in the steps but can never change the order. Optional steps with a sensible default are called hooks. In the sample: DataProcessor fixes process() as load → parse → save, and JsonProcessor / XmlProcessor supply the format-specific steps.

abstract class DataProcessor {
  process(): void {                       // the template method: fixed order
    this.load();
    this.parse();
    this.save();
  }
  protected abstract load(): void;
  protected abstract parse(): void;
  protected save(): void { console.log('Saving to the warehouse'); }   // hook with a default
}

class JsonProcessor extends DataProcessor {
  protected load(): void { console.log('Loading JSON file'); }
  protected parse(): void { console.log('Parsing JSON'); }
}
class XmlProcessor extends DataProcessor {
  protected load(): void { console.log('Loading XML feed'); }
  protected parse(): void { console.log('Parsing XML'); }
  protected save(): void { console.log('Saving XML as-is'); }         // overrides the hook
}

new JsonProcessor().process();          // Loading JSON file · Parsing JSON · Saving to the warehouse
new XmlProcessor().process();           // Loading XML feed · Parsing XML · Saving XML as-is

The base class owns the order of steps; subclasses (dashed) only supply the pieces, and XML also overrides the save() hook.

Use when
Several classes share the same overall procedure and differ only in a few steps — import pipelines, report generation, test setup and teardown — and you want the shared order in exactly one place.
Watch for
It relies on inheritance, so the step list is hard to change later and subclasses are tightly bound to the base. When steps need to vary independently, prefer Strategy or plain composition.
Seen in
Django class-based views (get_context_data), JUnit and pytest setup/teardown fixtures, React class lifecycle methods, and Rails ActiveRecord callbacks.

Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).

Beyond the Gang of Four

Two patterns you'll meet in every backend codebase that aren't in the 1994 catalogue but earn their place next to it.

R
Repository
a collection-like API over storage
+
interface UserRepo {
  findById(id): User
  save(u: User): void
}
// domain talks to UserRepo — not SQL, not an ORM.
// SqlUserRepo / InMemoryUserRepo implement it.

Domain code depends on the UserRepo idea; a real or in-memory version plugs in behind it.

Use when
You want domain logic free of persistence details (and trivially testable).
It's really
Dependency Inversion applied to your data layer.
D
Dependency Injection
hand dependencies in, don't build them
+
// not a GoF pattern, but the most important habit:
class Checkout {
  constructor(
    private repo: OrderRepo,
    private pay: Payment,
    private notify: Notifier,
  ) {}   // collaborators injected → swappable + testable
}

main() builds the Database and hands it in — OrderService never creates its own.

Use when
Always. It's the practical mechanics of the "D" in SOLID.
Bonus
A DI container can wire these, but constructor injection alone gets 90% of the value.
How to use this list. Don't memorise twenty patterns — remember the three questions. When a piece of code is painful to change, find the question it answers, read the two or three candidates, and pick the smallest one that removes the pain. A pattern you don't need is just indirection.
06 · Architectural patterns 7 min

The same rule, zoomed out:
keep dependencies pointing inward.

SOLID is about individual classes; architecture is the same instinct applied to a whole system. The three styles below are really one idea getting progressively stricter: keep your core business rules in the middle, and push the parts that change often — the UI, the database, the framework — out to the edges.

Start here

Layered (N-tier)

Presentation / UI
Application
Domain / Business
Infrastructure / Data

Each layer talks only to the one directly below it. Simple and familiar — though your business rules end up leaning on the database. A solid default for straightforward apps that mostly create, read, update, and delete records (often called CRUD).

Decouple the edges

Hexagonal · Ports & Adapters

UI
Database
Domain
+ ports
CLI / API
Ext. API

The core defines ports (interfaces); adapters implement them. Drive it from a test, a CLI, or HTTP — the domain never knows which.

The strict form

Clean / Onion

Frameworks
Adapters
deps point in
Use cases
Entities

The Dependency Rule: source dependencies only point inward. Entities know nothing of DB or web. Maximum testability — at the cost of more layers.

And for the UI: MVC → MVVM → MVI

  • MVC — Model / View / Controller. The Controller sits in the middle, taking user input and updating the Model and View. The classic shape for server-rendered web pages.
  • MVVM — a ViewModel holds the screen's state, and the View updates itself automatically whenever that state changes. Fits UI toolkits that re-render on their own when data updates.
  • MVI — data flows in one direction only: intent → state → view, with a single state object that is never edited in place, only replaced. Predictable and easy to debug because every screen comes from one known state.

All three answer the same question: keep view logic out of business logic.

  • Small CRUD / prototype → Layered. Don't over-engineer.
  • Rich domain, many integrations, heavy testing → Hexagonal / Clean.
  • Match the UI pattern to your framework's grain (MVVM with declarative UI, MVI for strict state).
  • Migrate toward stricter only when coupling pain justifies it — YAGNI.
07 · Principles that tie it together 2 min

The everyday discipline
behind every pattern.

DRY

Don't Repeat Yourself

Every piece of knowledge has one home. But beware: duplicate code ≠ duplicate knowledge — don't couple two things just because they look alike today.

KISS

Keep It Simple

The simplest design that meets the requirement wins. Clever is a liability when someone else debugs it at 2 a.m.

YAGNI

You Aren't Gonna Need It

Don't build for imagined futures. Add abstraction when the second real case arrives — not before.

SoC

Separation of Concerns

One module, one concern. The macro version of Single Responsibility — why layers and ports exist.

Testability

Design for testing

If it's hard to test, it's badly coupled. Test pain is a design smell, not a testing problem. DI makes it easy.

Tension

Balance, don't dogma

DRY vs KISS, abstraction vs YAGNI — these pull against each other. Judgment is knowing which to favor here.

08 · Recap & takeaways 1 min

Five rules to walk out with.

1Optimize for change. Low coupling, high cohesion is the whole game.
2Program to interfaces. Encapsulation + polymorphism let one line survive new requirements.
3Compose, then inherit. Use inheritance only for a true, stable "is-a".
4Point dependencies inward. SOLID at the class level, Clean/Hexagonal at the system level — same idea.
5Apply, don't worship. Patterns and principles are tools. KISS & YAGNI keep the others honest.

Keep going

  • Clean Code & Clean Architecture — Robert C. Martin
  • Design Patterns — the "Gang of Four" (reference, not a reading list)
  • Refactoring — Martin Fowler
  • refactoring.guru — patterns explained visually, free

One sentence to remember

"Make the change easy, then make the easy change."

— Kent Beck

Knowledge check

Did it stick?

Five quick questions on OOP, SOLID, composition, patterns, and architecture — instant feedback, no sign-in.

Downloadable materials

4 files

Take the deck home: runnable sample projects for every pattern (also linked inside each pattern above), plus the diagram handouts. Unzip, read the README, run, then try the exercises.

  • All 20 patterns — TypeScript

    One standalone Node project per pattern: the code behind every snippet above, each with a README, run steps (tsc + node, no dependencies) and exercises.

    TypeScript58 KBDownload .zip
  • All 20 patterns — Python

    The same 20 samples ported to idiomatic Python 3.10+ (ABCs, Protocols, dataclasses) — one folder per pattern, README included, no dependencies.

    Python48 KBDownload .zip
  • Design patterns — diagram handout

    The slide-style diagrams for all 20 patterns: the real-world scenario behind each sample, plus the Observer-vs-Mediator comparison.

    263 KBOpen PDF
  • SOLID principles — diagram handout

    One page per principle with the analogy and a before/after code comparison, matching section 04.

    222 KBOpen PDF
Rate this deck
be the first

Navigate with ← → or scroll · back to library