Senior Engineer's Guide

TypeScript
Design Patterns

From OOP fundamentals to battle-tested patterns โ€” taught with real-world examples by a senior engineer who's actually shipped these in production.

OOP Principles Creational Structural Behavioral Project Exercises
01 โ€” Foundation

Object-Oriented
Programming

The four pillars of OOP are the grammar of software design. Master these and patterns become obvious.

๐Ÿ”’
Encapsulation
Hide what doesn't need to be seen. Expose only what matters.
Real world A car's engine is encapsulated. You get a steering wheel, pedals, and a gear stick. You don't get access to the fuel injectors, pistons, or timing belts. That's a good API.
TypeScript
// โŒ Bad: Everything is public. Anyone can mess with balance directly.
class BadBankAccount {
  balance: number = 0;
  pin: string = "1234"; // ๐Ÿ’€ exposed PIN!
}

// โœ… Good: Encapsulate state. Only expose safe operations.
class BankAccount {
  private balance: number;
  private pin: string;
  private transactionHistory: string[] = [];

  constructor(initialBalance: number, pin: string) {
    this.balance = initialBalance;
    this.pin = pin;
  }

  deposit(amount: number): void {
    if (amount <= 0) throw new Error("Deposit must be positive");
    this.balance += amount;
    this.logTransaction(`Deposited โ‚น${amount}`);
  }

  withdraw(amount: number, pin: string): void {
    if (pin !== this.pin) throw new Error("Wrong PIN");
    if (amount > this.balance) throw new Error("Insufficient funds");
    this.balance -= amount;
    this.logTransaction(`Withdrew โ‚น${amount}`);
  }

  getBalance(): number {
    return this.balance; // read-only access
  }

  private logTransaction(msg: string): void {
    this.transactionHistory.push(`[${new Date().toISOString()}] ${msg}`);
  }
}

const account = new BankAccount(10000, "9876");
account.deposit(5000);
account.withdraw(2000, "9876");
console.log(account.getBalance()); // 13000
// account.pin โ†’ TS Error: 'pin' is private โœ…
๐ŸŽญ
Abstraction
Simplify complexity. Show the what, hide the how.
Real world A TV remote is an abstraction over hundreds of circuit signals. You press "Volume Up" โ€” you don't send PWM signals to the IR LED yourself.
TypeScript
// Abstract class defines the "what" โ€” enforces structure
abstract class PaymentProcessor {
  // Template method โ€” orchestrates the algorithm
  processPayment(amount: number): boolean {
    this.validateAmount(amount);
    const authorized = this.authorize(amount);
    if (authorized) this.chargeCustomer(amount);
    this.sendReceipt(amount);
    return authorized;
  }

  private validateAmount(amount: number): void {
    if (amount <= 0) throw new Error("Invalid amount");
  }

  // The "how" is deferred to subclasses
  protected abstract authorize(amount: number): boolean;
  protected abstract chargeCustomer(amount: number): void;
  protected abstract sendReceipt(amount: number): void;
}

class RazorpayProcessor extends PaymentProcessor {
  protected authorize(amount: number): boolean {
    console.log(`Razorpay: Authorizing โ‚น${amount}...`);
    return true; // calls Razorpay API internally
  }
  protected chargeCustomer(amount: number): void {
    console.log(`Razorpay: Charging โ‚น${amount}`);
  }
  protected sendReceipt(amount: number): void {
    console.log(`Razorpay: SMS receipt for โ‚น${amount}`);
  }
}

const processor = new RazorpayProcessor();
processor.processPayment(1999); // caller doesn't care HOW it works
๐Ÿงฌ
Inheritance
Reuse and extend. Build hierarchies of specialized behavior.
Real world A Notification system: all notifications have a title, body, and timestamp. Push notifications additionally have a device token. Email notifications have a recipient address. Inheritance handles this naturally.
TypeScript
class Notification {
  constructor(
    protected title: string,
    protected body: string,
    private readonly timestamp: Date = new Date()
  ) {}

  format(): string {
    return `[${this.timestamp.toLocaleTimeString()}] ${this.title}: ${this.body}`;
  }

  send(): void {
    console.log(`Sending: ${this.format()}`);
  }
}

class PushNotification extends Notification {
  constructor(title: string, body: string, private deviceToken: string) {
    super(title, body); // must call parent constructor
  }

