Design Patterns & Principles

A practical reference for software design patterns and clean code principles, with TypeScript and JavaScript examples.

What's Inside

SectionTopics
Design PrinciplesKISS, DRY, YAGNI, Separation of Concerns
SOLID PrinciplesSRP, OCP, LSP, ISP, DIP
Design PatternsBehavioral, Structural, Creational
Clean CodeNaming, functions, readability

How to Use

Browse the chapters in the sidebar. Each section includes explanations, diagrams, and code examples.

Keep It Simple Stupide

The idea is straightforward: keep your code simple and avoid unnecessary complexity.

  • Is easy to read and understand.
  • Minimizes the number of moving parts (functions, classes, methods).
  • Avoids overly clever solutions that might confuse future developers. The goal is not sacrificing functionality but designing the simplest solution possible without compromising performance or usability.

why KISS Matters?

  • matainbility
  • debugging
  • readabiltiy
  • scalability

when to break kiss?

  • Performance Requirements
  • Future Scalability
  • Security Requirements
  • Error Handling in Critical Systems

Dont Repeate youself:

aimed at reducing redundancy and improving code maintainability. Every piece of knowledge should have a single, authoritative representation in your system. Don't duplicate logic, data, or intent.

why DRY matters?

  • code resuability: make resuable function for repetative task
  • maintainlibilty: make chages at single place
  • error reduction : make cahge in single place

Password Validation

when to break DRY?

Two pieces of code look similar but change for different reasons or at different speeds. Code looks similar today but represents different concepts that will diverge. Premature Abstraction: Readability Issues: Different Concepts:

You Aren’t Gonna Need It

  • Don’t write code unless it’s required now.
  • Avoid solving problems that don’t yet exist.
  • Focus on delivering the simplest solution for today.

why yagni matters

  • save time
  • reduce complexcity
  • prevent overengineering
  • encourage itrative development Eg.
// Overengineered:
interface User {
  id: number
  name: string
  address?: {
    street: string
    city: string
  }
}
// Keep it simple:
interface User {
  id: number
  name: string
}

when to break yagni

  • Performance Requirements
  • Future Scalability
  • Security Requirements
  • Error Handling in Critical Systems

If the cost of not doing it now outweighs doing it later, it might make sense to break YAGNI.

YAGNI Guidelines for Utility Libraries ✅ DO Build: Current Requirements Only Extension Points (Not Extensions) Hooks/callbacks for customization Composition over configuration Most common use case = zero config

Clear, Minimal API

Small surface area Each method has clear purpose

Separation of Concerns (SoC) is a fundamental design principle in software engineering where you organize code by dividing a program into distinct sections, each handling a specific responsibility or "concern." code idea is each part in system focuse one thing. This makes code easier to understand, maintain, test, and modify because changes to one concern don't ripple through unrelated parts of the system.

SOLID Principles

SOLID is an acronym for five object-oriented design principles that make software easier to understand, maintain, and extend.

LetterPrincipleOne-liner
SSingle ResponsibilityA class should have only one reason to change
OOpen / ClosedOpen for extension, closed for modification
LLiskov SubstitutionSubtypes must be substitutable for their base types
IInterface SegregationClients should not depend on interfaces they don't use
DDependency InversionDepend on abstractions, not concretions

Single Responsibility Principle (SRP)

A class should have only one reason to change — it should have only one job or responsibility.

Problem

A single OrderService that persists data, charges payment, and sends email is responsible for three unrelated concerns. Three different teams may need to touch the same file, causing merge conflicts and bugs.

// ❌ Too many responsibilities
class OrderService {
  async place(order) {
    // 1. Persist order
    // 2. Charge payment
    // 3. Send confirmation email
  }
}

Solution

Split into focused classes, each with a single job:

class OrderRepository {
  async save(order: string) { /* DB logic */ }
}

class PaymentGateway {
  async charge(order: string) { /* Billing API */ }
}

class EmailNotifier {
  async send(order: string) { /* SMTP logic */ }
}

class OrderService {
  constructor(
    private repo: OrderRepository,
    private gateway: PaymentGateway,
    private notifier: EmailNotifier
  ) {}

  async place(order: string) {
    await this.repo.save(order);
    await this.gateway.charge(order);
    await this.notifier.send(order);
  }
}

Key Insight

SRP reduces the blast radius of changes. When payroll logic changes, only SalaryCalculator is touched — not the employee data class or the email notifier.

Real-world Example

An Employee system split into:

  • EmployeeData — just data
  • SalaryCalculator — payroll logic
  • EmployeeRepository — database access
  • NotificationService — emails

Open / Closed Principle (OCP)

Software entities should be open for extension, but closed for modification.

Add new behavior by extending — not by editing working code.

Problem

Every new discount tier requires touching (and potentially breaking) the existing switch statement:

// ❌ Closed for extension, open to breakage
class DiscountCalculator {
  get(price: number, tier: string) {
    switch (tier) {
      case 'silver':   return price * 0.95;
      case 'gold':     return price * 0.9;
      case 'platinum': return price * 0.85;
      default: return price;
    }
  }
}

