puzzle

Modern Design Patterns Practical Guide - GoF to 2025

2025.12.02

Design patterns are a catalog of reusable solutions in software design. Thirty years since the 1994 GoF (Gang of Four) book, while the essence of patterns remains unchanged, their application methods have evolved significantly. This article explains practical pattern usage in modern development environments.

Design Pattern Classification

CreationalStructuralBehavioral
SingletonAdapterStrategy
Factory MethodBridgeObserver
Abstract FactoryCompositeCommand
BuilderDecoratorState
PrototypeFacadeTemplate Method
FlyweightIterator
ProxyMediator
Memento
Visitor
Chain of Resp.

Creational Patterns

Factory Method Pattern

Delegates object creation to subclasses, separating creation logic.

// Modern TypeScript Factory Method implementation

// Product interface
interface Notification {
  send(message: string): Promise<void>;
}

// Concrete products
class EmailNotification implements Notification {
  constructor(private email: string) {}

  async send(message: string): Promise<void> {
    console.log(`Email to ${this.email}: ${message}`);
    // Actual email sending logic
  }
}

class SlackNotification implements Notification {
  constructor(private webhookUrl: string) {}

  async send(message: string): Promise<void> {
    console.log(`Slack webhook: ${message}`);
    // Slack API call
  }
}

class SMSNotification implements Notification {
  constructor(private phoneNumber: string) {}

  async send(message: string): Promise<void> {
    console.log(`SMS to ${this.phoneNumber}: ${message}`);
    // SMS sending API call
  }
}

// Factory (function-based - modern approach)
type NotificationType = 'email' | 'slack' | 'sms';

interface NotificationConfig {
  type: NotificationType;
  email?: string;
  webhookUrl?: string;
  phoneNumber?: string;
}

function createNotification(config: NotificationConfig): Notification {
  switch (config.type) {
    case 'email':
      if (!config.email) throw new Error('Email required');
      return new EmailNotification(config.email);
    case 'slack':
      if (!config.webhookUrl) throw new Error('Webhook URL required');
      return new SlackNotification(config.webhookUrl);
    case 'sms':
      if (!config.phoneNumber) throw new Error('Phone number required');
      return new SMSNotification(config.phoneNumber);
    default:
      throw new Error(`Unknown notification type: ${config.type}`);
  }
}

// Usage example
const notification = createNotification({
  type: 'email',
  email: 'user@example.com'
});
await notification.send('Hello!');

Builder Pattern

Constructs complex objects step by step.

// Builder pattern - Fluent API implementation

interface HttpRequestConfig {
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  url: string;
  headers: Record<string, string>;
  body?: unknown;
  timeout: number;
  retries: number;
}

class HttpRequestBuilder {
  private config: Partial<HttpRequestConfig> = {
    method: 'GET',
    headers: {},
    timeout: 30000,
    retries: 0,
  };

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

  method(method: HttpRequestConfig['method']): this {
    this.config.method = method;
    return this;
  }

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

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

  contentType(type: string): this {
    return this.header('Content-Type', type);
  }

  json(data: unknown): this {
    this.config.body = data;
    return this.contentType('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;
  }

  // Convenience method - direct execution
  async execute<T>(): Promise<T> {
    const config = this.build();
    // Actual fetch execution logic
    const response = await fetch(config.url, {
      method: config.method,
      headers: config.headers,
      body: config.body ? JSON.stringify(config.body) : undefined,
    });
    return response.json();
  }
}

// Usage example
const response = await new HttpRequestBuilder()
  .url('https://api.example.com/users')
  .method('POST')
  .authorization('my-token')
  .json({ name: 'John', email: 'john@example.com' })
  .timeout(5000)
  .retries(3)
  .execute<{ id: string }>();

Singleton Pattern (Modern Alternative)

Avoid traditional Singleton and use DI containers or module scope instead.

// ❌ Traditional Singleton (avoid)
class LegacySingleton {
  private static instance: LegacySingleton;

  private constructor() {}

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

// ✅ Module scope singleton
// database.ts
class DatabaseConnection {
  constructor(private connectionString: string) {}

