← Back to Resources

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

  1. Start Simple: Begin with Observer and Factory - they're the most commonly used
  2. Don't Over-Engineer: Use patterns when they solve a real problem, not just because you can
  3. Combine Patterns: These patterns often work together (e.g., Factory + Singleton)
  4. Practice: Refactor existing code to use these patterns to truly understand them
  5. Know When Not to Use: Understanding when NOT to use a pattern is as important as knowing when to use it

Next Steps

  • Implement these patterns in your current projects
  • Look for opportunities to refactor existing code
  • Study other patterns: Decorator, Proxy, Command, Template Method
  • Read "Design Patterns: Elements of Reusable Object-Oriented Software" (Gang of Four)

Remember: Design patterns are tools, not rules. Use them wisely to improve code quality, not complicate it!