Understanding Design Patterns: The Key to Writing Reusable Code
Design patterns provide reusable solutions to common software design problems. By mastering patterns like Singleton, Factory, Observer, and Strategy, developers can create scalable, maintainable, and efficient applications. This guide explains these patterns with real-world scenarios and code examples.
1. Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. It’s commonly used in logging, configuration management, and database connections.
class Singleton {
constructor() {
if (Singleton.instance) {
return Singleton.instance;
}
Singleton.instance = this;
}
}
const instance1 = new Singleton();
const instance2 = new Singleton();
console.log(instance1 === instance2); // true
Real-World Use Case: A logging service that ensures all parts of an application write to the same log file:
class Logger {
constructor() {
if (Logger.instance) {
return Logger.instance;
}
this.logs = [];
Logger.instance = this;
}
log(message) {
this.logs.push(message);
console.log(`LOG: ${message}`);
}
}
const logger1 = new Logger();
const logger2 = new Logger();
logger1.log("First log");
logger2.log("Second log");
console.log(logger1.logs); // ['First log', 'Second log']
2. Factory Pattern
The Factory pattern creates objects without specifying the exact class to instantiate. It’s useful when the type of object required is determined at runtime.
class Car {
constructor(type) {
this.type = type;
}
}
class CarFactory {
static createCar(type) {
return new Car(type);
}
}
const sedan = CarFactory.createCar("Sedan");
const suv = CarFactory.createCar("SUV");
console.log(sedan.type); // Sedan
console.log(suv.type); // SUV
Real-World Use Case: A notification system that creates different types of notifications based on user preferences:
class Notification {
constructor(message) {
this.message = message;
}
send() {
console.log("Sending notification: " + this.message);
}
}
class EmailNotification extends Notification {
send() {
console.log("Sending email: " + this.message);
}
}
class SMSNotification extends Notification {
send() {
console.log("Sending SMS: " + this.message);
}
}
class NotificationFactory {
static createNotification(type, message) {
switch (type) {
case "email":
return new EmailNotification(message);
case "sms":
return new SMSNotification(message);
default:
return new Notification(message);
}
}
}
const email = NotificationFactory.createNotification("email", "Hello via Email!");
email.send(); // Sending email: Hello via Email!
3. Observer Pattern
The Observer pattern establishes a one-to-many relationship where changes in one object (the subject) notify and update dependent objects (observers). It’s widely used in event-driven systems.
class Subject {
constructor() {
this.observers = [];
}
attach(observer) {
this.observers.push(observer);
}
detach(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
notify() {
this.observers.forEach(observer => observer.update());
}
}
class Observer {
update() {
console.log("Observer updated!");
}
}
const subject = new Subject();
const observer1 = new Observer();
const observer2 = new Observer();
subject.attach(observer1);
subject.attach(observer2);
subject.notify(); // Observer updated! (twice)
Real-World Use Case: A stock market tracker where investors (observers) receive updates when a stock price changes:
class Stock {
constructor(price) {
this.price = price;
this.investors = [];
}
addInvestor(investor) {
this.investors.push(investor);
}
updatePrice(newPrice) {
this.price = newPrice;
this.notifyInvestors();
}
notifyInvestors() {
this.investors.forEach(investor => investor.notify(this.price));
}
}
class Investor {
constructor(name) {
this.name = name;
}
notify(price) {
console.log(`${this.name} notified of stock price: ${price}`);
}
}
const stock = new Stock(100);
const investor1 = new Investor("Alice");
const investor2 = new Investor("Bob");
stock.addInvestor(investor1);
stock.addInvestor(investor2);
stock.updatePrice(120); // Alice and Bob get notified
4. Strategy Pattern
The Strategy pattern allows you to define a family of algorithms and make them interchangeable without modifying the client code.
class PaymentStrategy {
processPayment(amount) {
throw new Error("This method should be overridden!");
}
}
class CreditCardPayment extends PaymentStrategy {
processPayment(amount) {
console.log(`Paid ${amount} using Credit Card.`);
}
}
class PayPalPayment extends PaymentStrategy {
processPayment(amount) {
console.log(`Paid ${amount} using PayPal.`);
}
}
class PaymentProcessor {
setStrategy(strategy) {
this.strategy = strategy;
}
process(amount) {
this.strategy.processPayment(amount);
}
}
const paymentProcessor = new PaymentProcessor();
paymentProcessor.setStrategy(new CreditCardPayment());
paymentProcessor.process(100); // Paid 100 using Credit Card.
paymentProcessor.setStrategy(new PayPalPayment());
paymentProcessor.process(200); // Paid 200 using PayPal.
Conclusion
Mastering design patterns like Singleton, Factory, Observer, and Strategy helps developers build robust and maintainable software. By understanding their use cases and applying them effectively, you can solve common coding challenges with ease. Which pattern is your favorite? Share your thoughts below!