  async query<T>(sql: string): Promise<T[]> {
    // Query execution
    return [];
  }
}

// Export at module level
export const db = new DatabaseConnection(process.env.DATABASE_URL!);

// ✅ Use DI container (recommended)
// container.ts
import { Container } from 'inversify';

const container = new Container();
container.bind<DatabaseConnection>('Database')
  .to(DatabaseConnection)
  .inSingletonScope();

export { container };

Structural Patterns

Adapter Pattern

Bridges incompatible interfaces.

// Adapting a legacy API to a new interface

// Existing legacy system
interface LegacyPaymentSystem {
  processPayment(
    amount: number,
    cardNumber: string,
    expiry: string,
    cvv: string
  ): boolean;
}

class LegacyStripePayment implements LegacyPaymentSystem {
  processPayment(
    amount: number,
    cardNumber: string,
    expiry: string,
    cvv: string
  ): boolean {
    console.log('Processing via legacy Stripe...');
    return true;
  }
}

// New interface
interface PaymentGateway {
  charge(payment: PaymentDetails): Promise<PaymentResult>;
}

interface PaymentDetails {
  amount: number;
  currency: string;
  card: {
    number: string;
    expiryMonth: number;
    expiryYear: number;
    cvc: string;
  };
}

interface PaymentResult {
  success: boolean;
  transactionId: string;
  error?: string;
}

// Adapter
class LegacyPaymentAdapter implements PaymentGateway {
  constructor(private legacySystem: LegacyPaymentSystem) {}

  async charge(payment: PaymentDetails): Promise<PaymentResult> {
    const expiry = `${payment.card.expiryMonth}/${payment.card.expiryYear}`;

    const success = this.legacySystem.processPayment(
      payment.amount,
      payment.card.number,
      expiry,
      payment.card.cvc
    );

    return {
      success,
      transactionId: success ? crypto.randomUUID() : '',
      error: success ? undefined : 'Payment failed',
    };
  }
}

// Usage example
const legacyStripe = new LegacyStripePayment();
const paymentGateway: PaymentGateway = new LegacyPaymentAdapter(legacyStripe);

const result = await paymentGateway.charge({
  amount: 1000,
  currency: 'JPY',
  card: {
    number: '4242424242424242',
    expiryMonth: 12,
    expiryYear: 2025,
    cvc: '123',
  },
});

Decorator Pattern

Dynamically adds functionality to existing objects.

// Implementation using TypeScript decorators

// Method decorator - logging
function Log(
  target: object,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const originalMethod = descriptor.value;

  descriptor.value = async function (...args: unknown[]) {
    console.log(`[${propertyKey}] Called with:`, args);
    const start = performance.now();

    try {
      const result = await originalMethod.apply(this, args);
      const duration = performance.now() - start;
      console.log(`[${propertyKey}] Returned:`, result, `(${duration}ms)`);
      return result;
    } catch (error) {
      console.error(`[${propertyKey}] Error:`, error);
      throw error;
    }
  };

  return descriptor;
}

// Cache decorator
function Cache(ttlMs: number = 60000) {
  const cache = new Map<string, { value: unknown; expiry: number }>();

  return function (
    target: object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ): PropertyDescriptor {
    const originalMethod = descriptor.value;

    descriptor.value = async function (...args: unknown[]) {
      const key = JSON.stringify(args);
      const cached = cache.get(key);

      if (cached && cached.expiry > Date.now()) {
        console.log(`[Cache Hit] ${propertyKey}`);
        return cached.value;
      }

      const result = await originalMethod.apply(this, args);
      cache.set(key, { value: result, expiry: Date.now() + ttlMs });
      return result;
    };

    return descriptor;
  };
}

// Retry decorator
function Retry(maxAttempts: number = 3, delayMs: number = 1000) {
  return function (
    target: object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ): PropertyDescriptor {
    const originalMethod = descriptor.value;

    descriptor.value = async function (...args: unknown[]) {
      let lastError: Error;

      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
          return await originalMethod.apply(this, args);
        } catch (error) {
          lastError = error as Error;
          console.warn(`[Retry] Attempt ${attempt}/${maxAttempts} failed`);

          if (attempt < maxAttempts) {
            await new Promise(resolve => setTimeout(resolve, delayMs));
          }
        }
      }

      throw lastError!;
    };

    return descriptor;
  };
}

// Applying decorators
class UserService {
  @Log
  @Cache(30000)  // 30 second cache
  @Retry(3, 1000)
  async getUser(id: string): Promise<User> {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  }
}

Proxy Pattern

Controls access to objects.

// Implementation using ES6 Proxy

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

// Validation proxy
function createValidatedUser(user: User): User {
  return new Proxy(user, {
    set(target, property, value) {
      if (property === 'email') {
        if (typeof value !== 'string' || !value.includes('@')) {
          throw new Error('Invalid email format');
        }
      }

      if (property === 'role') {
        if (!['admin', 'user'].includes(value)) {
          throw new Error('Invalid role');
        }
      }

      return Reflect.set(target, property, value);
    },
  });
}

