7 Essential Software Design Patterns
Software design patterns are tried-and-true solutions to common programming problems that appear across different languages and platforms. These patterns aren't complicated concepts—they're simply proven approaches that experienced developers use to write cleaner, more maintainable code.
Back in 1994, four developers known as the "Gang of Four" documented 23 commonly used design patterns in their influential book. While we won't cover all 23 here, understanding these seven essential patterns will significantly improve your code quality and problem-solving abilities.
All design patterns fall into three main categories:
- Creational Patterns: Focus on object creation and provide flexibility in how objects come into existence
- Structural Patterns: Deal with how objects relate to each other, like blueprints for building larger structures
- Behavioral Patterns: Handle communication between objects and how they interact and distribute responsibilities
Creational Patterns
1. Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides global access to that instance. Think of it as your application's logging system—when errors occur throughout your app, you want one central logger handling everything with consistent formatting.
Without Singleton:
const logger1 = new Logger();
const logger2 = new Logger(); // Multiple loggers creating chaos
With Singleton:
class Logger {
constructor() {
if (Logger.instance) {
return Logger.instance;
}
this.logs = [];
Logger.instance = this;
}
static getInstance() {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message) {
this.logs.push(`${new Date().toISOString()}: ${message}`);
console.log(message);
}
}
// Usage
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();
console.log(logger1 === logger2); // true - same instance
When to use: When you absolutely need a single instance that's globally accessible, like database connection pools or configuration managers.
Pros: Guaranteed single instance and global access Cons: Difficult to test and can become a glorified global variable
2. Builder Pattern
The Builder pattern is perfect for creating complex objects with multiple optional parameters. Instead of having constructors with 15+ parameters, you can chain methods in any order and skip optional ones.
Without Builder:
const request = new HTTPRequest(
"https://api.example.com",
"POST",
headers,
queryParams,
bodyData,
timeout,
retryLogic
);
With Builder:
class RequestBuilder {
constructor() {
this.config = {
url: '',
method: 'GET',
headers: {},
timeout: 5000,
retries: 3
};
}
url(url) {
this.config.url = url;
return this;
}
method(method) {
this.config.method = method;
return this;
}
header(key, value) {
this.config.headers[key] = value;
return this;
}
timeout(timeout) {
this.config.timeout = timeout;
return this;
}
retries(retries) {
this.config.retries = retries;
return this;
}
build() {
return new HTTPRequest(this.config);
}
}
class HTTPRequest {
constructor(config) {
this.config = config;
}
async execute() {
// Implementation here
console.log('Executing request with config:', this.config);
}
}
// Usage
const request = new RequestBuilder()
.url("https://api.example.com")
.method("POST")
.header("Content-Type", "application/json")
.timeout(10000)
.retries(5)
.build();
When to use: When constructors have more parameters than you have fingers, or when you need to construct objects step by step.
3. Factory Pattern
The Factory pattern centralizes object creation logic, hiding the complexity of instantiation from client code. Instead of scattering new
keywords throughout your codebase, you delegate object creation to a factory.
Without Factory:
let user;
if (type === "admin") {
user = new AdminUser();
} else if (type === "moderator") {
user = new ModeratorUser();
} else {
user = new RegularUser();
}
With Factory:
class AdminUser {
constructor(id, name) {
this.id = id;
this.name = name;
this.role = 'admin';
this.permissions = ['read', 'write', 'delete', 'manage'];
}
}
class ModeratorUser {
constructor(id, name) {
this.id = id;
this.name = name;
this.role = 'moderator';
this.permissions = ['read', 'write', 'moderate'];
}
}
class RegularUser {
constructor(id, name) {
this.id = id;
this.name = name;
this.role = 'user';
this.permissions = ['read'];
}
}
class UserFactory {
static createUser(type, id, name) {
switch (type) {
case 'admin':
return new AdminUser(id, name);
case 'moderator':
return new ModeratorUser(id, name);
default:
return new RegularUser(id, name);
}
}
}
// Usage
const admin = UserFactory.createUser('admin', '1', 'John');
const user = UserFactory.createUser('user', '2', 'Jane');
When to use: When you see the new
keyword scattered throughout your codebase for creating similar objects.
Structural Patterns
4. Facade Pattern
The Facade pattern provides a simplified interface to a complex subsystem. It's like clicking a "Buy Now" button on a website—behind that simple action, there's payment processing, inventory checks, shipping calculations, and fraud detection.
Without Facade:
const paymentProcessor = new PaymentProcessor();
const inventorySystem = new InventorySystem();
const fraudChecker = new FraudChecker();
if (fraudChecker.isValid(order) &&
inventorySystem.isAvailable(order.item) &&
paymentProcessor.processPayment(order.payment)) {
// Place order logic
}
With Facade:
class PaymentProcessor {
processPayment(paymentInfo) {
console.log('Processing payment...');
return true; // Simulate success
}
}
class InventorySystem {
isAvailable(item) {
console.log('Checking inventory...');
return true; // Simulate availability
}
}
class FraudChecker {
isValid(order) {
console.log('Checking for fraud...');
return true; // Simulate valid order
}
}
class OrderFacade {
constructor() {
this.paymentProcessor = new PaymentProcessor();
this.inventorySystem = new InventorySystem();
this.fraudChecker = new FraudChecker();
}
placeOrder(order) {
console.log('Placing order...');
if (!this.fraudChecker.isValid(order)) {
throw new Error('Order failed fraud check');
}
if (!this.inventorySystem.isAvailable(order.item)) {
throw new Error('Item not available');
}
if (!this.paymentProcessor.processPayment(order.payment)) {
throw new Error('Payment failed');
}
console.log('Order placed successfully!');
return true;
}
}
// Usage
const orderFacade = new OrderFacade();
const order = {
item: 'laptop',
payment: { amount: 1000, method: 'credit' }
};
orderFacade.placeOrder(order);
When to use: When you have complex subsystems that need simplification. It's essentially a fancy word for encapsulation.
5. Adapter Pattern
The Adapter pattern allows incompatible interfaces to work together. Just like using a USB-to-HDMI adapter to connect your laptop to a TV, this pattern bridges the gap between different APIs or libraries.
Without Adapter:
const thirdPartyWeatherAPI = new ThirdPartyWeatherAPI();
const tempC = thirdPartyWeatherAPI.getTempC();
const tempF = (tempC * 9/5) + 32; // Conversion logic everywhere
With Adapter:
// Third-party API that returns data in Celsius and km/h
class ThirdPartyWeatherAPI {
getTempC() {
return 25; // Celsius
}
getSpeedKmh() {
return 50; // km/h
}
}
// Our app expects Fahrenheit and mph
class WeatherAdapter {
constructor(thirdPartyAPI) {
this.thirdPartyAPI = thirdPartyAPI;
}
getTempF() {
const tempC = this.thirdPartyAPI.getTempC();
return (tempC * 9/5) + 32;
}
getSpeedMph() {
const speedKmh = this.thirdPartyAPI.getSpeedKmh();
return speedKmh * 0.621371;
}
}
// Usage
const thirdPartyAPI = new ThirdPartyWeatherAPI();
const weather = new WeatherAdapter(thirdPartyAPI);
if (weather.getTempF() > 75) {
console.log("It's hot!");
}
if (weather.getSpeedMph() > 10) {
console.log("It's windy!");
}
When to use: When integrating third-party libraries or APIs that don't match your application's expected interface.
Behavioral Patterns
6. Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. Think about commuting to work—you have different strategies (driving, biking, taking the bus) to achieve the same goal.
Without Strategy:
class Commuter {
goToWork(transport) {
if (transport === 'car') {
console.log('Start car, check gas, navigate traffic, find parking');
} else if (transport === 'bus') {
console.log('Walk to bus stop, wait for bus, pay fare');
} else if (transport === 'bike') {
console.log('Get bike, check tire pressure, pedal to work');
}
}
}
With Strategy:
// Strategy interface (implicit in JavaScript)
class TransportStrategy {
travel() {
throw new Error('travel() method must be implemented');
}
}
class CarStrategy extends TransportStrategy {
travel() {
console.log('Start car, check gas, navigate traffic, find parking');
}
}
class BusStrategy extends TransportStrategy {
travel() {
console.log('Walk to bus stop, wait for bus, pay fare');
}
}
class BikeStrategy extends TransportStrategy {
travel() {
console.log('Get bike, check tire pressure, pedal to work');
}
}
class WalkStrategy extends TransportStrategy {
travel() {
console.log('Put on comfortable shoes, walk to work');
}
}
class Commuter {
constructor() {
this.strategy = null;
}
setStrategy(strategy) {
this.strategy = strategy;
}
goToWork() {
if (!this.strategy) {
throw new Error('No transport strategy set');
}
this.strategy.travel();
}
}
// Usage
const commuter = new Commuter();
commuter.setStrategy(new CarStrategy());
commuter.goToWork();
commuter.setStrategy(new BikeStrategy());
commuter.goToWork();
commuter.setStrategy(new WalkStrategy());
commuter.goToWork();
When to use: Whenever you have different ways of doing the same thing. It follows the open-closed principle—you can add new strategies without modifying existing code.
7. Observer Pattern
The Observer pattern allows objects to subscribe to events that happen to other objects. Think of YouTube notifications—when you subscribe and hit the bell, you get notified whenever a new video is uploaded.
Without Observer:
class VideoChannel {
constructor() {
this.subscribers = [];
}
uploadVideo(title) {
// Manually notify each subscriber
this.subscribers.forEach(user => {
user.notify(`New video: ${title}`);
});
}
}
With Observer:
class VideoChannel {
constructor() {
this.subscribers = [];
}
subscribe(subscriber) {
this.subscribers.push(subscriber);
}
unsubscribe(subscriber) {
this.subscribers = this.subscribers.filter(sub => sub !== subscriber);
}
uploadVideo(title) {
console.log(`Uploading video: ${title}`);
this.notifySubscribers(`New video: ${title}`);
}
notifySubscribers(message) {
this.subscribers.forEach(subscriber => {
subscriber.notify(message);
});
}
}
class User {
constructor(name) {
this.name = name;
}
notify(message) {
console.log(`${this.name} received notification: ${message}`);
}
}
// Usage
const channel = new VideoChannel();
const user1 = new User('John');
const user2 = new User('Jane');
channel.subscribe(user1);
channel.subscribe(user2);
channel.uploadVideo('Design Patterns Tutorial');
// Output:
// Uploading video: Design Patterns Tutorial
// John received notification: New video: Design Patterns Tutorial
// Jane received notification: New video: Design Patterns Tutorial
channel.unsubscribe(user1);
channel.uploadVideo('Advanced JavaScript');
// Output:
// Uploading video: Advanced JavaScript
// Jane received notification: New video: Advanced JavaScript
Alternative using EventEmitter (Node.js):
const EventEmitter = require('events');
class VideoChannel extends EventEmitter {
uploadVideo(title) {
console.log(`Uploading video: ${title}`);
this.emit('videoUploaded', title);
}
}
// Usage
const channel = new VideoChannel();
channel.on('videoUploaded', (title) => {
console.log(`John received notification: New video: ${title}`);
});
channel.on('videoUploaded', (title) => {
console.log(`Jane received notification: New video: ${title}`);
});
channel.uploadVideo('Design Patterns Tutorial');
When to use: When you need to implement "if this happens, do that" functionality. Perfect for event-driven systems, monitoring, and state change notifications.
Conclusion
These seven design patterns are fundamental tools in every developer's toolkit. Like any good tool, success lies in knowing when to use each one:
- Use Singleton when you need exactly one instance globally accessible
- Use Builder for complex object construction with many optional parameters
- Use Factory to centralize object creation logic
- Use Facade to simplify complex subsystems
- Use Adapter to make incompatible interfaces work together
- Use Strategy whenever you have different ways of doing the same thing
- Use Observer for event-driven programming and notifications
Remember, these patterns exist because they solve common problems that appear repeatedly in software development. Master them, and you'll write cleaner, more maintainable code that your future self—and your teammates—will thank you for.
The key is practice. Start identifying these patterns in your existing code, and look for opportunities to refactor using these proven solutions. Your code will become more readable, flexible, and maintainable as a result.