  send(): void {
    console.log(`[PUSH โ†’ ${this.deviceToken}] ${this.format()}`);
    // calls FCM / APNS here...
  }
}

class EmailNotification extends Notification {
  constructor(title: string, body: string, private email: string) {
    super(title, body);
  }

  send(): void {
    console.log(`[EMAIL โ†’ ${this.email}] ${this.format()}`);
    // calls SendGrid / SES here...
  }
}

// Polymorphism in action โ€” same interface, different behavior
const notifications: Notification[] = [
  new PushNotification("Order Shipped", "Your order is on the way!", "tok_abc123"),
  new EmailNotification("Order Shipped", "Your order is on the way!", "user@example.com"),
];

notifications.forEach(n => n.send());
๐Ÿ”€
Polymorphism
One interface, many forms. Write code that works for things not yet invented.
Real world A media player has a "play()" button. It plays MP3s, MP4s, Spotify streams, YouTube videos โ€” all with the same button. The player is written once; new formats plug in via the same interface.
TypeScript
// Interface = contract (the "play button")
interface StorageProvider {
  upload(file: File): Promise<string>;
  download(key: string): Promise<Buffer>;
  delete(key: string): Promise<void>;
}

class S3Provider implements StorageProvider {
  async upload(file: File): Promise<string> {
    console.log("Uploading to AWS S3...");
    return `s3://my-bucket/${file.name}`;
  }
  async download(key: string): Promise<Buffer> { return Buffer.from(""); }
  async delete(key: string): Promise<void> { console.log(`S3: Deleted ${key}`); }
}

class GCSProvider implements StorageProvider {
  async upload(file: File): Promise<string> {
    console.log("Uploading to Google Cloud Storage...");
    return `gs://my-bucket/${file.name}`;
  }
  async download(key: string): Promise<Buffer> { return Buffer.from(""); }
  async delete(key: string): Promise<void> { console.log(`GCS: Deleted ${key}`); }
}

// FileService doesn't know or care WHICH provider it gets
class FileService {
  constructor(private storage: StorageProvider) {}

  async saveUserAvatar(file: File): Promise<string> {
    console.log("Validating file type...");
    return this.storage.upload(file); // polymorphic call
  }
}

// Swap providers without touching FileService
const devService = new FileService(new GCSProvider());
const prodService = new FileService(new S3Provider());
โšก Senior Engineer Insight Prefer interfaces over abstract classes for polymorphism in TypeScript. Interfaces are structural โ€” TypeScript uses duck typing, meaning any class with matching methods satisfies the interface. This gives you flexibility without forced inheritance hierarchies.
02 โ€” Creational Patterns

How Objects
Come to Life

Creational patterns control object creation โ€” making systems independent of how their objects are created, composed, and represented.

โ–ธ Singleton
Real world A database connection pool. You don't want 100 requests creating 100 separate database connections. One pool, shared everywhere. Also: Logger, Config manager, EventBus.
TypeScript โ€” Singleton Pattern
class DatabaseConnection {
  private static instance: DatabaseConnection | null = null;
  private connectionCount = 0;

  // Private constructor โ€” no one can call `new DatabaseConnection()`
  private constructor(private connectionString: string) {
    console.log(`๐Ÿ”Œ DB connected: ${this.connectionString}`);
  }

  // The ONLY way to get a DatabaseConnection
  static getInstance(connectionString: string): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection(connectionString);
    }
    return DatabaseConnection.instance;
  }

  query(sql: string): string {
    this.connectionCount++;
    return `Result of: ${sql} (query #${this.connectionCount})`;
  }
}

// Across your entire app, these all return the SAME instance
const db1 = DatabaseConnection.getInstance("postgres://localhost/mydb");
const db2 = DatabaseConnection.getInstance("postgres://localhost/mydb");
console.log(db1 === db2); // true โœ…

db1.query("SELECT * FROM users");
db2.query("SELECT * FROM orders"); // query #2 on same connection
โš ๏ธ When to avoid Singleton Singletons make testing hard โ€” they carry state between tests. In Node.js apps, prefer module-level exports (Node caches modules) or dependency injection containers like tsyringe.
โ–ธ Factory Method
Real world A logistics company has a method createTransport(). For local orders it creates Trucks. For international orders it creates Ships. For urgent ones, Planes. Callers just call createTransport().
TypeScript โ€” Factory Method Pattern
interface Logger {
  log(message: string): void;
  error(message: string): void;
}

