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.
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.
of a system's lifetime cost is maintenance, not initial build.
written once…
…read and reasoned about ten times more.
requirements will change. Design for change, not perfection.
Strip away the jargon and almost every principle ahead is really one instruction: loosen the coupling, raise the cohesion.
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.
Cart that knows how to add an item and total itself. The four pillars below are the ideas that make this work.One class Car blueprint stamps out many independent Car objects.
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 }
A class promises to provide every method an interface lists, supplying the real code. One interface, many implementers.
class Email implements Notifier { send(m) { /* … */ } }
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")
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) {} }
An object = guarded state + a public interface. Callers use the methods; they never touch the fields.
balance, owner).deposit(), withdraw()).One area() call; each shape answers in its own way.
area() — the runtime picks the right one.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
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.
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.
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."
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
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.
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.
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.
class Worker { constructor(private leave: LeavePolicy) {} requestLeave() { this.leave.apply() } } new Worker(new PaidLeave()) new Worker(new NoLeave()) // policy plugged in
Contractor inherits a takeVacation() it can't honor — the chain is rigid.
Worker has-a LeavePolicy; swap PaidLeave ↔ NoLeave freely.
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.
Every module knows every other — one change ripples everywhere.
Modules depend on shared interfaces — change one in isolation.
Split jobs that change for different reasons. An Invoice shouldn't also render PDFs and talk to the database.
class Report { calculate() {...} toPDF() {...} // formatting save() {...} // persistence }
class Report { calculate() {...} } class PdfRenderer { render(r) {...} } class ReportRepo { save(r) {...} }
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.
fee(p) { switch(p.type) { case "card": return ... case "paypal": return ... } // touch this for every method }
interface Payment { fee(): number } class Card implements Payment {...} class Crypto implements Payment {...} // new method = new file, zero edits
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:
takeVacation() throwing).// 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
// 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.
Many small, role-based interfaces beat one fat one. A Robot shouldn't implement eat() just to satisfy Worker.
interface Worker { work(); eat(); sleep() } class Robot implements Worker { eat(){ /* …meaningless */ } }
interface Workable { work() } interface Feedable { eat() } class Robot implements Workable {...} class Human implements Workable, Feedable {}
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.
class OrderService { db = new MySqlDatabase() // hard-wired } // can't test without a real MySQL
class OrderService { constructor(private db: Database) {} } new OrderService(new Postgres()) new OrderService(new FakeDb()) // testable
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.
The same shape solves the same problem in the next project — you stop re-inventing it.
Each pattern separates a concern (creation, composition, coordination) so classes stay small.
Change lands in one place instead of rippling through a pile of if/else.
New payment types, new renderers, new handlers — added by writing new classes, not editing old ones.
“Observer” or “Facade” says in one word what a paragraph would.
Reviews, design docs and hand-overs get shorter when the names are shared.
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.
Start from the pain, not the pattern: the symptom tells you the family, the family tells you the two or three candidates.
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.
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.
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).
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.
switch is simpler and just as clear.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).
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.
Take it home — a runnable project with a README and exercises: TypeScript sample (5 KB) · Python sample (5 KB).
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.
build() can leak state between objects. In TS and Python, an options object or keyword arguments with defaults often does the job.@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).
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.
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).
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.
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.
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).
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.
if (renderer instanceof …)), the bridge has already leaked.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).
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.
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.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).
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.
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.@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).
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.
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).
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.
Take it home — a runnable project with a README and exercises: TypeScript sample (3 KB) · Python sample (2 KB).
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.
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).
These patterns move the decision about who does what next out of tangled method bodies and into small, swappable objects.
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.
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).
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.
Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).
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.
hasNext()/next() is rarely needed today — most languages ship the protocol. Also decide what happens if the collection changes mid-walk.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).
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.
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).
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.
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).
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.
switch (status) blocks are multiplying.Take it home — a runnable project with a README and exercises: TypeScript sample (2 KB) · Python sample (2 KB).
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.
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).
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.
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).
Two patterns you'll meet in every backend codebase that aren't in the 1994 catalogue but earn their place next to it.
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.
// 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.
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.
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).
The core defines ports (interfaces); adapters implement them. Drive it from a test, a CLI, or HTTP — the domain never knows which.
The Dependency Rule: source dependencies only point inward. Entities know nothing of DB or web. Maximum testability — at the cost of more layers.
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.
Every piece of knowledge has one home. But beware: duplicate code ≠ duplicate knowledge — don't couple two things just because they look alike today.
The simplest design that meets the requirement wins. Clever is a liability when someone else debugs it at 2 a.m.
Don't build for imagined futures. Add abstraction when the second real case arrives — not before.
One module, one concern. The macro version of Single Responsibility — why layers and ports exist.
If it's hard to test, it's badly coupled. Test pain is a design smell, not a testing problem. DI makes it easy.
DRY vs KISS, abstraction vs YAGNI — these pull against each other. Judgment is knowing which to favor here.
"Make the change easy, then make the easy change."
— Kent Beck
Five quick questions on OOP, SOLID, composition, patterns, and architecture — instant feedback, no sign-in.
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.
One standalone Node project per pattern: the code behind every snippet above, each with a README, run steps (tsc + node, no dependencies) and exercises.
The same 20 samples ported to idiomatic Python 3.10+ (ABCs, Protocols, dataclasses) — one folder per pattern, README included, no dependencies.
The slide-style diagrams for all 20 patterns: the real-world scenario behind each sample, plus the Observer-vs-Mediator comparison.
One page per principle with the analogy and a before/after code comparison, matching section 04.
Navigate with ← → or scroll · back to library