Design Patterns
Common design patterns for building maintainable, scalable software
Design patterns are reusable solutions to common problems in software design. They represent best practices evolved over time and provide a shared vocabulary for discussing design decisions.
Pattern Categories
Creational Patterns
Factory Pattern
Create objects without specifying the exact class:
// Abstract factory for database connections
interface DatabaseConnection {
connect(): Promise<void>;
query(sql: string): Promise<any>;
disconnect(): Promise<void>;
}
class PostgresConnection implements DatabaseConnection {
async connect() { /* ... */ }
async query(sql: string) { /* ... */ }
async disconnect() { /* ... */ }
}
class MySQLConnection implements DatabaseConnection {
async connect() { /* ... */ }
async query(sql: string) { /* ... */ }
async disconnect() { /* ... */ }
}
// Factory
class DatabaseFactory {
static create(type: 'postgres' | 'mysql'): DatabaseConnection {
switch (type) {
case 'postgres':
return new PostgresConnection();
case 'mysql':
return new MySQLConnection();
default:
throw new Error(`Unknown database type: ${type}`);
}
}
}
// Usage
const db = DatabaseFactory.create('postgres');
await db.connect();When to use:
- Object creation logic should be separate from business logic
- System needs to be independent of how objects are created
- Multiple implementations of an interface exist
Builder Pattern
Construct complex objects step by step:
class QueryBuilder {
private query: string = '';
private params: any[] = [];
select(columns: string[]): this {
this.query = `SELECT ${columns.join(', ')}`;
return this;
}
from(table: string): this {
this.query += ` FROM ${table}`;
return this;
}
where(condition: string, value: any): this {
this.query += ` WHERE ${condition}`;
this.params.push(value);
return this;
}
orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
this.query += ` ORDER BY ${column} ${direction}`;
return this;
}
build(): { query: string; params: any[] } {
return { query: this.query, params: this.params };
}
}
// Usage
const { query, params } = new QueryBuilder()
.select(['id', 'name', 'email'])
.from('users')
.where('status = $1', 'active')
.orderBy('created_at', 'DESC')
.build();When to use:
- Object construction requires many steps
- Need to create different representations of an object
- Want to make construction code more readable
Singleton Pattern
Ensure a class has only one instance:
class ConfigurationManager {
private static instance: ConfigurationManager;
private config: Map<string, any> = new Map();
private constructor() {
// Private constructor prevents direct instantiation
}
static getInstance(): ConfigurationManager {
if (!ConfigurationManager.instance) {
ConfigurationManager.instance = new ConfigurationManager();
}
return ConfigurationManager.instance;
}
get(key: string): any {
return this.config.get(key);
}
set(key: string, value: any): void {
this.config.set(key, value);
}
}
// Usage
const config = ConfigurationManager.getInstance();
config.set('apiUrl', 'https://api.example.com');Caution: Singletons can make testing difficult. Consider dependency injection instead.
Structural Patterns
Adapter Pattern
Convert an interface to one clients expect:
// Legacy payment system
class LegacyPaymentProcessor {
processPayment(amount: number, cardNumber: string): boolean {
// Legacy implementation
return true;
}
}
// New interface
interface PaymentGateway {
charge(payment: {
amount: number;
currency: string;
card: { number: string; expiry: string; cvv: string };
}): Promise<{ success: boolean; transactionId: string }>;
}
// Adapter
class LegacyPaymentAdapter implements PaymentGateway {
private legacy: LegacyPaymentProcessor;
constructor(legacy: LegacyPaymentProcessor) {
this.legacy = legacy;
}
async charge(payment: {
amount: number;
currency: string;
card: { number: string; expiry: string; cvv: string };
}): Promise<{ success: boolean; transactionId: string }> {
const success = this.legacy.processPayment(
payment.amount,
payment.card.number
);
return {
success,
transactionId: success ? `legacy-${Date.now()}` : '',
};
}
}When to use:
- Integrating with legacy or third-party code
- Creating reusable classes that work with unrelated interfaces
- Using existing classes with incompatible interfaces
Decorator Pattern
Add responsibilities to objects dynamically:
interface Logger {
log(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(message);
}
}
// Decorator base
abstract class LoggerDecorator implements Logger {
protected logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
abstract log(message: string): void;
}
// Timestamp decorator
class TimestampLogger extends LoggerDecorator {
log(message: string): void {
const timestamp = new Date().toISOString();
this.logger.log(`[${timestamp}] ${message}`);
}
}
// JSON decorator
class JsonLogger extends LoggerDecorator {
log(message: string): void {
this.logger.log(JSON.stringify({ message, timestamp: Date.now() }));
}
}
// Usage - decorators can be stacked
let logger: Logger = new ConsoleLogger();
logger = new TimestampLogger(logger);
logger.log('User logged in'); // [2024-01-15T10:30:00.000Z] User logged inWhen to use:
- Add responsibilities without modifying existing code
- Responsibilities can be added or removed at runtime
- Extension by subclassing is impractical
Facade Pattern
Provide a simplified interface to a complex subsystem:
// Complex subsystems
class UserService {
async getUser(id: string) { /* ... */ }
}
class OrderService {
async getOrders(userId: string) { /* ... */ }
}
class NotificationService {
async send(userId: string, message: string) { /* ... */ }
}
class PaymentService {
async getBalance(userId: string) { /* ... */ }
}
// Facade
class CustomerDashboardFacade {
private userService: UserService;
private orderService: OrderService;
private notificationService: NotificationService;
private paymentService: PaymentService;
constructor() {
this.userService = new UserService();
this.orderService = new OrderService();
this.notificationService = new NotificationService();
this.paymentService = new PaymentService();
}
async getDashboardData(userId: string) {
const [user, orders, balance] = await Promise.all([
this.userService.getUser(userId),
this.orderService.getOrders(userId),
this.paymentService.getBalance(userId),
]);
return { user, orders, balance };
}
}When to use:
- Simplify access to complex subsystems
- Reduce dependencies between clients and subsystems
- Layer your subsystems
Behavioral Patterns
Strategy Pattern
Define a family of interchangeable algorithms:
interface ValidationStrategy {
validate(value: string): boolean;
}
class EmailValidation implements ValidationStrategy {
validate(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
}
class PhoneValidation implements ValidationStrategy {
validate(value: string): boolean {
return /^\+?[\d\s-]{10,}$/.test(value);
}
}
class ZipCodeValidation implements ValidationStrategy {
validate(value: string): boolean {
return /^\d{5}(-\d{4})?$/.test(value);
}
}
// Context
class FormValidator {
private strategy: ValidationStrategy;
setStrategy(strategy: ValidationStrategy): void {
this.strategy = strategy;
}
validate(value: string): boolean {
return this.strategy.validate(value);
}
}
// Usage
const validator = new FormValidator();
validator.setStrategy(new EmailValidation());
console.log(validator.validate('test@example.com')); // trueWhen to use:
- Many related classes differ only in behavior
- Need different variants of an algorithm
- Want to avoid exposing complex algorithm structures
Observer Pattern
Define a subscription mechanism for state changes:
interface Observer {
update(data: any): void;
}
interface Subject {
attach(observer: Observer): void;
detach(observer: Observer): void;
notify(): void;
}
class EventEmitter implements Subject {
private observers: Map<string, Observer[]> = new Map();
private state: any;
attach(observer: Observer, event: string = 'default'): void {
if (!this.observers.has(event)) {
this.observers.set(event, []);
}
this.observers.get(event)!.push(observer);
}
detach(observer: Observer, event: string = 'default'): void {
const observers = this.observers.get(event);
if (observers) {
const index = observers.indexOf(observer);
if (index > -1) {
observers.splice(index, 1);
}
}
}
notify(event: string = 'default'): void {
const observers = this.observers.get(event);
if (observers) {
observers.forEach(observer => observer.update(this.state));
}
}
setState(state: any, event: string = 'default'): void {
this.state = state;
this.notify(event);
}
}When to use:
- Changes in one object require changing others
- An object should notify others without knowing who they are
- Implementing event handling systems
Repository Pattern
Abstract data access behind a collection-like interface:
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
create(entity: Omit<T, 'id'>): Promise<T>;
update(id: string, entity: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
interface User {
id: string;
email: string;
name: string;
}
class UserRepository implements Repository<User> {
private db: Database;
constructor(db: Database) {
this.db = db;
}
async findById(id: string): Promise<User | null> {
return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
}
async findAll(): Promise<User[]> {
return this.db.query('SELECT * FROM users');
}
async create(user: Omit<User, 'id'>): Promise<User> {
const result = await this.db.query(
'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
[user.email, user.name]
);
return result;
}
async update(id: string, user: Partial<User>): Promise<User> {
// Implementation
}
async delete(id: string): Promise<void> {
await this.db.query('DELETE FROM users WHERE id = $1', [id]);
}
}When to use:
- Decouple business logic from data access
- Enable unit testing with mock repositories
- Support multiple data sources
Architecture Patterns
Layered Architecture
Hexagonal Architecture (Ports & Adapters)
Pattern Selection Guide
| Scenario | Recommended Pattern |
|---|---|
| Creating objects without specifying class | Factory |
| Complex object construction | Builder |
| Single shared instance | Singleton (use sparingly) |
| Incompatible interfaces | Adapter |
| Add behavior dynamically | Decorator |
| Simplify complex subsystem | Facade |
| Interchangeable algorithms | Strategy |
| Event notification | Observer |
| Data access abstraction | Repository |
Best Practices
Do
- Use patterns to solve specific problems
- Document pattern usage in ADRs
- Start simple, add patterns when needed
- Ensure team understands patterns used
- Consider testability when choosing patterns
Don't
- Force patterns where they don't fit
- Over-engineer with unnecessary patterns
- Use patterns just because they're "best practice"
- Ignore simpler solutions
- Create unnecessary abstractions
Related Resources
Compliance
This section fulfills ISO 13485 requirements for design outputs (7.3.4) and control of production (7.5.1), and ISO 27001 requirements for secure architecture (A.8.27) and secure development lifecycle (A.8.25).
How is this guide?
Last updated on