class ConsoleLogger implements Logger {
  log(msg: string) { console.log(`[CONSOLE] โ„น ${msg}`); }
  error(msg: string) { console.error(`[CONSOLE] โŒ ${msg}`); }
}

class FileLogger implements Logger {
  log(msg: string) { console.log(`[FILE] Writing: ${msg}`); }
  error(msg: string) { console.log(`[FILE] Error: ${msg}`); }
}

class CloudLogger implements Logger {
  log(msg: string) { console.log(`[DATADOG] Sending metric: ${msg}`); }
  error(msg: string) { console.log(`[DATADOG] Alert: ${msg}`); }
}

// Factory โ€” single place to decide which logger to create
type LoggerType = "console" | "file" | "cloud";

function createLogger(type: LoggerType): Logger {
  const factories: Record<LoggerType, () => Logger> = {
    console: () => new ConsoleLogger(),
    file:    () => new FileLogger(),
    cloud:   () => new CloudLogger(),
  };
  return factories[type]();
}

// Config-driven โ€” switch logger without changing callers
const env = process.env.NODE_ENV;
const logger = createLogger(env === "production" ? "cloud" : "console");
logger.log("App started");
logger.error("Something went wrong");
โ–ธ Builder
Real world Ordering a Subway sandwich โ€” you build it step by step: bread โ†’ protein โ†’ cheese โ†’ vegetables โ†’ sauce. Each step is optional, order matters, and you only finalize at checkout. A Query Builder works identically.
TypeScript โ€” Builder Pattern
interface HttpRequestConfig {
  url: string;
  method: string;
  headers: Record<string, string>;
  body?: unknown;
  timeout?: number;
  retries?: number;
}

class HttpRequestBuilder {
  private config: Partial<HttpRequestConfig> = {
    method: "GET",
    headers: {},
  };

  url(url: string): this {
    this.config.url = url;
    return this; // return this for chaining
  }

  method(method: "GET" | "POST" | "PUT" | "DELETE"): this {
    this.config.method = method;
    return this;
  }

  header(key: string, value: string): this {
    this.config.headers![key] = value;
    return this;
  }

  bearerToken(token: string): this {
    return this.header("Authorization", `Bearer ${token}`);
  }

  body(data: unknown): this {
    this.config.body = data;
    return this.header("Content-Type", "application/json");
  }

  timeout(ms: number): this {
    this.config.timeout = ms;
    return this;
  }

  retries(count: number): this {
    this.config.retries = count;
    return this;
  }

  build(): HttpRequestConfig {
    if (!this.config.url) throw new Error("URL is required");
    return this.config as HttpRequestConfig;
  }
}

// Readable, fluent API โ€” like writing English
const request = new HttpRequestBuilder()
  .url("https://api.example.com/users")
  .method("POST")
  .bearerToken("eyJ...")
  .body({ name: "Ravi", email: "ravi@example.com" })
  .timeout(5000)
  .retries(3)
  .build();

console.log(request);
03 โ€” Structural Patterns

Composing
Larger Systems

Structural patterns deal with object composition โ€” creating relationships between objects to form larger structures.

โ–ธ Adapter
Real world Your MacBook only has USB-C ports. Your old USB-A device still works perfectly โ€” you use a dongle (adapter). In code: integrating a third-party library whose API doesn't match yours.
TypeScript โ€” Adapter Pattern
// What YOUR system expects
interface AnalyticsService {
  trackEvent(event: string, userId: string, props: object): void;
}

// What MIXPANEL actually provides (their SDK)
class MixpanelSDK {
  track(eventName: string, distinctId: string, metadata: object): void {
    console.log(`[Mixpanel] ${eventName} for ${distinctId}:`, metadata);
  }
}

// What AMPLITUDE actually provides
class AmplitudeSDK {
  logEvent(eventType: string, userIdentifier: string, eventProperties: object): void {
    console.log(`[Amplitude] ${eventType} for ${userIdentifier}:`, eventProperties);
  }
}

// ADAPTERS โ€” bridge the gap
class MixpanelAdapter implements AnalyticsService {
  constructor(private mixpanel: MixpanelSDK) {}

  trackEvent(event: string, userId: string, props: object): void {
    // Translate YOUR interface โ†’ Mixpanel's interface
    this.mixpanel.track(event, userId, props);
  }
}

