Skip to content

02 - Design Patterns (TypeScript) ​

Quick Reference (scan in 5 min) ​

PatternPurposeKey IdeaWhen to Use
SingletonExactly one instancePrivate constructor + getInstance()Logging, shared config, connection pools
ObserverReact to state changesSubject notifies a list of observersEvent systems, payment status updates, pub/sub
Chain of ResponsibilityPass request through a chainEach handler decides: process or forwardCaching layers, middleware, validation pipelines
StrategySwap algorithms at runtimeInterface + interchangeable implementationsPayment methods, sorting, discount calculations
FactoryCreate objects without exposing logicMethod returns instance based on inputPayment processors, notification channels
BuilderConstruct complex objects step-by-stepMethod chaining + final .build()Transactions, API requests, query construction

Key takeaways:

  • Singleton and Observer are the most commonly asked in interviews
  • Strategy and Factory show up in every fintech codebase (payment methods are the textbook use case)
  • Chain of Responsibility is less common in interviews but very practical (caching, middleware)
  • Builder shines whenever an object has 4+ optional fields

1. Singleton ​

What it is: A creational pattern that restricts a class to a single instance and provides a global access point to it.

When to use:

  • Application-wide logging service
  • Shared configuration / feature flags
  • Database or Redis connection pool
  • Analytics tracker (one instance collecting all events)

Implementation ​

ts
class Logger {
  private static instance: Logger;
  private logs: string[] = [];

  // Private constructor prevents `new Logger()` from outside
  private constructor() {}

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

  log(message: string): void {
    const timestamp = new Date().toISOString();
    const entry = `[${timestamp}] ${message}`;
    this.logs.push(entry);
    console.log(entry);
  }

  warn(message: string): void {
    this.log(`WARN: ${message}`);
  }

  error(message: string): void {
    this.log(`ERROR: ${message}`);
  }

  getLogs(): readonly string[] {
    return [...this.logs];
  }
}

// Usage — always the same instance
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();
console.log(logger1 === logger2); // true

logger1.log('Payment initiated for INR 500');
logger2.log('Payment confirmed — txn_abc123');
console.log(logger1.getLogs().length); // 2 (same instance)

Fintech example: Config service ​

ts
interface AppConfig {
  apiBaseUrl: string;
  paymentGateway: 'razorpay' | 'paytm' | 'stripe';
  maxRetries: number;
  featureFlags: Record<string, boolean>;
}

class ConfigService {
  private static instance: ConfigService;
  private config: AppConfig | null = null;

  private constructor() {}

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

  async load(): Promise<void> {
    const response = await fetch('/api/config');
    this.config = await response.json();
  }

  get<K extends keyof AppConfig>(key: K): AppConfig[K] {
    if (!this.config) throw new Error('Config not loaded. Call load() first.');
    return this.config[key];
  }

  isFeatureEnabled(flag: string): boolean {
    if (!this.config) return false;
    return this.config.featureFlags[flag] ?? false;
  }
}

Trade-offs / gotchas ​

  • Testing difficulty — Global state leaks between tests. Fix: add a reset() method for test environments, or inject the singleton via DI instead of calling getInstance() directly.
  • Hidden dependencies — Any module can grab the instance silently; hard to trace what depends on what. Prefer dependency injection when possible.
  • Concurrency — In Node.js this is fine (single-threaded), but in multi-threaded environments the naive if (!instance) check has a race condition.
  • Module-level singletons — In JS/TS, a module is only evaluated once. Exporting a plain object (export const logger = new Logger()) achieves the same effect without the class ceremony.

2. Observer ​

What it is: A behavioral pattern where a subject maintains a list of observers and notifies them automatically of state changes.

When to use:

  • Payment status updates (pending -> processing -> success/failure)
  • Real-time notifications (new transactions, balance changes)
  • Event-driven architectures / pub-sub within a module
  • Decoupling producers from consumers