Solution

Define a strategy interface; add new tiers by adding new classes:

interface DiscountStrategy {
  apply(price: number): number;
}

class SilverDiscount implements DiscountStrategy {
  apply(price: number) { return price * 0.95; }
}

class GoldDiscount implements DiscountStrategy {
  apply(price: number) { return price * 0.9; }
}

// Adding platinum requires ZERO changes to existing code:
class PlatinumDiscount implements DiscountStrategy {
  apply(price: number) { return price * 0.85; }
}

class Checkout {
  constructor(private strategy: DiscountStrategy) {}
  total(price: number) { return this.strategy.apply(price); }
}

const checkout = new Checkout(new GoldDiscount());
console.log(checkout.total(100)); // 90

Key Insight

Modification is dangerous, extension is safe. In production systems, touching working code is the #1 source of regressions.

Liskov Substitution Principle (LSP)

If a function works for a base type, it must work for any derived type — without the caller knowing.

Problem

Inheriting Rectangle into Square looks natural but breaks the contract — setting width also silently changes height:

// ❌ Square breaks Rectangle's contract
class Rectangle {
  setWidth(w: number)  { this.w = w; }
  setHeight(h: number) { this.h = h; }
  area() { return this.w * this.h; }
}

class Square extends Rectangle {
  setWidth(n: number)  { this.w = this.h = n; } // side effect!
  setHeight(n: number) { this.w = this.h = n; } // side effect!
}

Solution

Give both shapes a common Shape interface instead of forcing inheritance:

interface Shape {
  area(): number;
}

class Rectangle implements Shape {
  constructor(private w: number, private h: number) {}
  area() { return this.w * this.h; }
}

class Square implements Shape {
  constructor(private side: number) {}
  area() { return this.side * this.side; }
}

// Any code that accepts Shape works correctly with both:
function printArea(shape: Shape) {
  console.log(shape.area());
}

Key Insight

If a child changes the contract of its parent, code that works with the parent will break unpredictably with the child.

Real-world Example

  • PaymentProcessor base → CreditCardProcessor, GiftCardProcessor
  • Both must honour the same charge() contract

Interface Segregation Principle (ISP)

Clients should not be forced to depend upon interfaces they do not use.

Split large interfaces into smaller, focused ones.

Problem

A BasicPlayer is forced to implement record and stream it doesn't support:

// ❌ Fat interface
interface MediaPlayer {
  play(file: string): void;
  record(source: string): void;
  stream(url: string): void;
}

class BasicPlayer implements MediaPlayer {
  play(file: string) { /* ok */ }
  record() { throw new Error('Not supported'); } // forced!
  stream() { throw new Error('Not supported'); } // forced!
}

Solution

Split into role-specific interfaces:

interface Playable   { play(file: string): void; }
interface Recordable { record(source: string): void; }
interface Streamable { stream(url: string): void; }

class BasicPlayer implements Playable {
  play(file: string) { /* ... */ }
}

class ProRecorder implements Playable, Recordable {
  play(file: string)   { /* ... */ }
  record(src: string)  { /* ... */ }
}

Key Insight

Each class implements only what it physically supports. No more throwing NotSupportedException from required methods.

Real-world Example

Printer management:

  • Printable, Scannable, Faxable, Stapleable
  • An inkjet implements Printable + Scannable; a laser-only printer just Printable

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Problem

UserController is hardwired to FileLogger. Swapping to a console or mock logger requires changing the controller:

// ❌ High-level depends on low-level concrete
class FileLogger {
  log(msg: string) { /* write to file */ }
}

class UserController {
  constructor() { this.logger = new FileLogger(); } // tightly coupled
  save(user: { name: string }) { this.logger.log(`Saved ${user.name}`); }
}

Solution

Depend on a Logger abstraction; inject the implementation:

interface Logger {
  log(msg: string): void;
}

class ConsoleLogger implements Logger {
  log(msg: string) { console.log(msg); }
}

class UserController {
  constructor(private logger: Logger) {}
  save(user: { name: string }) { this.logger.log(`Saved ${user.name}`); }
}

// Wiring — swap implementations without touching UserController:
const controller      = new UserController(new ConsoleLogger());
// const fileController  = new UserController(new FileLogger());
// const testController  = new UserController(new MockLogger());

Architecture Diagram

┌─────────────────┐
│ UserController  │  ← High-level
└────────┬────────┘
         │ depends on abstraction
         ↓
┌──────────────────────┐
│  Logger (interface)  │  ← Abstraction
└────────┬─────────────┘
         ↑ implements
┌─────────────────┐
│  ConsoleLogger  │  ← Low-level
└─────────────────┘

Key Insight

Without DIP, swapping a low-level detail (e.g. database vendor, email provider) ripples through your entire codebase. DIP isolates the blast radius to a single wiring point.

Real-world Examples

  • EmailService interface → SendGridEmailService, SESEmailService
  • FileStorage interface → S3Storage, AzureBlobStorage
  • PaymentProcessor interface → StripeProcessor, PayPalProcessor