// Lazy loading proxy
function createLazyLoader<T extends object>(
  loader: () => Promise<T>
): T {
  let instance: T | null = null;
  let loading: Promise<T> | null = null;

  return new Proxy({} as T, {
    get(target, property) {
      if (!instance) {
        if (!loading) {
          loading = loader().then(loaded => {
            instance = loaded;
            return loaded;
          });
        }
        // Choose to return promise or block
        return loading.then(inst => (inst as any)[property]);
      }
      return (instance as any)[property];
    },
  });
}

// Access control proxy
function createSecureObject<T extends object>(
  obj: T,
  currentUser: User
): T {
  return new Proxy(obj, {
    get(target, property) {
      const value = Reflect.get(target, property);

      // Admin-only properties
      const adminOnlyProps = ['password', 'secretKey', 'apiToken'];
      if (adminOnlyProps.includes(String(property))) {
        if (currentUser.role !== 'admin') {
          throw new Error('Access denied: Admin only');
        }
      }

      return value;
    },

    set(target, property, value) {
      if (currentUser.role !== 'admin') {
        throw new Error('Access denied: Read only');
      }
      return Reflect.set(target, property, value);
    },
  });
}

Behavioral Patterns

Strategy Pattern

Encapsulates algorithms and makes them interchangeable.

// Pricing calculation strategy

interface PricingStrategy {
  calculatePrice(basePrice: number, quantity: number): number;
  getName(): string;
}

class RegularPricing implements PricingStrategy {
  calculatePrice(basePrice: number, quantity: number): number {
    return basePrice * quantity;
  }
  getName(): string {
    return 'Regular';
  }
}

class BulkPricing implements PricingStrategy {
  constructor(private discountThreshold: number, private discountRate: number) {}

  calculatePrice(basePrice: number, quantity: number): number {
    if (quantity >= this.discountThreshold) {
      return basePrice * quantity * (1 - this.discountRate);
    }
    return basePrice * quantity;
  }

  getName(): string {
    return `Bulk (${this.discountRate * 100}% off for ${this.discountThreshold}+)`;
  }
}

class SubscriberPricing implements PricingStrategy {
  constructor(private memberDiscountRate: number) {}

  calculatePrice(basePrice: number, quantity: number): number {
    return basePrice * quantity * (1 - this.memberDiscountRate);
  }

  getName(): string {
    return `Subscriber (${this.memberDiscountRate * 100}% off)`;
  }
}

class SeasonalPricing implements PricingStrategy {
  constructor(
    private seasonalMultiplier: number,
    private seasonName: string
  ) {}

  calculatePrice(basePrice: number, quantity: number): number {
    return basePrice * quantity * this.seasonalMultiplier;
  }

  getName(): string {
    return `Seasonal - ${this.seasonName}`;
  }
}

// Context
class ShoppingCart {
  private items: Array<{ name: string; price: number; quantity: number }> = [];
  private pricingStrategy: PricingStrategy = new RegularPricing();

  addItem(name: string, price: number, quantity: number): void {
    this.items.push({ name, price, quantity });
  }

  setPricingStrategy(strategy: PricingStrategy): void {
    this.pricingStrategy = strategy;
  }

  calculateTotal(): number {
    return this.items.reduce((total, item) => {
      return total + this.pricingStrategy.calculatePrice(item.price, item.quantity);
    }, 0);
  }

  getReceipt(): string {
    const lines = this.items.map(item => {
      const subtotal = this.pricingStrategy.calculatePrice(item.price, item.quantity);
      return `${item.name} x${item.quantity}: $${subtotal}`;
    });

    return [
      `Pricing: ${this.pricingStrategy.getName()}`,
      '---',
      ...lines,
      '---',
      `Total: $${this.calculateTotal()}`,
    ].join('\n');
  }
}

// Usage example
const cart = new ShoppingCart();
cart.addItem('Widget', 1000, 5);
cart.addItem('Gadget', 2000, 3);

console.log(cart.getReceipt());
// Pricing: Regular
// Total: $11000

cart.setPricingStrategy(new BulkPricing(3, 0.15));
console.log(cart.getReceipt());
// Pricing: Bulk (15% off for 3+)
// Total: $9350

Observer Pattern

Defines a one-to-many dependency between objects.

// Type-safe Observer implementation in TypeScript

type EventMap = {
  userCreated: { id: string; email: string };
  userUpdated: { id: string; changes: Partial<User> };
  userDeleted: { id: string };
  orderPlaced: { orderId: string; userId: string; total: number };
};

type EventKey = keyof EventMap;
type EventHandler<K extends EventKey> = (event: EventMap[K]) => void | Promise<void>;