class AmplitudeAdapter implements AnalyticsService {
  constructor(private amplitude: AmplitudeSDK) {}

  trackEvent(event: string, userId: string, props: object): void {
    this.amplitude.logEvent(event, userId, props);
  }
}

// Your app code never changes โ€” just swap the adapter
class UserService {
  constructor(private analytics: AnalyticsService) {}

  onUserSignup(userId: string): void {
    // No Mixpanel/Amplitude knowledge here
    this.analytics.trackEvent("user_signed_up", userId, { source: "organic" });
  }
}

const service = new UserService(new MixpanelAdapter(new MixpanelSDK()));
service.onUserSignup("user_42");
โ–ธ Decorator
Real world Coffee shop: base coffee โ†’ add milk โ†’ add caramel โ†’ add whipped cream. Each addition wraps the previous. The final price and description is computed by unwrapping each layer. Express.js middleware works exactly this way.
TypeScript โ€” Decorator Pattern
interface DataSource {
  write(data: string): void;
  read(): string;
}

// Concrete component
class FileDataSource implements DataSource {
  private data: string = "";
  write(data: string): void { this.data = data; }
  read(): string { return this.data; }
}

// Base decorator โ€” wraps a DataSource
abstract class DataSourceDecorator implements DataSource {
  constructor(protected wrapped: DataSource) {}
  write(data: string): void { this.wrapped.write(data); }
  read(): string { return this.wrapped.read(); }
}

// Concrete decorator 1: adds encryption
class EncryptionDecorator extends DataSourceDecorator {
  write(data: string): void {
    const encrypted = Buffer.from(data).toString("base64");
    console.log("๐Ÿ” Encrypting...");
    super.write(encrypted);
  }
  read(): string {
    const data = super.read();
    console.log("๐Ÿ”“ Decrypting...");
    return Buffer.from(data, "base64").toString();
  }
}

// Concrete decorator 2: adds compression
class CompressionDecorator extends DataSourceDecorator {
  write(data: string): void {
    console.log("๐Ÿ—œ Compressing...");
    super.write(`[compressed:${data}]`); // simplified
  }
  read(): string {
    const raw = super.read();
    console.log("๐Ÿ“ฆ Decompressing...");
    return raw.replace(/\[compressed:(.*)\]/, "$1");
  }
}

// Layer decorators โ€” file โ†’ encrypt โ†’ compress
const source = new CompressionDecorator(
  new EncryptionDecorator(
    new FileDataSource()
  )
);

source.write("user sensitive data");
console.log(source.read()); // "user sensitive data"
โ–ธ Facade
Real world When you click "Place Order" on Swiggy, you don't call the inventory service, payment gateway, delivery routing, and notification service separately. A single API facade orchestrates all of that for you.
TypeScript โ€” Facade Pattern
// Complex subsystems
class InventoryService {
  reserve(productId: string, qty: number): boolean {
    console.log(`Reserved ${qty}x ${productId}`); return true;
  }
}
class PaymentService {
  charge(userId: string, amount: number): boolean {
    console.log(`Charged โ‚น${amount} to ${userId}`); return true;
  }
}
class ShippingService {
  schedule(orderId: string, address: string): string {
    console.log(`Scheduled delivery for ${orderId}`);
    return `TRACK-${Date.now()}`;
  }
}
class NotificationService {
  sendOrderConfirmation(userId: string, orderId: string): void {
    console.log(`๐Ÿ“ง Confirmation sent to ${userId} for ${orderId}`);
  }
}

// FACADE โ€” simple interface over the complexity
class OrderFacade {
  private inventory = new InventoryService();
  private payment = new PaymentService();
  private shipping = new ShippingService();
  private notifications = new NotificationService();

  placeOrder(userId: string, productId: string, qty: number, address: string): string {
    const orderId = `ORD-${Date.now()}`;
    const amount = qty * 299; // simplified pricing

    if (!this.inventory.reserve(productId, qty)) throw new Error("Out of stock");
    if (!this.payment.charge(userId, amount)) throw new Error("Payment failed");
    const trackingId = this.shipping.schedule(orderId, address);
    this.notifications.sendOrderConfirmation(userId, orderId);

    return trackingId;
  }
}

// Client code is beautifully simple
const orders = new OrderFacade();
const tracking = orders.placeOrder("user_1", "iPhone15", 1, "MG Road, Bengaluru");
console.log(`Track your order: ${tracking}`);
04 โ€” Behavioral Patterns

