Design Patterns & Principles
A practical reference for software design patterns and clean code principles, with TypeScript and JavaScript examples.
What's Inside
| Section | Topics |
|---|---|
| Design Principles | KISS, DRY, YAGNI, Separation of Concerns |
| SOLID Principles | SRP, OCP, LSP, ISP, DIP |
| Design Patterns | Behavioral, Structural, Creational |
| Clean Code | Naming, 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.
| Letter | Principle | One-liner |
|---|---|---|
| S | Single Responsibility | A class should have only one reason to change |
| O | Open / Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be substitutable for their base types |
| I | Interface Segregation | Clients should not depend on interfaces they don't use |
| D | Dependency Inversion | Depend 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 dataSalaryCalculator— payroll logicEmployeeRepository— database accessNotificationService— 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
PaymentProcessorbase →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 justPrintable
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
EmailServiceinterface →SendGridEmailService,SESEmailServiceFileStorageinterface →S3Storage,AzureBlobStoragePaymentProcessorinterface →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