class TypedEventEmitter {
  private handlers = new Map<EventKey, Set<EventHandler<any>>>();

  on<K extends EventKey>(event: K, handler: EventHandler<K>): () => void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler);

    // Return unsubscribe function
    return () => this.off(event, handler);
  }

  off<K extends EventKey>(event: K, handler: EventHandler<K>): void {
    this.handlers.get(event)?.delete(handler);
  }

  async emit<K extends EventKey>(event: K, data: EventMap[K]): Promise<void> {
    const eventHandlers = this.handlers.get(event);
    if (!eventHandlers) return;

    const promises = Array.from(eventHandlers).map(handler =>
      Promise.resolve(handler(data))
    );

    await Promise.all(promises);
  }

  once<K extends EventKey>(event: K, handler: EventHandler<K>): () => void {
    const wrappedHandler: EventHandler<K> = async (data) => {
      this.off(event, wrappedHandler);
      await handler(data);
    };
    return this.on(event, wrappedHandler);
  }
}

// Usage example
const eventBus = new TypedEventEmitter();

// Email sending service
eventBus.on('userCreated', async ({ email }) => {
  console.log(`Sending welcome email to ${email}`);
});

// Analytics service
eventBus.on('userCreated', ({ id }) => {
  console.log(`Tracking user creation: ${id}`);
});

// Order processing
eventBus.on('orderPlaced', async ({ orderId, userId, total }) => {
  console.log(`Processing order ${orderId} for user ${userId}: $${total}`);
});

// Emit event
await eventBus.emit('userCreated', {
  id: 'user-123',
  email: 'user@example.com',
});

Command Pattern

Encapsulates operations as objects.

// Command implementation with Undo/Redo functionality

interface Command {
  execute(): Promise<void>;
  undo(): Promise<void>;
  getDescription(): string;
}

// Text editor command example
class TextEditor {
  private content: string = '';

  getContent(): string {
    return this.content;
  }

  setContent(content: string): void {
    this.content = content;
  }

  insertAt(position: number, text: string): void {
    this.content =
      this.content.slice(0, position) + text + this.content.slice(position);
  }

  deleteRange(start: number, end: number): string {
    const deleted = this.content.slice(start, end);
    this.content = this.content.slice(0, start) + this.content.slice(end);
    return deleted;
  }
}

class InsertTextCommand implements Command {
  constructor(
    private editor: TextEditor,
    private position: number,
    private text: string
  ) {}

  async execute(): Promise<void> {
    this.editor.insertAt(this.position, this.text);
  }

  async undo(): Promise<void> {
    this.editor.deleteRange(this.position, this.position + this.text.length);
  }

  getDescription(): string {
    return `Insert "${this.text}" at position ${this.position}`;
  }
}

class DeleteTextCommand implements Command {
  private deletedText: string = '';

  constructor(
    private editor: TextEditor,
    private start: number,
    private end: number
  ) {}

  async execute(): Promise<void> {
    this.deletedText = this.editor.deleteRange(this.start, this.end);
  }

  async undo(): Promise<void> {
    this.editor.insertAt(this.start, this.deletedText);
  }

  getDescription(): string {
    return `Delete from ${this.start} to ${this.end}`;
  }
}

// Command manager (Invoker)
class CommandManager {
  private history: Command[] = [];
  private redoStack: Command[] = [];

  async execute(command: Command): Promise<void> {
    await command.execute();
    this.history.push(command);
    this.redoStack = [];  // Clear redo stack on new command execution
  }

  async undo(): Promise<boolean> {
    const command = this.history.pop();
    if (!command) return false;

    await command.undo();
    this.redoStack.push(command);
    return true;
  }

  async redo(): Promise<boolean> {
    const command = this.redoStack.pop();
    if (!command) return false;

    await command.execute();
    this.history.push(command);
    return true;
  }

  getHistory(): string[] {
    return this.history.map(cmd => cmd.getDescription());
  }
}

// Usage example
const editor = new TextEditor();
const manager = new CommandManager();

await manager.execute(new InsertTextCommand(editor, 0, 'Hello '));
await manager.execute(new InsertTextCommand(editor, 6, 'World!'));
console.log(editor.getContent()); // "Hello World!"

await manager.undo();
console.log(editor.getContent()); // "Hello "

await manager.redo();
console.log(editor.getContent()); // "Hello World!"

Modern Architecture Patterns

Repository Pattern

Abstracts data access logic.

// Repository pattern with TypeScript

interface Entity {
  id: string;
}

interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  findBy(criteria: Partial<T>): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<boolean>;
}