Patterns of
Communication

Behavioral patterns are about algorithms and assignment of responsibilities between objects.

โ–ธ Observer (Event Emitter)
Real world YouTube subscriptions. When a channel uploads a video, all subscribers get notified. The channel doesn't know who its subscribers are โ€” it just broadcasts. Subscribers opt-in. This is the pub/sub model powering every UI framework.
TypeScript โ€” Observer Pattern
type EventHandler<T> = (data: T) => void;

class EventEmitter<Events extends Record<string, unknown>> {
  private listeners = new Map<string, Set<EventHandler<unknown>>>();

  on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): void {
    if (!this.listeners.has(event as string)) {
      this.listeners.set(event as string, new Set());
    }
    this.listeners.get(event as string)!.add(handler as EventHandler<unknown>);
  }

  off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): void {
    this.listeners.get(event as string)?.delete(handler as EventHandler<unknown>);
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.listeners.get(event as string)?.forEach(handler => handler(data));
  }
}

// Strongly typed events โ€” no magic strings
interface OrderEvents {
  "order:created": { orderId: string; userId: string; amount: number };
  "order:shipped": { orderId: string; trackingId: string };
  "order:cancelled": { orderId: string; reason: string };
}

const eventBus = new EventEmitter<OrderEvents>();

// Subscribers register independently
eventBus.on("order:created", ({ orderId, amount }) => {
  console.log(`๐Ÿ“ง Email: Order ${orderId} confirmed for โ‚น${amount}`);
});

eventBus.on("order:created", ({ orderId, userId }) => {
  console.log(`๐Ÿ“Š Analytics: New order ${orderId} from ${userId}`);
});

eventBus.on("order:shipped", ({ orderId, trackingId }) => {
  console.log(`๐Ÿ“ฆ Push: Your order ${orderId} is shipped! Track: ${trackingId}`);
});

// Publisher doesn't know WHO is listening
eventBus.emit("order:created", { orderId: "ORD-1", userId: "u_42", amount: 1299 });
โ–ธ Strategy
Real world Google Maps gives you route options: by car, by foot, by metro. The destination is the same โ€” the algorithm (strategy) for getting there differs. You can swap strategies at runtime. Same goes for sorting algorithms, discount engines, auth strategies.
TypeScript โ€” Strategy Pattern
// Strategy interface interface DiscountStrategy { calculate(price: number): number; readonly name: string; } // Concrete strategies class NoDiscount implements DiscountStrategy { readonly name = "No Discount"; calculate(price: number): number { return price; } } class PercentageDiscount implements DiscountStrategy { readonly name: string; constructor(private percent: number) { this.name = `${percent}% Off`; } calculate(price: number): number { return price * (1 - this.percent / 100); } } class BuyOneGetOneFree implements DiscountStrategy { readonly name = "Buy 1 Get 1 Free"; calculate(price: number): number { return price / 2; // half price per unit when buying 2 } } class FlatDiscount implements DiscountStrategy { readonly name: string; constructor(private amount: number) { this.name = `โ‚น${amount} Flat Off`; } calculate(price: number): number { return Math.max(0, price - this.amount); } } // Context โ€” uses a strategy class ShoppingCart { private strategy: DiscountStrategy = new NoDiscount(); setDiscountStrategy(strategy: DiscountStrategy): void { this.strategy = strategy; } checkout(price: number): void { const final = this.strategy.calculate(price); console.log(`[${this.strategy.name}] โ‚น${price} โ†’ โ‚น${final.toFixed(2)}`); } } const cart = new ShoppingCart(); // Swap strategies at runtime based on user segment cart.checkout(1000); // โ‚น1000 cart.setDiscountStrategy(new PercentageDiscount(20)); cart.checkout(1000); // โ‚น800 cart.setDiscountStrategy(new FlatDiscount(150)); cart.checkout(1000); // โ‚น850 cart.setDiscountStrategy(new BuyOneGetOneFree()); cart.checkout(1000); // โ‚น500
โ–ธ Chain of Responsibility
Real world A tech support ticket goes to Level 1 โ†’ if unresolved, Level 2 โ†’ if unresolved, Level 3. Express.js middleware is exactly this pattern. Each handler decides: handle it or pass it on.
TypeScript โ€” Chain of Responsibility
interface HttpRequest {
  path: string;
  method: string;
  headers: Record<string, string>;
  body?: unknown;
  user?: { id: string; role: string };
}

