Introduction to Design Patterns
Design patterns are reusable solutions to common problems that occur in software design. They represent best practices evolved over time and provide a common vocabulary for developers to communicate effectively about software architecture.
In this comprehensive guide, we'll explore the most essential design patterns for modern web development, complete with practical examples in JavaScript and TypeScript. Whether you're building a simple website or a complex web application, understanding these patterns will help you write more maintainable and scalable code.
Why Design Patterns Matter
Design patterns offer several key benefits:
- Code Reusability: Write once, use many times
- Maintainability: Easier to modify and extend
- Scalability: Handle growth and complexity
- Team Communication: Common vocabulary
- Best Practices: Proven solutions to common problems
Creational Patterns
Creational patterns deal with object creation mechanisms, trying to create objects in a manner suitable to the situation.
1. Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. This is particularly useful for database connections, logging, and configuration management.
class DatabaseConnection {
private static instance: DatabaseConnection;
private connection: any;
private constructor() {
// Private constructor prevents direct instantiation
this.connection = this.initializeConnection();
}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
private initializeConnection() {
// Initialize database connection
return { connected: true };
}
public query(sql: string) {
// Execute database query
console.log(`Executing: ${sql}`);
}
}
// Usage
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true - same instance
2. Factory Pattern
The Factory pattern creates objects without specifying their exact class. It's useful when you need to create objects based on certain conditions or configurations.
interface PaymentProcessor {
processPayment(amount: number): boolean;
}
class CreditCardProcessor implements PaymentProcessor {
processPayment(amount: number): boolean {
console.log(`Processing credit card payment: $${amount}`);
return true;
}
}
class PayPalProcessor implements PaymentProcessor {
processPayment(amount: number): boolean {
console.log(`Processing PayPal payment: $${amount}`);
return true;
}
}
class PaymentProcessorFactory {
static createProcessor(type: 'creditcard' | 'paypal'): PaymentProcessor {
switch (type) {
case 'creditcard':
return new CreditCardProcessor();
case 'paypal':
return new PayPalProcessor();
default:
throw new Error('Unsupported payment type');
}
}
}
// Usage
const processor = PaymentProcessorFactory.createProcessor('creditcard');
processor.processPayment(100);
Structural Patterns
Structural patterns deal with object composition and relationships between entities, making it easier to design systems where the relationships between objects are more flexible.
3. Adapter Pattern
The Adapter pattern allows incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces.
// Old API
class OldAPI {
fetchData() {
return { data: 'old format', version: '1.0' };
}
}
// New API interface
interface NewAPI {
getData(): { content: string; apiVersion: string };
}
// Adapter
class APIAdapter implements NewAPI {
private oldAPI: OldAPI;
constructor(oldAPI: OldAPI) {
this.oldAPI = oldAPI;
}
getData() {
const oldData = this.oldAPI.fetchData();
return {
content: oldData.data,
apiVersion: oldData.version
};
}
}
// Usage
const oldAPI = new OldAPI();
const adapter = new APIAdapter(oldAPI);
const newFormatData = adapter.getData();
Behavioral Patterns
Behavioral patterns focus on communication between objects and the flow of control through a system.
4. Observer Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
interface Observer {
update(data: any): void;
}
class Subject {
private observers: Observer[] = [];
private state: any;
addObserver(observer: Observer): void {
this.observers.push(observer);
}
removeObserver(observer: Observer): void {
const index = this.observers.indexOf(observer);
if (index > -1) {
this.observers.splice(index, 1);
}
}
notifyObservers(): void {
this.observers.forEach(observer => observer.update(this.state));
}
setState(newState: any): void {
this.state = newState;
this.notifyObservers();
}
}
class EmailNotification implements Observer {
update(data: any): void {
console.log(`Email: Order status changed to ${data.status}`);
}
}
class SMSNotification implements Observer {
update(data: any): void {
console.log(`SMS: Order status changed to ${data.status}`);
}
}
// Usage
const orderSubject = new Subject();
const emailNotifier = new EmailNotification();
const smsNotifier = new SMSNotification();
orderSubject.addObserver(emailNotifier);
orderSubject.addObserver(smsNotifier);
orderSubject.setState({ status: 'shipped', orderId: '12345' });
5. Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it.
interface SortingStrategy {
sort(data: number[]): number[];
}
class BubbleSort implements SortingStrategy {
sort(data: number[]): number[] {
console.log('Using bubble sort');
return [...data].sort((a, b) => a - b);
}
}
class QuickSort implements SortingStrategy {
sort(data: number[]): number[] {
console.log('Using quick sort');
return [...data].sort((a, b) => a - b);
}
}
class Sorter {
private strategy: SortingStrategy;
constructor(strategy: SortingStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: SortingStrategy): void {
this.strategy = strategy;
}
sort(data: number[]): number[] {
return this.strategy.sort(data);
}
}
// Usage
const data = [64, 34, 25, 12, 22, 11, 90];
const sorter = new Sorter(new BubbleSort());
console.log(sorter.sort(data));
sorter.setStrategy(new QuickSort());
console.log(sorter.sort(data));
Modern JavaScript/TypeScript Patterns
With modern JavaScript and TypeScript, we can implement design patterns more elegantly using features like modules, decorators, and advanced type system.
6. Module Pattern
The Module pattern provides a way to create private and public methods and variables, protecting parts from leaking into the global scope.
// userService.ts
export class UserService {
private users: Map<string, any> = new Map();
public createUser(userData: any): string {
const id = this.generateId();
this.users.set(id, { ...userData, id });
return id;
}
public getUser(id: string): any {
return this.users.get(id);
}
private generateId(): string {
return Math.random().toString(36).substr(2, 9);
}
}
// Export singleton instance
export const userService = new UserService();
Best Practices and Tips
- Don't overuse patterns - use them when they solve real problems
- Understand the trade-offs of each pattern
- Consider your team's familiarity with patterns
- Start simple and refactor to patterns when needed
- Use TypeScript for better type safety with patterns
Conclusion
Design patterns are powerful tools that can significantly improve your code quality and maintainability. However, they should be used judiciously. The key is to understand when and how to apply them effectively.
Start by mastering the fundamental patterns we've covered in this guide, then gradually explore more advanced patterns as your projects grow in complexity. Remember, the goal is to write code that is not just functional, but also maintainable, scalable, and easy to understand.
Design patterns are not a silver bullet, but they are valuable tools in a developer's toolkit. Use them wisely, and they will help you build better software.