interface User extends Entity {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

// In-memory implementation (for testing)
class InMemoryUserRepository implements Repository<User> {
  private users: Map<string, User> = new Map();

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }

  async findAll(): Promise<User[]> {
    return Array.from(this.users.values());
  }

  async findBy(criteria: Partial<User>): Promise<User[]> {
    return Array.from(this.users.values()).filter(user =>
      Object.entries(criteria).every(
        ([key, value]) => user[key as keyof User] === value
      )
    );
  }

  async save(entity: User): Promise<User> {
    this.users.set(entity.id, entity);
    return entity;
  }

  async delete(id: string): Promise<boolean> {
    return this.users.delete(id);
  }
}

// Prisma implementation (for production)
class PrismaUserRepository implements Repository<User> {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string): Promise<User | null> {
    return this.prisma.user.findUnique({ where: { id } });
  }

  async findAll(): Promise<User[]> {
    return this.prisma.user.findMany();
  }

  async findBy(criteria: Partial<User>): Promise<User[]> {
    return this.prisma.user.findMany({ where: criteria });
  }

  async save(entity: User): Promise<User> {
    return this.prisma.user.upsert({
      where: { id: entity.id },
      update: entity,
      create: entity,
    });
  }

  async delete(id: string): Promise<boolean> {
    try {
      await this.prisma.user.delete({ where: { id } });
      return true;
    } catch {
      return false;
    }
  }
}

Unit of Work Pattern

Tracks and manages changes within a transaction.

// Unit of Work implementation

interface UnitOfWork {
  begin(): Promise<void>;
  commit(): Promise<void>;
  rollback(): Promise<void>;
  userRepository: Repository<User>;
  orderRepository: Repository<Order>;
}

class PrismaUnitOfWork implements UnitOfWork {
  private transaction: Prisma.TransactionClient | null = null;
  private _userRepository: Repository<User> | null = null;
  private _orderRepository: Repository<Order> | null = null;

  constructor(private prisma: PrismaClient) {}

  async begin(): Promise<void> {
    // Prisma interactive transaction
    return new Promise((resolve) => {
      this.prisma.$transaction(async (tx) => {
        this.transaction = tx;
        resolve();
        // Transaction is held until commit/rollback
      });
    });
  }

  get userRepository(): Repository<User> {
    if (!this._userRepository) {
      this._userRepository = new PrismaUserRepository(
        this.transaction || this.prisma
      );
    }
    return this._userRepository;
  }

  get orderRepository(): Repository<Order> {
    if (!this._orderRepository) {
      this._orderRepository = new PrismaOrderRepository(
        this.transaction || this.prisma
      );
    }
    return this._orderRepository;
  }

  async commit(): Promise<void> {
    // Prisma transaction auto-commits
    this.transaction = null;
  }

  async rollback(): Promise<void> {
    throw new Error('Rollback requested');
  }
}

// Usage example
async function createOrderWithUser(uow: UnitOfWork) {
  await uow.begin();

  try {
    const user = await uow.userRepository.save({
      id: crypto.randomUUID(),
      email: 'new@example.com',
      name: 'New User',
      createdAt: new Date(),
    });

    const order = await uow.orderRepository.save({
      id: crypto.randomUUID(),
      userId: user.id,
      total: 5000,
      status: 'pending',
    });

    await uow.commit();
    return { user, order };
  } catch (error) {
    await uow.rollback();
    throw error;
  }
}

Relationship with SOLID Principles

PrincipleRelated Patterns
S - Single Responsibility Principle (SRP)Factory, Strategy, Command
O - Open/Closed Principle (OCP)Strategy, Decorator, Template Method
L - Liskov Substitution Principle (LSP)Factory Method, Abstract Factory
I - Interface Segregation Principle (ISP)Adapter, Facade
D - Dependency Inversion Principle (DIP)Repository, Dependency Injection

Summary

Design patterns are a common language for problem-solving and help align understanding in team development.

Selection Guidelines

PurposeRecommended Patterns
Flexibility in object creationFactory, Builder
Extending existing codeDecorator, Adapter
Switching algorithmsStrategy
Event-drivenObserver
Undo operationsCommand
Data access abstractionRepository

Best Practices for Modern Development

  1. Avoid over-applying patterns - Simple solutions for simple problems
  2. Leverage language features - TypeScript/Python type systems, decorators
  3. Prioritize testability - Use DI to make dependencies injectable
  4. Combine with functional approaches - Use functions for stateless processing

Design patterns are a means, not an end. Understanding the problem and selecting the appropriate pattern is what matters.

← Back to list