type NextFn = () => void;

abstract class Middleware {
  protected next: Middleware | null = null;

  setNext(middleware: Middleware): Middleware {
    this.next = middleware;
    return middleware; // allows chaining: a.setNext(b).setNext(c)
  }

  protected pass(req: HttpRequest): void {
    this.next?.handle(req);
  }

  abstract handle(req: HttpRequest): void;
}

class RateLimiterMiddleware extends Middleware {
  private requestCounts = new Map<string, number>();

  handle(req: HttpRequest): void {
    const ip = req.headers["x-forwarded-for"] || "unknown";
    const count = (this.requestCounts.get(ip) || 0) + 1;
    this.requestCounts.set(ip, count);

    if (count > 100) {
      console.log("๐Ÿšซ Rate limit exceeded"); return;
    }
    console.log("โœ… Rate limit OK");
    this.pass(req);
  }
}

class AuthMiddleware extends Middleware {
  handle(req: HttpRequest): void {
    const token = req.headers["authorization"];
    if (!token?.startsWith("Bearer ")) {
      console.log("๐Ÿšซ Unauthorized"); return;
    }
    req.user = { id: "user_1", role: "admin" }; // attach user to request
    console.log("โœ… Authenticated");
    this.pass(req);
  }
}

class LoggingMiddleware extends Middleware {
  handle(req: HttpRequest): void {
    console.log(`๐Ÿ“ ${req.method} ${req.path} by ${req.user?.id}`);
    this.pass(req);
  }
}

class RouteHandler extends Middleware {
  handle(req: HttpRequest): void {
    console.log(`๐ŸŽฏ Handling ${req.path}...`);
  }
}

// Wire up the chain
const rateLimiter = new RateLimiterMiddleware();
rateLimiter
  .setNext(new AuthMiddleware())
  .setNext(new LoggingMiddleware())
  .setNext(new RouteHandler());

// Every incoming request runs through the chain
rateLimiter.handle({
  path: "/api/orders", method: "GET",
  headers: { authorization: "Bearer eyJ...", "x-forwarded-for": "1.2.3.4" }
});
05 โ€” Capstone Project

E-Commerce
Order System

A production-grade order processing system that uses every pattern we've covered. This is how real systems look.

๐Ÿ›’ OrderFlow Engine

An order processing system modelling Flipkart/Amazon's core flow: authentication, order creation, payment processing, notifications, and analytics โ€” all using design patterns.

Singleton โ†’ Config & DB Factory โ†’ Payment providers Builder โ†’ Order construction Adapter โ†’ Analytics SDKs Facade โ†’ Order orchestration Observer โ†’ Event bus Strategy โ†’ Discounts Chain โ†’ Middleware
order-system.ts โ€” Full System Integration
// ============================================================
// OrderFlow Engine โ€” uses ALL patterns in one cohesive system
// ============================================================

// โ”€โ”€ SINGLETON: App Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class AppConfig {
  private static instance: AppConfig;
  readonly env: string;
  readonly paymentGateway: string;
  readonly analyticsProvider: string;

  private constructor() {
    this.env = process.env.NODE_ENV || "development";
    this.paymentGateway = process.env.PAYMENT_GW || "razorpay";
    this.analyticsProvider = process.env.ANALYTICS || "mixpanel";
  }

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

// โ”€โ”€ BUILDER: Order โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
interface OrderItem { productId: string; qty: number; price: number; }
interface Order {
  id: string; userId: string; items: OrderItem[];
  shippingAddress: string; totalAmount: number; couponCode?: string;
}

class OrderBuilder {
  private order: Partial<Order> = { items: [] };

  forUser(userId: string): this { this.order.userId = userId; return this; }
  addItem(item: OrderItem): this { this.order.items!.push(item); return this; }
  shipTo(address: string): this { this.order.shippingAddress = address; return this; }
  applyCoupon(code: string): this { this.order.couponCode = code; return this; }

  build(): Order {
    if (!this.order.userId || !this.order.shippingAddress)
      throw new Error("Order requires userId and shippingAddress");
    if (this.order.items!.length === 0)
      throw new Error("Order must have at least one item");

    this.order.id = `ORD-${Date.now()}`;
    this.order.totalAmount = this.order.items!.reduce((sum, i) => sum + i.price * i.qty, 0);
    return this.order as Order;
  }
}