Full implementation with generics ​

ts
// Generic type-safe EventEmitter
type Listener<T> = (data: T) => void;

class EventEmitter<EventMap extends Record<string, unknown>> {
  private listeners = new Map<keyof EventMap, Set<Listener<any>>>();

  subscribe<K extends keyof EventMap>(
    event: K,
    listener: Listener<EventMap[K]>
  ): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener);

    // Return unsubscribe function (cleanup-friendly)
    return () => {
      this.listeners.get(event)?.delete(listener);
    };
  }

  notify<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
    const eventListeners = this.listeners.get(event);
    if (!eventListeners) return;

    for (const listener of eventListeners) {
      try {
        listener(data);
      } catch (error) {
        console.error(`Error in listener for "${String(event)}":`, error);
      }
    }
  }

  listenerCount<K extends keyof EventMap>(event: K): number {
    return this.listeners.get(event)?.size ?? 0;
  }

  removeAllListeners<K extends keyof EventMap>(event?: K): void {
    if (event) {
      this.listeners.delete(event);
    } else {
      this.listeners.clear();
    }
  }
}

Fintech example: Payment status notifications ​

ts
// Define the event map — fully typed
interface PaymentEvents {
  'payment:initiated': { txnId: string; amount: number; currency: string };
  'payment:processing': { txnId: string; gateway: string };
  'payment:success': { txnId: string; receipt: string; settledAt: Date };
  'payment:failed': { txnId: string; reason: string; retryable: boolean };
  'payment:refunded': { txnId: string; refundId: string; amount: number };
}

// Create a typed emitter
const paymentBus = new EventEmitter<PaymentEvents>();

// Observer 1: Update the UI
const unsubUI = paymentBus.subscribe('payment:success', (data) => {
  // TypeScript knows: data is { txnId: string; receipt: string; settledAt: Date }
  showSuccessScreen(data.txnId, data.receipt);
});

// Observer 2: Send analytics
paymentBus.subscribe('payment:success', (data) => {
  analytics.track('payment_completed', {
    transactionId: data.txnId,
    settledAt: data.settledAt.toISOString(),
  });
});

// Observer 3: Handle failures
paymentBus.subscribe('payment:failed', (data) => {
  if (data.retryable) {
    showRetryButton(data.txnId);
  } else {
    showContactSupport(data.reason);
  }
});

// Emit from the payment service
function processPayment(txnId: string, amount: number) {
  paymentBus.notify('payment:initiated', {
    txnId,
    amount,
    currency: 'INR',
  });

  paymentBus.notify('payment:processing', {
    txnId,
    gateway: 'paytm',
  });

  // ... after gateway response
  paymentBus.notify('payment:success', {
    txnId,
    receipt: 'RCP-2024-001',
    settledAt: new Date(),
  });
}

// Cleanup when component unmounts
unsubUI(); // removes the UI listener only

Trade-offs / gotchas ​

  • Memory leaks — Forgetting to unsubscribe is the #1 bug. Always return an unsubscribe function and call it on cleanup (e.g., in React's useEffect return).
  • Ordering — Observers are notified in subscription order, but you should never depend on this.
  • Error isolation — One observer throwing should not break others. The try/catch in notify handles this.
  • Debugging — Events are implicit; harder to trace than direct function calls. Use descriptive event names and log emits in development.

3. Chain of Responsibility ​

What it is: A behavioral pattern where a request is passed through a chain of handlers. Each handler either processes the request or forwards it to the next handler.

When to use:

  • Caching layers (L1 memory -> L2 Redis -> L3 API)
  • Middleware pipelines (auth -> rate limit -> validate -> handle)
  • Validation chains (format check -> business rules -> fraud check)
  • Logging / enrichment pipelines

Implementation: Caching chain ​

ts
// Abstract handler
abstract class DataHandler<T> {
  private next: DataHandler<T> | null = null;

