Design Patterns: 7 Essential Patterns Every Developer Must Know
In 1994, four authors — Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides — published the book known as the "Gang of Four": Design Patterns. 30 years later, it's still asked about in every job interview, and its traces are in every large codebase.
But the classic mistake when learning design patterns: memorizing the patterns without understanding when to use them. This article treats each pattern as a tool that solves a specific problem.
What Is a Design Pattern?
A design pattern is a reusable solution template for problems frequently encountered in software design. Not code — an idea. Not copied directly, but adapted to the problem.
Examined in three categories: Creational (object creation), Structural (structural relationships), Behavioral (behavior and communication).
1. Singleton — Creational
Problem: Only one instance of a class should be created, and global access to that instance should be provided.
Real use: Database connection pool, logger, application configuration.
class DatabaseConnection { private static instance: DatabaseConnection; private connection: Connection; private constructor() { this.connection = createConnection({ host: process.env.DB_HOST, database: process.env.DB_NAME }); } static getInstance(): DatabaseConnection { if (!DatabaseConnection.instance) { DatabaseConnection.instance = new DatabaseConnection(); } return DatabaseConnection.instance; } query(sql: string) { return this.connection.execute(sql); } } // Same instance from everywhere const db1 = DatabaseConnection.getInstance(); const db2 = DatabaseConnection.getInstance(); console.log(db1 === db2); // true
Warning: Singleton creates global state and makes testing harder. Prefer dependency injection; avoid Singleton unless necessary.
2. Factory — Creational
Problem: You want to separate object creation logic from the code that uses the object. Which class to instantiate is determined at runtime.
Real use: Different payment providers, different notification channels, different storage providers.
interface PaymentProvider { charge(amount: number): Promise<PaymentResult>; refund(transactionId: string): Promise<void>; } class StripeProvider implements PaymentProvider { async charge(amount: number) { /* Stripe API */ } async refund(transactionId: string) { /* Stripe API */ } } class PayPalProvider implements PaymentProvider { async charge(amount: number) { /* PayPal API */ } async refund(transactionId: string) { /* PayPal API */ } } // Factory class PaymentFactory { static create(provider: string): PaymentProvider { switch (provider) { case "stripe": return new StripeProvider(); case "paypal": return new PayPalProvider(); default: throw new Error(`Unknown provider: ${provider}`); } } } // Usage: works without knowing which provider it is const payment = PaymentFactory.create(process.env.PAYMENT_PROVIDER); await payment.charge(99.99);
New payment provider added? Just write a new class and add a case to the factory. Existing code doesn't change.
3. Observer — Behavioral
Problem: When the state of one object changes, dependent objects need to be automatically notified.
Real use: Event systems, state management (Redux), DOM event listeners, WebSocket messages.
interface Observer { update(event: string, data: any): void; } class EventEmitter { private listeners: Map<string, Observer[]> = new Map(); subscribe(event: string, observer: Observer): void { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event)!.push(observer); } emit(event: string, data: any): void { const observers = this.listeners.get(event) || []; observers.forEach(observer => observer.update(event, data)); } } // Usage const orderService = new EventEmitter(); // Different services listening to the same event orderService.subscribe("order.placed", { update: (event, order) => emailService.sendConfirmation(order) }); orderService.subscribe("order.placed", { update: (event, order) => inventoryService.reserveItems(order) }); orderService.subscribe("order.placed", { update: (event, order) => analyticsService.track(event, order) }); // Order placed — all observers automatically triggered orderService.emit("order.placed", { id: "ord-123", items: [] });
The Observer pattern loosely couples system components. The email service doesn't need to know about the order service. The order service doesn't need to know who is listening.
4. Strategy — Behavioral
Problem: There are multiple versions of an algorithm and which one to use should be selectable at runtime.
Real use: Sorting algorithms, payment strategies, compression algorithms, authentication strategies.
interface SortStrategy { sort(data: number[]): number[]; } class QuickSort implements SortStrategy { sort(data: number[]): number[] { return [...data].sort((a, b) => a - b); } } class BubbleSort implements SortStrategy { sort(data: number[]): number[] { const arr = [...data]; for (let i = 0; i < arr.length; i++) { for (let j = 0; j < arr.length - i - 1; j++) { if (arr[j] > arr[j + 1]) [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; } } return arr; } } class Sorter { constructor(private strategy: SortStrategy) {} setStrategy(strategy: SortStrategy) { this.strategy = strategy; } sort(data: number[]): number[] { return this.strategy.sort(data); } } // Strategy can be swapped at runtime const sorter = new Sorter(new QuickSort()); sorter.sort([3, 1, 4, 1, 5, 9]); sorter.setStrategy(new BubbleSort()); sorter.sort([3, 1, 4]);
5. Decorator — Structural
Problem: You want to add new behavior to an object without modifying the existing class.
Real use: Middlewares, logging, caching, authentication layers.
interface DataService { getData(id: string): Promise<any>; } class RealDataService implements DataService { async getData(id: string) { return db.query("SELECT * FROM items WHERE id = ?", [id]); } } // Cache decorator class CachedDataService implements DataService { constructor(private service: DataService, private cache: Redis) {} async getData(id: string) { const cached = await this.cache.get(`item:${id}`); if (cached) return JSON.parse(cached); const data = await this.service.getData(id); await this.cache.setex(`item:${id}`, 3600, JSON.stringify(data)); return data; } } // Logging decorator class LoggedDataService implements DataService { constructor(private service: DataService) {} async getData(id: string) { const start = Date.now(); const data = await this.service.getData(id); console.log(`Fetched item ${id} in ${Date.now() - start}ms`); return data; } } // Compose decorators const service = new LoggedDataService( new CachedDataService( new RealDataService(), redis ) );
Each decorator takes a single responsibility. RealDataService knows how to read from the database, not how to cache. CachedDataService knows how to cache, not where to fetch data from.
6. Repository — Structural
Problem: You want to separate data access logic from business logic. Even if the database implementation changes, business logic should be unaffected.
Real use: Almost every enterprise application. A cornerstone of Domain Driven Design.
interface UserRepository { findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; save(user: User): Promise<User>; delete(id: string): Promise<void>; } // PostgreSQL implementation class PostgresUserRepository implements UserRepository { async findById(id: string) { const row = await db.query("SELECT * FROM users WHERE id = $1", [id]); return row ? mapToUser(row) : null; } } // In-memory implementation for tests class InMemoryUserRepository implements UserRepository { private users: Map<string, User> = new Map(); async findById(id: string) { return this.users.get(id) || null; } } // Service only knows the interface class UserService { constructor(private userRepo: UserRepository) {} async getUserProfile(id: string) { const user = await this.userRepo.findById(id); if (!user) throw new Error("User not found"); return user; } } // PostgreSQL in production const service = new UserService(new PostgresUserRepository()); // In-memory in tests const testService = new UserService(new InMemoryUserRepository());
7. Builder — Creational
Problem: You want to construct a complex object step by step. Too many constructor parameters or the creation process has too many steps.
Real use: Query builders, test fixtures, complex configuration objects.
class QueryBuilder { private table: string = ""; private conditions: string[] = []; private columns: string[] = ["*"]; private limitValue?: number; private orderByColumn?: string; from(table: string): this { this.table = table; return this; } select(...columns: string[]): this { this.columns = columns; return this; } where(condition: string): this { this.conditions.push(condition); return this; } limit(n: number): this { this.limitValue = n; return this; } orderBy(column: string): this { this.orderByColumn = column; return this; } build(): string { let query = `SELECT ${this.columns.join(", ")} FROM ${this.table}`; if (this.conditions.length > 0) { query += ` WHERE ${this.conditions.join(" AND ")}`; } if (this.orderByColumn) query += ` ORDER BY ${this.orderByColumn}`; if (this.limitValue) query += ` LIMIT ${this.limitValue}`; return query; } } // Readable, chainable API const query = new QueryBuilder() .from("users") .select("id", "email", "name") .where("active = true") .where("age > 18") .orderBy("created_at") .limit(10) .build();
When to Use Patterns?
Design patterns are tools, not solutions. Like any tool, they cause damage when used in the wrong place.
Questions to ask before using a pattern: What problem am I solving? Can I solve this problem without this pattern? Is adding this complexity really worth it?
Instead of adding complexity because "I used a pattern," look for situations where you can say "this pattern cleanly solves this problem." The best code is code a reader can understand without needing to know the pattern's name.