// โ”€โ”€ FACTORY: Payment Gateway โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
interface PaymentGateway {
  charge(userId: string, amount: number): Promise<string>;
}

class RazorpayGateway implements PaymentGateway {
  async charge(userId: string, amount: number): Promise<string> {
    console.log(`[Razorpay] Charging โ‚น${amount}`);
    return `rzp_${Date.now()}`;
  }
}
class StripeGateway implements PaymentGateway {
  async charge(userId: string, amount: number): Promise<string> {
    console.log(`[Stripe] Charging $${amount}`);
    return `pi_${Date.now()}`;
  }
}

function createPaymentGateway(type: string): PaymentGateway {
  if (type === "stripe") return new StripeGateway();
  return new RazorpayGateway(); // default
}

// โ”€โ”€ OBSERVER: Event Bus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
interface SystemEvents {
  "order:placed": Order;
  "payment:completed": { orderId: string; txnId: string };
  "order:failed": { orderId: string; reason: string };
}

class EventBus {
  private static instance = new EventBus(); // Singleton + Observer
  private subs = new Map<string, Function[]>();

  static get() { return this.instance; }

  on<K extends keyof SystemEvents>(e: K, fn: (d: SystemEvents[K]) => void): void {
    (this.subs.get(e as string) || this.subs.set(e as string, []).get(e as string)!).push(fn);
  }

  emit<K extends keyof SystemEvents>(e: K, data: SystemEvents[K]): void {
    this.subs.get(e as string)?.forEach(fn => fn(data));
  }
}

// โ”€โ”€ STRATEGY: Discounts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
interface DiscountStrategy { apply(amount: number): number; }
class NoDiscount implements DiscountStrategy { apply(a: number) { return a; } }
class CouponDiscount implements DiscountStrategy {
  private coupons: Record<string, number> = { SAVE10: 10, SALE20: 20, FLAT50: 50 };
  constructor(private code: string) {}
  apply(amount: number): number {
    const pct = this.coupons[this.code] || 0;
    return amount * (1 - pct / 100);
  }
}

// โ”€โ”€ FACADE: Order Orchestrator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class OrderService {
  private config = AppConfig.getInstance();
  private gateway = createPaymentGateway(this.config.paymentGateway);
  private events = EventBus.get();

  async placeOrder(order: Order): Promise<void> {
    console.log(`\n๐Ÿ›’ Processing order ${order.id}...`);

    // Strategy: pick discount
    const discount: DiscountStrategy = order.couponCode
      ? new CouponDiscount(order.couponCode)
      : new NoDiscount();
    const finalAmount = discount.apply(order.totalAmount);

    try {
      // Factory: payment gateway handles the charge
      const txnId = await this.gateway.charge(order.userId, finalAmount);

      // Observer: broadcast success to all listeners
      this.events.emit("order:placed", order);
      this.events.emit("payment:completed", { orderId: order.id, txnId });

      console.log(`โœ… Order ${order.id} complete! Txn: ${txnId}`);
    } catch (err: unknown) {
      const reason = err instanceof Error ? err.message : "Unknown error";
      this.events.emit("order:failed", { orderId: order.id, reason });
      throw err;
    }
  }
}

// โ”€โ”€ WIRE IT ALL TOGETHER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function main() {
  const bus = EventBus.get();

  // Register event listeners (Observer)
  bus.on("order:placed", (order) =>
    console.log(`๐Ÿ“ฆ Inventory: Reserve items for ${order.id}`));
  bus.on("payment:completed", ({ orderId, txnId }) =>
    console.log(`๐Ÿ“ง Email: Receipt for ${orderId}, txn ${txnId}`));
  bus.on("payment:completed", ({ orderId }) =>
    console.log(`๐Ÿ“ฑ Push: Your order ${orderId} is confirmed!`));
  bus.on("order:failed", ({ orderId, reason }) =>
    console.log(`๐Ÿšจ Alert: Order ${orderId} failed: ${reason}`));

  // Build an order (Builder)
  const order = new OrderBuilder()
    .forUser("user_ravi")
    .addItem({ productId: "LAPTOP-001", qty: 1, price: 75000 })
    .addItem({ productId: "MOUSE-002", qty: 2, price: 1500 })
    .shipTo("101 MG Road, Bengaluru - 560001")
    .applyCoupon("SAVE10")
    .build();

  // Process via facade (Facade)
  const service = new OrderService();
  await service.placeOrder(order);
}