  setNext(handler: DataHandler<T>): DataHandler<T> {
    this.next = handler;
    return handler; // enables chaining: a.setNext(b).setNext(c)
  }

  async handle(key: string): Promise<T | null> {
    const result = await this.process(key);
    if (result !== null) {
      return result;
    }
    if (this.next) {
      return this.next.handle(key);
    }
    return null;
  }

  protected abstract process(key: string): Promise<T | null>;
}

// L1: In-memory cache
class MemoryCacheHandler<T> extends DataHandler<T> {
  private cache = new Map<string, { data: T; expiry: number }>();
  private ttlMs: number;

  constructor(ttlMs: number = 30_000) {
    super();
    this.ttlMs = ttlMs;
  }

  protected async process(key: string): Promise<T | null> {
    const entry = this.cache.get(key);
    if (entry && Date.now() < entry.expiry) {
      console.log(`[L1 Memory] HIT for "${key}"`);
      return entry.data;
    }
    console.log(`[L1 Memory] MISS for "${key}"`);
    return null;
  }

  set(key: string, data: T): void {
    this.cache.set(key, { data, expiry: Date.now() + this.ttlMs });
  }
}

// L2: Redis cache (simulated)
class RedisCacheHandler<T> extends DataHandler<T> {
  private store = new Map<string, string>(); // simulates Redis

  protected async process(key: string): Promise<T | null> {
    const cached = this.store.get(key);
    if (cached) {
      console.log(`[L2 Redis] HIT for "${key}"`);
      return JSON.parse(cached) as T;
    }
    console.log(`[L2 Redis] MISS for "${key}"`);
    return null;
  }

  set(key: string, data: T): void {
    this.store.set(key, JSON.stringify(data));
  }
}

// L3: API (the final source of truth)
class ApiHandler<T> extends DataHandler<T> {
  private fetchFn: (key: string) => Promise<T>;

  constructor(fetchFn: (key: string) => Promise<T>) {
    super();
    this.fetchFn = fetchFn;
  }

  protected async process(key: string): Promise<T | null> {
    console.log(`[L3 API] Fetching "${key}" from server`);
    try {
      return await this.fetchFn(key);
    } catch (error) {
      console.error(`[L3 API] Failed to fetch "${key}"`, error);
      return null;
    }
  }
}

DataService wiring it together ​

ts
interface Transaction {
  id: string;
  amount: number;
  currency: string;
  status: 'pending' | 'completed' | 'failed';
}

class TransactionDataService {
  private memoryCache: MemoryCacheHandler<Transaction>;
  private redisCache: RedisCacheHandler<Transaction>;
  private apiHandler: ApiHandler<Transaction>;
  private chain: DataHandler<Transaction>;

  constructor() {
    this.memoryCache = new MemoryCacheHandler<Transaction>(30_000); // 30s
    this.redisCache = new RedisCacheHandler<Transaction>();
    this.apiHandler = new ApiHandler<Transaction>(async (key) => {
      const response = await fetch(`/api/transactions/${key}`);
      return response.json();
    });

    // Build the chain: Memory -> Redis -> API
    this.memoryCache.setNext(this.redisCache).setNext(this.apiHandler);
    this.chain = this.memoryCache;
  }

  async getTransaction(id: string): Promise<Transaction | null> {
    const result = await this.chain.handle(id);

    // Backfill caches on API hit
    if (result) {
      this.memoryCache.set(id, result);
      this.redisCache.set(id, result);
    }

    return result;
  }
}

// Usage
const service = new TransactionDataService();

// First call: L1 MISS -> L2 MISS -> L3 API fetch
await service.getTransaction('txn_001');

// Second call: L1 HIT (served from memory)
await service.getTransaction('txn_001');

