5 Design Patterns That Changed My Code Quality
Master these essential patterns to write cleaner, more maintainable, and scalable code.
1. Observer Pattern 👁️
Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified automatically.
When to Use
- Event handling systems
- Data binding in UI frameworks
- Real-time notifications
- Pub/Sub messaging systems
- Model-View updates
Advantages
- Loose Coupling: Subject and observers are loosely coupled
- Dynamic Relationships: Can add/remove observers at runtime
- Broadcast Communication: One-to-many communication made easy
Disadvantages
- Memory Leaks: Observers not properly unsubscribed can cause leaks
- Unexpected Updates: Can lead to cascading updates
- Debugging Complexity: Hard to track the flow of notifications
Code Example
// Observer Pattern - Event System
// Subject (Observable)
class NewsAgency {
constructor() {
this.observers = [];
this.news = null;
}
attach(observer) {
this.observers.push(observer);
}
detach(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
notify() {
this.observers.forEach(observer => observer.update(this.news));
}
setNews(news) {
this.news = news;
this.notify();
}
}
// Observers
class NewsChannel {
constructor(name) {
this.name = name;
}
update(news) {
console.log(`${this.name} received: ${news}`);
}
}
// Usage
const agency = new NewsAgency();
const channel1 = new NewsChannel("CNN");
const channel2 = new NewsChannel("BBC");
agency.attach(channel1);
agency.attach(channel2);
agency.setNews("Breaking: Design Patterns are awesome!");
2. Factory Pattern 🏭
Define an interface for creating objects, but let subclasses decide which class to instantiate.
When to Use
- Object creation without specifying exact class
- Complex initialization logic
- Creating different objects based on conditions
- Plugin architectures
- Managing object pools
Advantages
- Single Responsibility: Object creation logic is separated
- Open/Closed Principle: Easy to add new types without modifying existing code
- Decouples Code: Client code doesn't depend on concrete classes
Disadvantages
- Added Complexity: Can make code more complex with extra classes
- More Classes: Increases the number of classes to manage
Code Example
// Factory Pattern - Vehicle Factory
class Vehicle {
constructor(model, type) {
this.model = model;
this.type = type;
}
displayInfo() {
return `${this.type}: ${this.model}`;
}
}
class Car extends Vehicle {
constructor(model) {
super(model, 'Car');
this.wheels = 4;
}
drive() {
return `Driving the ${this.model} car`;
}
}
class VehicleFactory {
static createVehicle(type, model) {
switch(type.toLowerCase()) {
case 'car':
return new Car(model);
case 'motorcycle':
return new Motorcycle(model);
case 'truck':
return new Truck(model);
default:
throw new Error('Unknown vehicle type');
}
}
}
// Usage
const car = VehicleFactory.createVehicle('car', 'Tesla Model 3');
console.log(car.drive()); // Driving the Tesla Model 3 car
3. Singleton Pattern 🔐
Ensure a class has only one instance and provide a global point of access to it.
When to Use
- Database connections
- Configuration managers
- Logging systems
- Caching mechanisms
- Thread pools
- Device drivers
Advantages
- Controlled Access: Single instance ensures controlled access
- Reduced Namespace Pollution: No global variables needed
- Lazy Initialization: Instance created only when needed
- Consistent State: Shared state across application
Disadvantages
- Global State: Can introduce global state issues
- Testing Difficulties: Hard to mock in unit tests
- Hidden Dependencies: Dependencies are hidden in code
- Concurrency Issues: Needs special handling in multi-threaded environments
Code Example
// Singleton Pattern - Database Connection
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = null;
this.queries = 0;
Database.instance = this;
}
connect(connectionString) {
if (!this.connection) {
this.connection = connectionString;
console.log(`Connected to: ${connectionString}`);
}
return this.connection;
}
static getInstance() {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
}
// Usage
const db1 = new Database();
db1.connect('mongodb://localhost:27017');
const db2 = new Database();
console.log(db1 === db2); // true
4. Strategy Pattern 🎯
Define a family of algorithms, encapsulate each one, and make them interchangeable.
When to Use
- Multiple payment methods
- Different sorting algorithms
- Various compression algorithms
- Validation rules
- Route planning algorithms
- Pricing strategies
Advantages
- Open/Closed Principle: Easy to add new strategies without modifying context
- Runtime Switching: Can switch algorithms at runtime
- Isolates Implementation: Algorithm details hidden from client
- Eliminates Conditionals: Replaces large conditional statements
Disadvantages
- Client Awareness: Clients must be aware of different strategies
- Increased Objects: More objects in the system
- Communication Overhead: Context and strategy must communicate
Code Example
// Strategy Pattern - Payment Processing
class PaymentStrategy {
pay(amount) {
throw new Error('pay() must be implemented');
}
}
class CreditCardPayment extends PaymentStrategy {
constructor(cardNumber) {
super();
this.cardNumber = cardNumber;
}
pay(amount) {
console.log(`Processing credit card payment...`);
return `Paid $${amount} with card ending in ${this.cardNumber.slice(-4)}`;
}
}
class ShoppingCart {
constructor() {
this.items = [];
this.paymentStrategy = null;
}
setPaymentStrategy(strategy) {
this.paymentStrategy = strategy;
}
checkout(amount) {
if (!this.paymentStrategy) {
throw new Error('Payment strategy not set');
}
return this.paymentStrategy.pay(amount);
}
}
// Usage
const cart = new ShoppingCart();
cart.setPaymentStrategy(new CreditCardPayment('1234567890123456'));
console.log(cart.checkout(100)); // Paid $100 with card ending in 3456
5. Adapter Pattern 🔌
Convert the interface of a class into another interface clients expect.
When to Use
- Legacy code integration
- Third-party library integration
- API versioning
- Making incompatible interfaces work together
- Wrapping libraries for easier testing
Advantages
- Single Responsibility: Separation of interface conversion from business logic
- Open/Closed Principle: Can introduce new adapters without changing existing code
- Integrates Incompatible Interfaces: Makes incompatible classes work together
Disadvantages
- Increased Complexity: Adds additional layer of abstraction
- Sometimes Simpler to Change Service: Might be easier to just update the original interface
- Performance Overhead: Additional indirection can impact performance
Code Example
// Adapter Pattern - Payment Gateway Integration
class OldPaymentGateway {
processOldPayment(accountNumber, amount) {
console.log(`[OLD SYSTEM] Processing $${amount} from account ${accountNumber}`);
return {
success: true,
transactionId: `OLD-${Date.now()}`
};
}
}
class NewPaymentProcessor {
pay(paymentDetails) {
throw new Error('pay() must be implemented');
}
}
class PaymentAdapter extends NewPaymentProcessor {
constructor(oldGateway) {
super();
this.oldGateway = oldGateway;
}
pay(paymentDetails) {
const result = this.oldGateway.processOldPayment(
paymentDetails.account,
paymentDetails.amount
);
return {
status: result.success ? 'completed' : 'failed',
transactionId: result.transactionId
};
}
}
// Usage
const oldGateway = new OldPaymentGateway();
const adapter = new PaymentAdapter(oldGateway);
const result = adapter.pay({
account: '123456',
amount: 100,
email: 'customer@example.com'
});
console.log(`Payment status: ${result.status}`); // Payment status: completed
Quick Reference Table
| Pattern | Purpose | Real-World Example |
|---|---|---|
| Observer | One-to-many notification | YouTube notifications when a channel uploads |
| Factory | Object creation abstraction | Document factory creating PDF, Word, or Excel files |
| Singleton | Single instance guarantee | App configuration settings |
| Strategy | Interchangeable algorithms | Google Maps routing (fastest, shortest, avoid highways) |
| Adapter | Interface compatibility | Power adapter for different outlets |
Pro Tips
- Start Simple: Begin with Observer and Factory - they're the most commonly used
- Don't Over-Engineer: Use patterns when they solve a real problem, not just because you can
- Combine Patterns: These patterns often work together (e.g., Factory + Singleton)
- Practice: Refactor existing code to use these patterns to truly understand them
- Know When Not to Use: Understanding when NOT to use a pattern is as important as knowing when to use it