main().catch(console.error);
๐Ÿ—๏ธ Architecture Note Notice how each pattern has a single, clear responsibility. The OrderService (Facade) doesn't know about email sending โ€” that's the Observer's job. The Builder doesn't know about payment โ€” that's the Factory's job. This is the Open/Closed Principle: open for extension, closed for modification.
06 โ€” Practice

Build Muscle
Memory

The only way to truly internalize patterns is to implement them yourself. Start with Easy, work up to Hard.

EXERCISE 01 โ€” EASY
๐Ÿ” Config Manager (Singleton)

Create a ConfigManager singleton that stores environment variables. It should have get(key) and set(key, value) methods. Verify that two instances always refer to the same underlying config object.

โ— Easy
Think about: private constructor, static instance property, static getInstance() method. Test it with ConfigManager.getInstance() === ConfigManager.getInstance().
EXERCISE 02 โ€” EASY
๐Ÿ• Pizza Builder

Build a PizzaBuilder with methods: size("small"|"medium"|"large"), crust("thin"|"thick"), addTopping(name), extraCheese(), build(). The build() method should throw if no size is set.

โ— Easy
Keep a private pizza: Partial<Pizza> object. Each method sets a field and returns this. build() validates and returns the completed object as Pizza.
EXERCISE 03 โ€” MEDIUM
๐Ÿš— Transport Factory

Create a TransportFactory that creates Car, Bike, and Truck objects based on a string type. All implement a Transport interface with move(distance: number): string and getCostPerKm(): number. Add a TripPlanner class that accepts any Transport and calculates total trip cost.

โ— Medium
Factory function returns a Transport interface type. TripPlanner takes Transport in its constructor (dependency injection). This tests both Factory and Polymorphism.
EXERCISE 04 โ€” MEDIUM
๐Ÿ“ก Typed Event System (Observer)

Build a generic, strongly-typed EventEmitter<T> class. Define a ChatEvents interface with events: "message:sent", "user:joined", "user:left". Wire up 3 handlers for different events and prove type safety by attempting to emit a non-existent event (should give a TypeScript error).

โ— Medium
Use keyof T to constrain event names. Store handlers as Map<string, Set<Function>>. The generic parameter K extends keyof T on both on() and emit() ensures type safety end-to-end.
EXERCISE 05 โ€” MEDIUM
๐Ÿ”Œ Third-Party SMS Adapter

You have an internal MessageSender interface with send(to: string, text: string): void. Two external SDKs: TwilioSDK (which uses createMessage(phone, body, from)) and MSG91SDK (which uses sendSMS(mobile, message, senderId)). Write adapters for both and a NotificationService that uses only MessageSender.

โ— Medium
Each adapter implements MessageSender and holds a reference to the SDK. The adapter's send() translates parameters and calls the SDK's native method.
EXERCISE 06 โ€” HARD
๐Ÿฆ Banking System (All Patterns)

Build a mini banking system combining: Singleton (database), Builder (create account/transaction), Factory (account types: savings, current, FD), Decorator (add features: overdraft protection, interest calculation), Observer (fraud alerts, statement emails on transactions), Strategy (interest calculation varies by account type), Chain of Responsibility (transaction validation: amount check โ†’ balance check โ†’ daily limit check โ†’ fraud check).

โ— Hard
Start with the domain model (Account, Transaction). Add the Chain for validation. Add Strategy for interest. Add Observer for notifications. Use Factory to create account types. Wrap accounts with Decorator to add features. Tie together with a Facade BankingService. Build incrementally โ€” don't try to do it all at once!
EXERCISE 07 โ€” HARD
๐ŸŽฌ Video Streaming Platform

Model a simplified Netflix-like system: Singleton (content catalog cache), Factory (create different content types: Movie, Series, Documentary), Builder (construct user watch sessions), Decorator (add subtitle support, HD quality, download capability to content), Strategy (recommendation algorithm: trending, personalized, genre-based), Observer (update watch history, send continue-watching reminders), Facade (StreamingService: one method to start playing given a contentId and userId).

โ— Hard
Focus on clean interfaces first. The Content interface should have play(userId), getMetadata(). Decorators add capabilities without changing the core interface. The RecommendationEngine takes a Strategy. The WatchSession is built by a Builder. The EventBus handles history and reminders.