Trade-offs / gotchas ​

  • Order matters — The chain runs fastest-first (memory before Redis before API). Reordering breaks performance assumptions.
  • Silent failures — If no handler processes the request and the chain ends, you get null. Make sure the last handler always responds or throw explicitly.
  • Debugging — Requests traverse multiple handlers. Add logging at each level to trace which handler served the response.
  • Backfill logic — The chain itself does not backfill upper caches. You need to handle that in the orchestrator (as shown above in getTransaction).

4. Strategy ​

What it is: A behavioral pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime.

When to use:

  • Multiple payment methods (card, UPI, wallet) with different processing logic
  • Different discount calculation strategies (percentage, flat, tiered)
  • Sorting algorithms swapped based on data size
  • Validation strategies (strict for production, lenient for sandbox)

Implementation ​

ts
// The strategy interface — the contract all strategies must follow
interface PaymentStrategy {
  readonly name: string;
  validate(amount: number): boolean;
  process(amount: number, currency: string): Promise<PaymentResult>;
  calculateFees(amount: number): number;
}

interface PaymentResult {
  success: boolean;
  transactionId: string;
  method: string;
  netAmount: number;
}

// Strategy 1: Credit Card
class CreditCardStrategy implements PaymentStrategy {
  readonly name = 'credit_card';

  validate(amount: number): boolean {
    return amount > 0 && amount <= 500_000; // max 5 lakh per txn
  }

  async process(amount: number, currency: string): Promise<PaymentResult> {
    const fees = this.calculateFees(amount);
    console.log(`Processing card payment: ${currency} ${amount} (fees: ${fees})`);
    // ... card gateway API call
    return {
      success: true,
      transactionId: `CC-${Date.now()}`,
      method: this.name,
      netAmount: amount - fees,
    };
  }

  calculateFees(amount: number): number {
    return amount * 0.02; // 2% processing fee
  }
}

// Strategy 2: UPI
class UPIStrategy implements PaymentStrategy {
  readonly name = 'upi';

  validate(amount: number): boolean {
    return amount > 0 && amount <= 100_000; // UPI limit: 1 lakh
  }

  async process(amount: number, currency: string): Promise<PaymentResult> {
    const fees = this.calculateFees(amount);
    console.log(`Processing UPI payment: ${currency} ${amount} (fees: ${fees})`);
    // ... UPI gateway API call
    return {
      success: true,
      transactionId: `UPI-${Date.now()}`,
      method: this.name,
      netAmount: amount - fees,
    };
  }

  calculateFees(amount: number): number {
    return 0; // UPI is zero-fee in India
  }
}

// Strategy 3: Wallet
class WalletStrategy implements PaymentStrategy {
  readonly name = 'wallet';
  private balance: number;

  constructor(balance: number) {
    this.balance = balance;
  }

  validate(amount: number): boolean {
    return amount > 0 && amount <= this.balance;
  }

  async process(amount: number, currency: string): Promise<PaymentResult> {
    if (!this.validate(amount)) {
      return {
        success: false,
        transactionId: '',
        method: this.name,
        netAmount: 0,
      };
    }
    this.balance -= amount;
    const fees = this.calculateFees(amount);
    console.log(`Processing wallet payment: ${currency} ${amount} (fees: ${fees})`);
    return {
      success: true,
      transactionId: `WAL-${Date.now()}`,
      method: this.name,
      netAmount: amount - fees,
    };
  }

  calculateFees(amount: number): number {
    return amount * 0.01; // 1% wallet fee
  }
}

Context class ​

ts
class PaymentProcessor {
  private strategy: PaymentStrategy;

  constructor(strategy: PaymentStrategy) {
    this.strategy = strategy;
  }

  // Swap strategy at runtime
  setStrategy(strategy: PaymentStrategy): void {
    console.log(`Switching payment method: ${this.strategy.name} -> ${strategy.name}`);
    this.strategy = strategy;
  }

