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
| Creational | Structural | Behavioral |
|---|---|---|
| Singleton | Adapter | Strategy |
| Factory Method | Bridge | Observer |
| Abstract Factory | Composite | Command |
| Builder | Decorator | State |
| Prototype | Facade | Template Method |
| Flyweight | Iterator | |
| Proxy | Mediator | |
| 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
| Principle | Related 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
| Purpose | Recommended Patterns |
|---|---|
| Flexibility in object creation | Factory, Builder |
| Extending existing code | Decorator, Adapter |
| Switching algorithms | Strategy |
| Event-driven | Observer |
| Undo operations | Command |
| Data access abstraction | Repository |
Best Practices for Modern Development
- Avoid over-applying patterns - Simple solutions for simple problems
- Leverage language features - TypeScript/Python type systems, decorators
- Prioritize testability - Use DI to make dependencies injectable
- 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.