In This Guide
Design patterns are reusable solutions to common problems. The original 23 GoF patterns were groundbreaking, but modern development looks different — we write async code, deploy to clouds, communicate through events, and build distributed systems. Some classic patterns (Strategy, Observer, Factory) are more relevant than ever. Others (Singleton, Template Method) often cause more harm than good. This guide covers the patterns we actually use in production systems, with real TypeScript and Node.js examples, not abstract UML diagrams.
1. GoF Patterns That Still Matter
| Pattern | Still Useful? | Modern Form |
|---|---|---|
| Strategy | Essential | Pass functions instead of strategy classes. First-class functions replace the class hierarchy. |
| Observer | Essential | EventEmitter, RxJS, domain events, pub/sub. The foundation of reactive programming. |
| Factory | Essential | DI containers, factory functions. Creating objects based on runtime config. |
| Adapter | Essential | Anti-corruption layers, API wrappers. Translating between incompatible interfaces. |
| Decorator | Very useful | Middleware chains, TypeScript decorators, higher-order functions. |
| Proxy | Very useful | JS Proxy, API gateways, caching proxies, lazy loading. |
| Singleton | Avoid | Use DI containers for shared instances. Singletons hide dependencies and break testing. |
| Template Method | Rarely | Composition over inheritance. Use Strategy with function injection instead. |
Strategy Pattern — Modern Style
// Classic GoF: class hierarchy
interface PaymentStrategy { pay(amount: Money): Promise<Receipt>; }
class StripePayment implements PaymentStrategy { ... }
class RazorpayPayment implements PaymentStrategy { ... }
// Modern: just use functions
type PaymentFn = (amount: Money) => Promise<Receipt>;
const payWithStripe: PaymentFn = async (amount) => {
const charge = await stripe.charges.create({ amount: amount.toCents() });
return Receipt.from(charge);
};
const payWithRazorpay: PaymentFn = async (amount) => {
const order = await razorpay.orders.create({ amount: amount.toPaise() });
return Receipt.from(order);
};
// Usage: pass the function, not a class instance
async function checkout(cart: Cart, pay: PaymentFn): Promise<Order> {
const total = cart.calculateTotal();
const receipt = await pay(total);
return Order.confirm(cart, receipt);
}
2. Modern Creational Patterns
Builder Pattern — For Complex Configuration
The builder pattern shines when constructing objects with many optional parameters. Instead of a constructor with 15 arguments, chain method calls.
// Query builder — fluent API
const users = await db
.select('users')
.where({ role: 'admin', active: true })
.orderBy('created_at', 'desc')
.limit(20)
.offset(page * 20)
.execute();
// HTTP request builder
const response = await HttpRequest
.post('https://api.example.com/orders')
.header('Authorization', `Bearer ${token}`)
.header('Idempotency-Key', orderId)
.body({ items, shippingAddress })
.timeout(5000)
.retry(3)
.send();
Factory Function — Replacing Class Factories
// Factory function with validation — cleaner than constructor overloads
function createUser(input: CreateUserInput): Result<User, ValidationError> {
const email = Email.parse(input.email);
if (!email.ok) return Err(new ValidationError('Invalid email'));
const name = UserName.parse(input.name);
if (!name.ok) return Err(new ValidationError('Name too short'));
return Ok(new User({
id: UserId.generate(),
email: email.value,
name: name.value,
role: input.role ?? 'member',
createdAt: new Date()
}));
}
3. Behavioral Patterns for Async Systems
Middleware / Chain of Responsibility
Express middleware, Koa middleware, Redux middleware — they're all the Chain of Responsibility pattern. Each handler processes the request and decides whether to pass it to the next handler.
// Generic middleware pipeline
type Middleware<T> = (ctx: T, next: () => Promise<void>) => Promise<void>;
function compose<T>(...middlewares: Middleware<T>[]): (ctx: T) => Promise<void> {
return async (ctx: T) => {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) throw new Error('next() called multiple times');
index = i;
if (i >= middlewares.length) return;
await middlewares[i](ctx, () => dispatch(i + 1));
}
await dispatch(0);
};
}
// Usage: logging → auth → rate-limit → handler
const pipeline = compose(logging, authenticate, rateLimit, handleRequest);
Publisher/Subscriber (Event Bus)
// Type-safe event bus
type EventMap = {
'order.placed': { orderId: string; total: number };
'user.registered': { userId: string; email: string };
'payment.failed': { orderId: string; reason: string };
};
class EventBus {
private handlers = new Map<string, Function[]>();
on<K extends keyof EventMap>(event: K, handler: (data: EventMap[K]) => void) {
const list = this.handlers.get(event) || [];
list.push(handler);
this.handlers.set(event, list);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
(this.handlers.get(event) || []).forEach(h => h(data));
}
}
// Type-safe: TypeScript catches wrong event names or payloads
bus.on('order.placed', (data) => sendConfirmationEmail(data.orderId));
bus.on('payment.failed', (data) => notifySupport(data.orderId, data.reason));
4. Cloud-Native Patterns
| Pattern | Problem It Solves | Implementation |
|---|---|---|
| Sidecar | Add cross-cutting concerns without modifying the app | Envoy proxy for mTLS, logging agent for log collection |
| Ambassador | Offload network concerns from the app | Local proxy that handles retries, circuit breaking for legacy apps |
| Strangler Fig | Incrementally replace a legacy system | Route new features to new service, old features to legacy. Gradually migrate. |
| Backend for Frontend | Different clients need different API shapes | Mobile BFF returns slim payloads, web BFF returns rich data |
| Outbox | Reliable event publishing with database consistency | Write event to DB table in same transaction, poll and publish to broker |
| Saga | Distributed transactions across services | Each service does local transaction + event. Compensating actions on failure. |
5. Resilience Patterns
These patterns keep your system running when things go wrong — and in distributed systems, things always go wrong.
| Pattern | What It Does | When to Use |
|---|---|---|
| Circuit Breaker | Stops calling a failing service after N failures | Every external service call. Non-negotiable. |
| Retry with Backoff | Retries failed calls with increasing delays | Transient failures (network blips, 503s). NOT for 400s. |
| Bulkhead | Isolates failures to prevent cascading | Separate connection pools per downstream service |
| Timeout | Fails fast instead of waiting forever | Every network call. Every database query. No exceptions. |
| Fallback | Returns cached/default data when primary source fails | Product catalog (show cached), recommendations (show popular) |
// Retry with exponential backoff + jitter
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 200
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
if (!isRetryable(error)) throw error;
// Exponential backoff with jitter
const delay = baseDelay * Math.pow(2, attempt);
const jitter = delay * (0.5 + Math.random() * 0.5);
await sleep(jitter);
}
}
throw new Error('Unreachable');
}
function isRetryable(error: any): boolean {
return error.status >= 500 || error.code === 'ECONNRESET';
}
6. Data Access Patterns
| Pattern | What It Does | When We Use It |
|---|---|---|
| Repository | Abstracts data access behind a domain-oriented interface | Domain-driven design projects. One per aggregate root. |
| Unit of Work | Tracks changes and commits them in a single transaction | Complex use cases modifying multiple aggregates atomically |
| CQRS | Separate models for reading and writing | Read-heavy systems, complex queries, denormalized read stores |
| Data Mapper | Maps between domain objects and database rows | When domain model differs significantly from DB schema |
7. Anti-Patterns to Avoid
| Anti-Pattern | Symptom | Fix |
|---|---|---|
| God Object | One class does everything (UserManager with 3000 lines) | Split by responsibility. UserAuth, UserProfile, UserBilling. |
| Premature Abstraction | Interfaces and factories with only one implementation | Wait for the second use case. Three similar functions is better than one wrong abstraction. |
| Distributed Monolith | Microservices that must deploy together | Loose coupling via events, not synchronous chains of API calls. |
| Callback Hell | Nested callbacks 6 levels deep | async/await, Promise.all for parallel operations. |
| Primitive Obsession | Using strings for emails, numbers for money | Value objects: Email, Money, UserId. Validate at construction. |
8. Frequently Asked Questions
Should I learn the original GoF design patterns?
Learn the concepts, not the specific class hierarchies. Strategy, Observer, Factory, Adapter, and Decorator translate directly to modern code. Singleton, Abstract Factory, and Visitor are rarely useful in dynamic languages. Focus on understanding the problem each pattern solves rather than memorizing the UML diagrams. The "Refactoring to Patterns" mindset is more valuable than applying patterns upfront.
How do I decide which pattern to use?
Start with the problem, not the pattern. "I need to support multiple payment providers" leads to Strategy. "I need to notify 5 services when an order is placed" leads to Observer/Event Bus. "I need to add logging without changing every function" leads to Decorator/Middleware. If you're searching for a place to use a pattern, you're doing it wrong. Patterns should emerge from real problems.
Are design patterns language-specific?
The concepts are universal, but the implementation varies dramatically. Python and JavaScript have first-class functions, so many GoF patterns (Strategy, Command, Template Method) simplify to passing functions. Go uses interfaces implicitly, making Adapter trivially easy. Rust's ownership model eliminates some patterns entirely. Learn patterns in the language you use — a Java implementation of Observer looks nothing like a JavaScript EventEmitter.
What's the most important pattern for modern backend development?
Dependency Injection — not as a framework, but as a practice. Constructor injection makes code testable, swappable, and explicit about its dependencies. Combined with the Repository pattern for data access and the Strategy pattern for business rules, you get a codebase that's easy to test and maintain. We consider these three patterns non-negotiable for any project beyond a prototype.
We use design patterns daily — but pragmatically. Our code reviews focus on "does this pattern solve a real problem here?" rather than "which pattern should we apply?" This guide reflects patterns we've validated across production systems in multiple languages and frameworks.