  async pay(amount: number, currency: string = 'INR'): Promise<PaymentResult> {
    if (!this.strategy.validate(amount)) {
      throw new Error(
        `Validation failed for ${this.strategy.name}: amount ${amount} is not valid`
      );
    }

    const fees = this.strategy.calculateFees(amount);
    console.log(`Fees for ${this.strategy.name}: ${currency} ${fees}`);

    return this.strategy.process(amount, currency);
  }
}

// Usage
const processor = new PaymentProcessor(new CreditCardStrategy());
await processor.pay(1000, 'INR'); // CC-1708000000, fees: 20

// User switches to UPI at runtime
processor.setStrategy(new UPIStrategy());
await processor.pay(1000, 'INR'); // UPI-1708000001, fees: 0

// User switches to wallet
processor.setStrategy(new WalletStrategy(5000));
await processor.pay(1000, 'INR'); // WAL-1708000002, fees: 10

Trade-offs / gotchas ​

  • When there are only 2 strategies — An if/else is simpler. Strategy shines when you have 3+ options or expect growth.
  • Strategy selection logic — Someone still has to pick the right strategy. Often combined with a Factory (see next section).
  • Shared state — Strategies should be stateless if possible. The WalletStrategy above holds balance, which makes it harder to share across contexts.
  • Over-engineering — If the algorithms never change and there are only a few, a simple map of functions works fine.

5. Factory ​

What it is: A creational pattern that provides an interface for creating objects without specifying their exact class. The factory method decides which class to instantiate based on input.

When to use:

  • Creating payment processors based on selected method
  • Instantiating notification channels (SMS, email, push)
  • Building different report formats (PDF, CSV, Excel)
  • Any place where object creation logic is complex or conditional

Simple Factory ​

ts
// Reusing the PaymentStrategy interface from the Strategy section

class PaymentStrategyFactory {
  private static strategies: Record<string, () => PaymentStrategy> = {
    credit_card: () => new CreditCardStrategy(),
    upi: () => new UPIStrategy(),
    wallet: () => new WalletStrategy(0), // balance injected separately
  };

  static create(method: string): PaymentStrategy {
    const creator = this.strategies[method];
    if (!creator) {
      throw new Error(`Unknown payment method: "${method}"`);
    }
    return creator();
  }

  // Register new strategies without modifying existing code (Open/Closed)
  static register(method: string, creator: () => PaymentStrategy): void {
    this.strategies[method] = creator;
  }
}

// Usage
const strategy = PaymentStrategyFactory.create('upi');
const processor = new PaymentProcessor(strategy);
await processor.pay(500, 'INR');

// Register a new method at runtime
PaymentStrategyFactory.register('bnpl', () => new BuyNowPayLaterStrategy());
const bnpl = PaymentStrategyFactory.create('bnpl');
ts
// When you need to create groups of related objects that belong together

interface UIComponentFactory {
  createButton(label: string): ButtonComponent;
  createInput(placeholder: string): InputComponent;
  createLoader(): LoaderComponent;
}

interface ButtonComponent {
  render(): string;
}

interface InputComponent {
  render(): string;
}

interface LoaderComponent {
  render(): string;
}

// Family 1: Paytm-branded components
class PaytmUIFactory implements UIComponentFactory {
  createButton(label: string): ButtonComponent {
    return {
      render: () => `<button class="paytm-btn paytm-blue">${label}</button>`,
    };
  }

  createInput(placeholder: string): InputComponent {
    return {
      render: () =>
        `<input class="paytm-input" placeholder="${placeholder}" />`,
    };
  }

  createLoader(): LoaderComponent {
    return {
      render: () => `<div class="paytm-spinner"></div>`,
    };
  }
}

// Family 2: Merchant-branded components
class MerchantUIFactory implements UIComponentFactory {
  constructor(private brandColor: string) {}

  createButton(label: string): ButtonComponent {
    return {
      render: () =>
        `<button style="background:${this.brandColor}">${label}</button>`,
    };
  }

