Design Patterns Overview
Design patterns are reusable solutions to common problems in software design. They give you a shared vocabulary to discuss architecture decisions with your team and provide battle-tested approaches to recurring challenges. Rather than reinventing solutions, patterns let you apply proven strategies that others have refined over decades.
Why Patterns Matter
When you say "we need a factory here" or "this should be an observer," experienced developers immediately understand the structure, tradeoffs, and implementation approach. This shared vocabulary accelerates design discussions and code reviews.
Patterns also encode collective wisdom about what works. The Singleton pattern, for example, comes with well-documented warnings about testability and global state. Learning patterns means learning from others' mistakes.
// Without patterns: explaining the whole concept
"We need a thing that creates database connections based on config,
and different environments get different connection types..."
// With patterns: instant understanding
"We need a Factory for database connections."
How ES6 Modules Changed Everything
Classical design patterns emerged from languages like C++ and Java, where organizing code required explicit classes and careful instantiation control. JavaScript's ES6 modules fundamentally changed what patterns you need.
Modules as Natural Singletons
In the Gang of Four book, Singleton requires careful implementation to ensure only one instance exists. In ESM, you get this for free:
// src/config.ts
// This module IS a singleton - it runs once and caches the result
export const config = {
apiUrl: process.env.API_URL || 'http://localhost:3000',
debug: process.env.DEBUG === 'true',
};
// Every import gets the same object reference
import { config } from './config';
The module system caches the exported value after the first import. No special pattern implementation needed.
Module-Level Encapsulation
Where Java developers might use the Module Pattern with IIFEs to hide private state, ES modules provide real privacy:
// src/counter.ts
// These are truly private - not accessible from other modules
let count = 0;
const history: number[] = [];
// Only what you export is public
export function increment(): number {
history.push(count);
return ++count;
}
export function getCount(): number {
return count;
}
// history is completely hidden - no way to access it externally
Static Imports as Dependency Injection
Classical DI frameworks exist because languages needed runtime mechanisms to swap implementations. With ES modules, you can use import maps or build-time aliasing:
// package.json or import map
{
"imports": {
"#db": "./src/db/postgres.js"
}
}
// src/users.ts - same code works with different implementations
import { query } from '#db';
// In tests, you can remap #db to a mock implementation
Functional vs. Object-Oriented Patterns
JavaScript uniquely straddles both paradigms. You can implement most patterns either way—or blend approaches.
OOP Approach: Strategy Pattern
// Classic OOP with interfaces and classes
interface PaymentStrategy {
pay(amount: number): Promise<PaymentResult>;
}
class StripePayment implements PaymentStrategy {
async pay(amount: number): Promise<PaymentResult> {
// Stripe-specific logic
}
}
class PayPalPayment implements PaymentStrategy {
async pay(amount: number): Promise<PaymentResult> {
// PayPal-specific logic
}
}
class Checkout {
constructor(private strategy: PaymentStrategy) {}
async processPayment(amount: number) {
return this.strategy.pay(amount);
}
}
Functional Approach: Same Pattern, Different Style
// Functional with higher-order functions
type PaymentFn = (amount: number) => Promise<PaymentResult>;
const stripePayment: PaymentFn = async (amount) => {
// Stripe-specific logic
};
const paypalPayment: PaymentFn = async (amount) => {
// PayPal-specific logic
};
const processPayment = (payFn: PaymentFn) => async (amount: number) => {
return payFn(amount);
};
// Usage
const checkout = processPayment(stripePayment);
await checkout(99.99);
When to Use Which
Prefer OOP patterns when:
- You have complex, stateful objects with multiple methods
- You need inheritance hierarchies (use sparingly)
- Your team comes from Java/C# backgrounds
- You're building class-based frameworks (ORMs, UI component systems)
Prefer functional patterns when:
- Logic is stateless or state is minimal
- You want easier testing (pure functions)
- You're composing small, focused operations
- You're working with React hooks or functional pipelines
Blend them when:
- You need classes for structure but pure functions for logic
- Your domain model is OOP but utilities are functional
- You're gradually migrating between paradigms
// Blended: OOP structure with functional methods
class UserService {
constructor(private db: Database) {}
// Functional-style: pure transformation
private sanitizeEmail = (email: string): string =>
email.toLowerCase().trim();
// OOP-style: stateful operation
async createUser(email: string, name: string): Promise<User> {
const sanitized = this.sanitizeEmail(email);
return this.db.users.create({ email: sanitized, name });
}
}
The Anti-Pattern Warning
Patterns are tools, not goals. The biggest anti-pattern is pattern obsession—forcing patterns where simple code would suffice.
Signs You're Over-Engineering
// ❌ Over-engineered: AbstractFactoryFactoryBuilder
interface LoggerFactory {
createLogger(): Logger;
}
class ProductionLoggerFactory implements LoggerFactory {
createLogger(): Logger {
return new ProductionLogger();
}
}
class LoggerFactoryFactory {
static createFactory(env: string): LoggerFactory {
// ...endless abstraction
}
}
// ✅ Just do the simple thing
const logger = process.env.NODE_ENV === 'production'
? new ProductionLogger()
: new DevLogger();
When Patterns Actually Help
- Multiple implementations: If you genuinely swap payment providers, use a Factory
- Complex state management: Observer/Pub-Sub for decoupling event sources from handlers
- Team coordination: Patterns help when 10 developers need to work consistently
- Framework design: Libraries benefit from patterns (users expect Factory methods, Builder APIs)
The Rule of Three
Before introducing a pattern, apply the "Rule of Three":
- First time: Just write the code
- Second time: Note the similarity, but don't abstract yet
- Third time: Now you understand the pattern—extract it
Premature abstraction creates more problems than it solves.
Patterns Covered in This Section
This section covers patterns organized by their primary use case:
Classical Patterns in TypeScript
Foundational patterns adapted for modern JavaScript/TypeScript:
- Singleton: When you need it (rarely) and why ESM makes it trivial
- Factory: Encapsulating creation logic for different implementations
- Observer/Pub-Sub: Event-driven architectures with EventTarget, EventEmitter, RxJS
- Proxy: Using JavaScript's Proxy for validation, logging, reactivity
See Classical Patterns in TypeScript for implementations.
React & UI Patterns
Component architecture patterns for building maintainable UIs:
- Custom Hooks: The modern standard for logic reuse
- Compound Components: Flexible, implicit-state APIs
- Container/Presenter: Separating data from display
- Controlled Components: Managing state ownership
See React & UI Patterns for implementations.
System Architecture Patterns
Large-scale structural decisions:
- Clean Architecture: Dependency inversion for swappable infrastructure
- Monorepo vs. Polyrepo: Codebase organization strategies
- Backend for Frontend (BFF): Tailoring APIs for specific clients
See System Architecture Patterns for implementations.
See Also
- TypeScript - Type system features that enable pattern implementations
- React - Component patterns and hooks
- Node.js - EventEmitter and server-side patterns