Behavioral Pattern

Patterns that focus on communication between objects and the assignment of responsibilities between objects.

Structural Design Pattern :

Patterns that deal with object composition and identify simple ways have relationships between different objects

Adapter

  • Allows objects with incompatible interfaces to work together by wrapping an object with an interface that the client expects.
---
config:
  look: handDrawn
  theme: redux
  layout: elk
---
classDiagram
direction BT
    class OldPaymentSystem {
	    +makePayment(amount: number) Object
    }

    class StripPaymentService {
	    +chargeCard(cardNumber: number, amount: number, currency: string) Object
    }

    class ModernPaymentInterface {
	    +paymentProcess(paymentDetails: Object) Object
    }

    class OldPaymentAdapter {
	    +oldSystem: OldPaymentSystem
	    +constructor()
	    +paymentProcess(paymentDetails: Object) Object
    }

    class StripAdapter {
	    +strip: StripPaymentService
	    +constructor()
	    +paymentProcess(paymentDetails: Object) Object
    }

    class PaymentProcess {
	    -adapter: ModernPaymentInterface
	    +constructor(adapter: ModernPaymentInterface)
	    +process(paymentDetails: Object) Object
    }

	<<abstract>> ModernPaymentInterface

	note for OldPaymentSystem "Legacy payment system\nwith different interface"
	note for StripPaymentService "Third-party service\nwith incompatible interface"
	note for ModernPaymentInterface "Target interface that\nclient expects"
	note for OldPaymentAdapter "Adapter: Converts\nOldPaymentSystem to\nModernPaymentInterface"
	note for StripAdapter "Adapter: Converts\nStripPaymentService to\nModernPaymentInterface"
	note for PaymentProcess "Client: Works with\nModernPaymentInterface\nwithout knowing adaptees"

    ModernPaymentInterface <|-- OldPaymentAdapter : implements
    ModernPaymentInterface <|-- StripAdapter : implements
    OldPaymentAdapter o-- OldPaymentSystem : adapts
    StripAdapter o-- StripPaymentService : adapts
    PaymentProcess o-- ModernPaymentInterface : uses

Creational Patterns

Patterns that deal with object creation mechanisms, aiming to create objects in a manner suitable to the situation.

Singleton

Only one instance of a class should exist throughout the application lifecycle.

class Singleton {
  static #instance: Singleton | null = null;

  private constructor() {}

  static getInstance(): Singleton {
    if (!Singleton.#instance) {
      Singleton.#instance = new Singleton();
    }
    return Singleton.#instance;
  }
}

const a = Singleton.getInstance();
const b = Singleton.getInstance();
console.log(a === b); // true

Use for: database connection pools, config managers, loggers.


Factory

A factory method creates instances based on input — the caller doesn't need to know the concrete class.

class VehicleFactory {
  static create(type: string, brand: string, model: string): Vehicle {
    switch (type) {
      case 'car':        return new Car(type, brand, model);
      case 'truck':      return new Truck(type, brand, model);
      case 'motorcycle': return new Motorcycle(type, brand, model);
      default: throw new Error(`Unknown type: ${type}`);
    }
  }
}

const car = VehicleFactory.create('car', 'Toyota', 'Corolla');

Use for: UI component libraries, payment processors, notification channels.


Builder

Builder provides flexibility to construct complex objects step-by-step, supporting optional parts and different configurations.

class ComputerBuilder {
  private computer = new Computer();

  addCPU(cpu: string): this {
    this.computer.addPart('CPU', cpu);
    return this;
  }
  addRAM(ram: string): this {
    this.computer.addPart('RAM', ram);
    return this;
  }
  addGPU(gpu: string): this {
    this.computer.addPart('GPU', gpu);
    return this;
  }
  build(): Computer {
    return this.computer;
  }
}

const gaming = new ComputerBuilder()
  .addCPU('i9-13900K')
  .addRAM('64GB DDR5')
  .addGPU('RTX 4090')
  .build();

Use for: query builders, HTTP request builders, test fixtures.


Prototype

Create new objects by cloning an existing instance rather than constructing from scratch.

class Shape {
  constructor(public color: string, public x: number, public y: number) {}

  clone(): Shape {
    return new Shape(this.color, this.x, this.y);
  }
}

const original = new Shape('red', 10, 20);
const copy = original.clone();
copy.color = 'blue'; // original is unchanged

Use for: expensive-to-create objects, game entities, document templates.


Abstract Factory

Produce families of related objects without specifying their concrete classes.

interface Button  { render(): void; }
interface Checkbox { check(): void; }

interface UIFactory {
  createButton(): Button;
  createCheckbox(): Checkbox;
}

class WindowsFactory implements UIFactory {
  createButton()   { return new WindowsButton(); }
  createCheckbox() { return new WindowsCheckbox(); }
}

class MacFactory implements UIFactory {
  createButton()   { return new MacButton(); }
  createCheckbox() { return new MacCheckbox(); }
}

Use for: cross-platform UI toolkits, themed component libraries, database drivers.

clean code

  • no dublication