  createInput(placeholder: string): InputComponent {
    return {
      render: () =>
        `<input class="merchant-input" placeholder="${placeholder}" />`,
    };
  }

  createLoader(): LoaderComponent {
    return {
      render: () =>
        `<div class="merchant-spinner" style="color:${this.brandColor}"></div>`,
    };
  }
}

// Client code doesn't know which factory it's using
function renderCheckoutPage(factory: UIComponentFactory) {
  const payButton = factory.createButton('Pay Now');
  const amountInput = factory.createInput('Enter amount');
  const loader = factory.createLoader();

  return `
    ${amountInput.render()}
    ${payButton.render()}
    ${loader.render()}
  `;
}

// Switch entire UI family by changing the factory
const paytmUI = renderCheckoutPage(new PaytmUIFactory());
const merchantUI = renderCheckoutPage(new MerchantUIFactory('#FF6600'));

Trade-offs / gotchas ​

  • Simple Factory vs Factory Method vs Abstract Factory — Know the difference. Simple Factory is a static method. Factory Method uses inheritance. Abstract Factory creates families.
  • Complexity — For 2-3 types, a factory is fine. For 10+, consider a registry pattern (as shown in the register method above).
  • Return type — The factory should return an interface, not a concrete class. This keeps calling code decoupled.
  • Dependency injection overlap — In modern TS/JS, DI containers (like tsyringe or InversifyJS) do what factories do but with more features. Factories are still useful for simpler cases.

6. Builder ​

What it is: A creational pattern that constructs a complex object step-by-step. The same construction process can create different representations.

When to use:

  • Transaction objects with many optional fields
  • Complex API request construction
  • Building query strings or filter objects
  • Configuration objects with validation

Implementation: Transaction Builder ​

ts
interface Transaction {
  id: string;
  amount: number;
  currency: string;
  recipient: string;
  sender: string;
  description: string;
  metadata: Record<string, string>;
  scheduledAt: Date | null;
  idempotencyKey: string;
  priority: 'low' | 'normal' | 'high';
}

class TransactionBuilder {
  private transaction: Partial<Transaction> = {};

  constructor() {
    // Set sensible defaults
    this.transaction.id = crypto.randomUUID();
    this.transaction.currency = 'INR';
    this.transaction.metadata = {};
    this.transaction.scheduledAt = null;
    this.transaction.priority = 'normal';
    this.transaction.idempotencyKey = crypto.randomUUID();
  }

  setAmount(amount: number): this {
    if (amount <= 0) throw new Error('Amount must be positive');
    if (amount > 10_000_000) throw new Error('Amount exceeds maximum limit');
    this.transaction.amount = amount;
    return this;
  }

  setCurrency(currency: string): this {
    const allowed = ['INR', 'USD', 'EUR', 'GBP'];
    if (!allowed.includes(currency)) {
      throw new Error(`Unsupported currency: ${currency}`);
    }
    this.transaction.currency = currency;
    return this;
  }

  setRecipient(recipient: string): this {
    if (!recipient.trim()) throw new Error('Recipient cannot be empty');
    this.transaction.recipient = recipient;
    return this;
  }

  setSender(sender: string): this {
    if (!sender.trim()) throw new Error('Sender cannot be empty');
    this.transaction.sender = sender;
    return this;
  }

  setDescription(description: string): this {
    this.transaction.description = description;
    return this;
  }

  addMetadata(key: string, value: string): this {
    this.transaction.metadata![key] = value;
    return this;
  }

  scheduleAt(date: Date): this {
    if (date <= new Date()) throw new Error('Scheduled date must be in the future');
    this.transaction.scheduledAt = date;
    return this;
  }

  setPriority(priority: 'low' | 'normal' | 'high'): this {
    this.transaction.priority = priority;
    return this;
  }

  setIdempotencyKey(key: string): this {
    this.transaction.idempotencyKey = key;
    return this;
  }

