Design Patterns
Welcome to design patterns! In this lesson, we'll explore proven solutions to common programming problems. Design patterns help you write cleaner, more maintainable, and scalable code by applying tested architectural approaches.
What are Design Patterns?
Design patterns are reusable solutions to commonly occurring problems in software design. They represent best practices refined over time by experienced developers:
Key Benefits: Design patterns improve code readability, provide a common vocabulary for developers, prevent common mistakes, and make code easier to maintain and extend.
Pattern Categories
Design patterns are typically grouped into three main categories:
1. Creational Patterns
- Deal with object creation mechanisms
- Singleton, Factory, Builder
2. Structural Patterns
- Deal with object composition
- Module, Decorator, Facade
3. Behavioral Patterns
- Deal with object communication
- Observer, Strategy, Command
Singleton Pattern
Ensures a class has only one instance and provides a global point of access to it:
Singleton Implementation:
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = null;
Database.instance = this;
}
connect(url) {
if (!this.connection) {
this.connection = `Connected to ${url}`;
console.log(this.connection);
}
return this.connection;
}
disconnect() {
this.connection = null;
console.log('Disconnected');
}
}
// Usage
const db1 = new Database();
db1.connect('mongodb://localhost');
const db2 = new Database();
db2.connect('mongodb://production'); // Won't create new connection
console.log(db1 === db2); // true - same instance
Modern Singleton with Module:
// database.js
class Database {
constructor() {
this.connection = null;
}
connect(url) {
if (!this.connection) {
this.connection = `Connected to ${url}`;
}
return this.connection;
}
}
// Export a single instance
export default new Database();
// app.js
import db from './database.js';
db.connect('mongodb://localhost');
Use Case: Configuration objects, logging services, database connections, cache managers - anything that should have only one instance throughout the application.
Factory Pattern
Provides an interface for creating objects without specifying their exact class:
Factory Pattern Implementation:
class Car {
constructor(options) {
this.doors = options.doors || 4;
this.color = options.color || 'silver';
this.type = 'car';
}
}
class Truck {
constructor(options) {
this.doors = options.doors || 2;
this.color = options.color || 'white';
this.wheelSize = options.wheelSize || 'large';
this.type = 'truck';
}
}
class Motorcycle {
constructor(options) {
this.color = options.color || 'black';
this.engineSize = options.engineSize || '500cc';
this.type = 'motorcycle';
}
}
// Factory
class VehicleFactory {
createVehicle(type, options) {
switch(type) {
case 'car':
return new Car(options);
case 'truck':
return new Truck(options);
case 'motorcycle':
return new Motorcycle(options);
default:
throw new Error(`Vehicle type ${type} not recognized`);
}
}
}
// Usage
const factory = new VehicleFactory();
const car = factory.createVehicle('car', { color: 'red', doors: 2 });
const truck = factory.createVehicle('truck', { color: 'blue' });
const bike = factory.createVehicle('motorcycle', { engineSize: '1000cc' });
console.log(car); // Car { doors: 2, color: 'red', type: 'car' }
console.log(truck); // Truck { doors: 2, color: 'blue', wheelSize: 'large', type: 'truck' }
Module Pattern
Provides encapsulation and private members using closures:
Module Pattern:
const CounterModule = (function() {
// Private variables
let count = 0;
const maxCount = 100;
// Private function
function logCount() {
console.log(`Current count: ${count}`);
}
// Public API
return {
increment() {
if (count < maxCount) {
count++;
logCount();
}
},
decrement() {
if (count > 0) {
count--;
logCount();
}
},
getCount() {
return count;
},
reset() {
count = 0;
console.log('Counter reset');
}
};
})();
// Usage
CounterModule.increment(); // Current count: 1
CounterModule.increment(); // Current count: 2
console.log(CounterModule.getCount()); // 2
// count is private - cannot access directly
console.log(CounterModule.count); // undefined
Revealing Module Pattern
An improved version of the Module Pattern that clearly defines what is public:
Revealing Module Pattern:
const Calculator = (function() {
// Private variables and functions
let result = 0;
function add(x, y) {
result = x + y;
return result;
}
function subtract(x, y) {
result = x - y;
return result;
}
function multiply(x, y) {
result = x * y;
return result;
}
function getResult() {
return result;
}
function reset() {
result = 0;
}
// Reveal public methods
return {
add,
subtract,
multiply,
getResult,
reset
};
})();
// Usage
Calculator.add(5, 3); // 8
Calculator.multiply(4, 2); // 8
console.log(Calculator.getResult()); // 8
Calculator.reset();
console.log(Calculator.getResult()); // 0
Observer Pattern
Defines a one-to-many dependency where multiple objects can observe a subject:
Observer Pattern Implementation:
class Subject {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}
class Observer {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name} received: ${data}`);
}
}
// Usage
const newsPublisher = new Subject();
const subscriber1 = new Observer('John');
const subscriber2 = new Observer('Jane');
const subscriber3 = new Observer('Bob');
newsPublisher.subscribe(subscriber1);
newsPublisher.subscribe(subscriber2);
newsPublisher.subscribe(subscriber3);
newsPublisher.notify('Breaking News!');
// John received: Breaking News!
// Jane received: Breaking News!
// Bob received: Breaking News!
newsPublisher.unsubscribe(subscriber2);
newsPublisher.notify('Another update');
// John received: Another update
// Bob received: Another update
Real-World Use: Event systems, data binding in frameworks (React, Vue), pub/sub systems, notification systems, and state management.
Decorator Pattern
Adds new functionality to existing objects without altering their structure:
Decorator Pattern:
class Coffee {
cost() {
return 5;
}
description() {
return 'Simple coffee';
}
}
// Decorator function
function withMilk(coffee) {
const cost = coffee.cost();
const description = coffee.description();
coffee.cost = () => cost + 2;
coffee.description = () => `${description}, with milk`;
return coffee;
}
function withSugar(coffee) {
const cost = coffee.cost();
const description = coffee.description();
coffee.cost = () => cost + 1;
coffee.description = () => `${description}, with sugar`;
return coffee;
}
function withWhippedCream(coffee) {
const cost = coffee.cost();
const description = coffee.description();
coffee.cost = () => cost + 3;
coffee.description = () => `${description}, with whipped cream`;
return coffee;
}
// Usage
let myCoffee = new Coffee();
console.log(myCoffee.description()); // Simple coffee
console.log(myCoffee.cost()); // 5
myCoffee = withMilk(myCoffee);
myCoffee = withSugar(myCoffee);
myCoffee = withWhippedCream(myCoffee);
console.log(myCoffee.description()); // Simple coffee, with milk, with sugar, with whipped cream
console.log(myCoffee.cost()); // 11
Strategy Pattern
Defines a family of algorithms and makes them interchangeable:
Strategy Pattern:
// Strategy classes
class CreditCardPayment {
pay(amount) {
console.log(`Paid $${amount} using Credit Card`);
}
}
class PayPalPayment {
pay(amount) {
console.log(`Paid $${amount} using PayPal`);
}
}
class CryptoPayment {
pay(amount) {
console.log(`Paid $${amount} using Cryptocurrency`);
}
}
// Context
class ShoppingCart {
constructor(paymentStrategy) {
this.paymentStrategy = paymentStrategy;
this.amount = 0;
}
setPaymentStrategy(strategy) {
this.paymentStrategy = strategy;
}
addToCart(price) {
this.amount += price;
}
checkout() {
this.paymentStrategy.pay(this.amount);
this.amount = 0;
}
}
// Usage
const cart = new ShoppingCart(new CreditCardPayment());
cart.addToCart(100);
cart.addToCart(50);
cart.checkout(); // Paid $150 using Credit Card
cart.setPaymentStrategy(new PayPalPayment());
cart.addToCart(75);
cart.checkout(); // Paid $75 using PayPal
Command Pattern
Encapsulates actions as objects, allowing parameterization and queuing:
Command Pattern:
// Receiver
class Light {
turnOn() {
console.log('Light is ON');
}
turnOff() {
console.log('Light is OFF');
}
}
// Commands
class TurnOnCommand {
constructor(light) {
this.light = light;
}
execute() {
this.light.turnOn();
}
undo() {
this.light.turnOff();
}
}
class TurnOffCommand {
constructor(light) {
this.light = light;
}
execute() {
this.light.turnOff();
}
undo() {
this.light.turnOn();
}
}
// Invoker
class RemoteControl {
constructor() {
this.history = [];
}
execute(command) {
command.execute();
this.history.push(command);
}
undo() {
const command = this.history.pop();
if (command) {
command.undo();
}
}
}
// Usage
const light = new Light();
const remote = new RemoteControl();
const turnOn = new TurnOnCommand(light);
const turnOff = new TurnOffCommand(light);
remote.execute(turnOn); // Light is ON
remote.execute(turnOff); // Light is OFF
remote.undo(); // Light is ON
remote.undo(); // Light is OFF
Real-World Example: Building a Form Validator
Combining multiple patterns in a practical application:
Form Validator with Strategy and Chain of Responsibility:
// Strategy Pattern - Different validation strategies
class RequiredValidator {
validate(value) {
return value.trim().length > 0 ? null : 'This field is required';
}
}
class EmailValidator {
validate(value) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(value) ? null : 'Invalid email format';
}
}
class MinLengthValidator {
constructor(minLength) {
this.minLength = minLength;
}
validate(value) {
return value.length >= this.minLength
? null
: `Minimum length is ${this.minLength}`;
}
}
// Field class
class Field {
constructor(name) {
this.name = name;
this.value = '';
this.validators = [];
this.errors = [];
}
addValidator(validator) {
this.validators.push(validator);
return this; // Method chaining
}
setValue(value) {
this.value = value;
this.validate();
}
validate() {
this.errors = [];
for (const validator of this.validators) {
const error = validator.validate(this.value);
if (error) {
this.errors.push(error);
}
}
return this.errors.length === 0;
}
getErrors() {
return this.errors;
}
}
// Form class (Facade Pattern)
class Form {
constructor() {
this.fields = {};
}
addField(name) {
this.fields[name] = new Field(name);
return this.fields[name];
}
setFieldValue(name, value) {
if (this.fields[name]) {
this.fields[name].setValue(value);
}
}
validate() {
let isValid = true;
for (const fieldName in this.fields) {
if (!this.fields[fieldName].validate()) {
isValid = false;
}
}
return isValid;
}
getErrors() {
const errors = {};
for (const fieldName in this.fields) {
const fieldErrors = this.fields[fieldName].getErrors();
if (fieldErrors.length > 0) {
errors[fieldName] = fieldErrors;
}
}
return errors;
}
}
// Usage
const registrationForm = new Form();
registrationForm
.addField('email')
.addValidator(new RequiredValidator())
.addValidator(new EmailValidator());
registrationForm
.addField('password')
.addValidator(new RequiredValidator())
.addValidator(new MinLengthValidator(8));
// Set values
registrationForm.setFieldValue('email', 'invalid-email');
registrationForm.setFieldValue('password', 'short');
// Validate
if (!registrationForm.validate()) {
console.log(registrationForm.getErrors());
// {
// email: ['Invalid email format'],
// password: ['Minimum length is 8']
// }
}
Practice Exercise:
Task: Implement a Logger system using the Singleton and Observer patterns:
- Create a Logger singleton that can log messages at different levels (info, warning, error)
- Add observers that react to logs (ConsoleObserver, FileObserver)
- Allow filtering logs by level
Solution:
// Singleton Logger
class Logger {
constructor() {
if (Logger.instance) {
return Logger.instance;
}
this.observers = [];
Logger.instance = this;
}
addObserver(observer) {
this.observers.push(observer);
}
log(level, message) {
const logEntry = {
level,
message,
timestamp: new Date().toISOString()
};
this.observers.forEach(observer => observer.update(logEntry));
}
info(message) {
this.log('info', message);
}
warning(message) {
this.log('warning', message);
}
error(message) {
this.log('error', message);
}
}
// Observers
class ConsoleObserver {
constructor(minLevel = 'info') {
this.levels = { info: 1, warning: 2, error: 3 };
this.minLevel = minLevel;
}
update(logEntry) {
if (this.levels[logEntry.level] >= this.levels[this.minLevel]) {
console.log(`[${logEntry.level.toUpperCase()}] ${logEntry.timestamp}: ${logEntry.message}`);
}
}
}
class FileObserver {
constructor() {
this.logs = [];
}
update(logEntry) {
this.logs.push(logEntry);
}
save() {
console.log('Saving logs to file:', this.logs);
}
}
// Usage
const logger = new Logger();
const consoleObserver = new ConsoleObserver('warning');
const fileObserver = new FileObserver();
logger.addObserver(consoleObserver);
logger.addObserver(fileObserver);
logger.info('Application started'); // Not shown in console (below warning)
logger.warning('Low memory'); // [WARNING] timestamp: Low memory
logger.error('Database connection lost'); // [ERROR] timestamp: Database connection lost
fileObserver.save(); // Saves all logs including info
Summary
In this lesson, you learned:
- Design patterns are proven solutions to common programming problems
- Singleton pattern ensures only one instance of a class exists
- Factory pattern creates objects without specifying their exact class
- Module pattern provides encapsulation using closures
- Observer pattern enables publish-subscribe communication
- Decorator pattern adds functionality without modifying original code
- Strategy pattern makes algorithms interchangeable
- Command pattern encapsulates actions as objects
Next Up: In the next lesson, we'll explore Error Handling best practices in JavaScript!