  build(): Transaction {
    // Validate all required fields are present
    const required: (keyof Transaction)[] = ['amount', 'recipient', 'sender'];
    for (const field of required) {
      if (this.transaction[field] === undefined) {
        throw new Error(`Missing required field: "${field}"`);
      }
    }

    return { ...this.transaction } as Transaction;
  }
}

Usage with method chaining ​

ts
// Clean, readable construction
const txn = new TransactionBuilder()
  .setAmount(25_000)
  .setCurrency('INR')
  .setRecipient('merchant_paytm_001')
  .setSender('user_aayush_42')
  .setDescription('Monthly subscription payment')
  .addMetadata('plan', 'premium')
  .addMetadata('billingCycle', '2024-02')
  .setPriority('high')
  .build();

console.log(txn);
// {
//   id: 'a1b2c3d4-...',
//   amount: 25000,
//   currency: 'INR',
//   recipient: 'merchant_paytm_001',
//   sender: 'user_aayush_42',
//   description: 'Monthly subscription payment',
//   metadata: { plan: 'premium', billingCycle: '2024-02' },
//   scheduledAt: null,
//   idempotencyKey: 'e5f6g7h8-...',
//   priority: 'high'
// }

// Scheduled payment
const scheduledTxn = new TransactionBuilder()
  .setAmount(10_000)
  .setRecipient('merchant_electricity_board')
  .setSender('user_aayush_42')
  .setDescription('Electricity bill')
  .scheduleAt(new Date('2024-03-01'))
  .build();

// Minimal required fields only
const simpleTxn = new TransactionBuilder()
  .setAmount(500)
  .setRecipient('friend_rahul')
  .setSender('user_aayush_42')
  .build();

Director pattern (optional, for predefined configurations) ​

ts
class TransactionDirector {
  static createP2PTransfer(
    sender: string,
    recipient: string,
    amount: number
  ): Transaction {
    return new TransactionBuilder()
      .setAmount(amount)
      .setSender(sender)
      .setRecipient(recipient)
      .setDescription('P2P Transfer')
      .setPriority('normal')
      .build();
  }

  static createMerchantPayment(
    sender: string,
    merchantId: string,
    amount: number,
    orderId: string
  ): Transaction {
    return new TransactionBuilder()
      .setAmount(amount)
      .setSender(sender)
      .setRecipient(merchantId)
      .setDescription(`Payment for order ${orderId}`)
      .addMetadata('orderId', orderId)
      .addMetadata('type', 'merchant_payment')
      .setPriority('high')
      .build();
  }

  static createScheduledBillPayment(
    sender: string,
    biller: string,
    amount: number,
    dueDate: Date
  ): Transaction {
    return new TransactionBuilder()
      .setAmount(amount)
      .setSender(sender)
      .setRecipient(biller)
      .setDescription('Scheduled bill payment')
      .scheduleAt(dueDate)
      .addMetadata('type', 'bill_payment')
      .setPriority('low')
      .build();
  }
}

// Usage — clean one-liners for common cases
const p2p = TransactionDirector.createP2PTransfer('user_a', 'user_b', 500);
const merchant = TransactionDirector.createMerchantPayment(
  'user_a', 'shop_xyz', 1299, 'ORD-123'
);

Trade-offs / gotchas ​

  • When to use — If the constructor has 4+ parameters or the object has many optional fields, use a Builder. For simple objects, just use an object literal.
  • Immutability — The built object should be frozen or at minimum defensively copied ({ ...this.transaction }) so the builder cannot mutate it after build().
  • Reuse — A builder instance should not be reused after build(). Either create a new builder each time or add a reset() method.
  • Validation timing — Validate at build() time (not in each setter) if you want to allow partial construction. Validate in setters (as shown above) if you want early failure.
  • Builder vs object literal — In TypeScript, Partial<T> + a validate function is often simpler. Builders add value when there is construction logic (defaults, derived fields, complex validation).

Frontend interview preparation reference.