Design Patterns
Design Patterns
Introduction to Design Patterns
Design patterns are typical solutions to common problems in software design. They represent best practices evolved over time by experienced developers and provide templates for solving problems that can be used in many different situations.
Design patterns are not specific to any programming language but rather represent concepts that can be applied across different languages and technologies. They help developers create more maintainable, flexible, and understandable code.
Types of Design Patterns
Creational Patterns
Focus on object creation mechanisms, helping to create objects in a suitable manner
Structural Patterns
Deal with object composition and identify relationships between entities
Behavioral Patterns
Identify common communication patterns between objects
This guide explores popular design patterns with practical examples and explanations of when and how to use them effectively.
Creational Patterns
Creational patterns focus on object creation mechanisms, helping to create objects in a manner suitable to the situation.
Factory Method Pattern
The Factory Method pattern defines an interface for creating an object but lets subclasses or implementing functions decide which class to instantiate. It promotes loose coupling by eliminating the need to bind application-specific classes to code.
Factory Method Pattern
When to Use the Factory Method Pattern
- When a class cannot anticipate the type of objects it needs to create
- When a class wants its subclasses to specify the objects it creates
- When you want to localize the knowledge of which class gets created
Example: Factory Method
// Product interface
class Payment {
process() {
throw new Error("Method 'process()' must be implemented");
}
}
// Concrete products
class CreditCardPayment extends Payment {
constructor(cardDetails) {
super();
this.cardDetails = cardDetails;
}
process() {
console.log("Processing credit card payment");
// Implementation details
}
}
class PayPalPayment extends Payment {
constructor(email) {
super();
this.email = email;
}
process() {
console.log("Processing PayPal payment");
// Implementation details
}
}
class CryptoPayment extends Payment {
constructor(walletAddress) {
super();
this.walletAddress = walletAddress;
}
process() {
console.log("Processing crypto payment");
// Implementation details
}
}
// Factory method
class PaymentFactory {
static createPayment(type, paymentDetails) {
switch (type) {
case 'credit':
return new CreditCardPayment(paymentDetails);
case 'paypal':
return new PayPalPayment(paymentDetails);
case 'crypto':
return new CryptoPayment(paymentDetails);
default:
throw new Error(`Payment type ${type} not supported`);
}
}
}
// Usage
const creditPayment = PaymentFactory.createPayment('credit', { cardNumber: '4111111111111111', expiryDate: '12/25' });
creditPayment.process();
const paypalPayment = PaymentFactory.createPayment('paypal', 'user@example.com');
paypalPayment.process();
Singleton Pattern
The Singleton pattern ensures a class has only one instance and provides a global point of access to it. This is useful for resources that should be shared across the application.
Singleton Pattern
When to Use the Singleton Pattern
- When exactly one instance of a class is needed throughout the application
- When you need strict control over global variables
- For shared resources like configuration managers, connection pools, or caches
Example: Singleton
class DatabaseConnection {
constructor() {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance;
}
// Initialize database connection
this.connection = null;
this.connected = false;
DatabaseConnection.instance = this;
}
connect() {
if (this.connected) {
console.log("Already connected");
return;
}
console.log("Establishing database connection...");
// Connection logic here
this.connection = { id: Math.random().toString(36).substr(2, 9) };
this.connected = true;
console.log(`Connected with ID: ${this.connection.id}`);
}
query(sql) {
if (!this.connected) {
throw new Error("Not connected to database");
}
console.log(`Executing query using connection ${this.connection.id}: ${sql}`);
// Query logic here
}
disconnect() {
if (!this.connected) {
console.log("Not connected");
return;
}
console.log(`Closing connection ${this.connection.id}`);
this.connected = false;
this.connection = null;
}
}
// Usage
const dbConnection1 = new DatabaseConnection();
dbConnection1.connect();
// This returns the same instance
const dbConnection2 = new DatabaseConnection();
dbConnection2.query("SELECT * FROM users");
// Still the same instance
console.log(dbConnection1 === dbConnection2); // true
Modern implementation using a module in JavaScript:
// database.js
let instance = null;
class DatabaseConnection {
constructor() {
this.connection = null;
this.connected = false;
}
connect() {
// Connection logic
}
query(sql) {
// Query logic
}
disconnect() {
// Disconnection logic
}
}
export default function getDatabase() {
if (!instance) {
instance = new DatabaseConnection();
}
return instance;
}
// Usage
import getDatabase from './database.js';
const db1 = getDatabase();
db1.connect();
const db2 = getDatabase();
db2.query("SELECT * FROM users");
// Same instance
console.log(db1 === db2); // true
Builder Pattern
The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create different representations.
Builder Pattern
When to Use the Builder Pattern
- When an object has a complex construction process
- When an object must be created with different configurations
- When you want to create an immutable object with many optional parameters
Example: Builder
class QueryBuilder {
constructor() {
this.reset();
}
reset() {
this.table = '';
this.selectFields = ['*'];
this.whereConditions = [];
this.limitValue = null;
this.offsetValue = null;
this.orderByFields = [];
this.groupByFields = [];
this.joinStatements = [];
return this;
}
select(fields) {
if (Array.isArray(fields)) {
this.selectFields = fields;
} else {
this.selectFields = [fields];
}
return this;
}
from(table) {
this.table = table;
return this;
}
where(condition) {
this.whereConditions.push(condition);
return this;
}
limit(value) {
this.limitValue = value;
return this;
}
offset(value) {
this.offsetValue = value;
return this;
}
orderBy(field, direction = 'ASC') {
this.orderByFields.push({ field, direction });
return this;
}
groupBy(fields) {
if (Array.isArray(fields)) {
this.groupByFields = [...this.groupByFields, ...fields];
} else {
this.groupByFields.push(fields);
}
return this;
}
join(table, condition, type = 'INNER') {
this.joinStatements.push({ table, condition, type });
return this;
}
build() {
if (!this.table) {
throw new Error('Table name is required');
}
let query = `SELECT ${this.selectFields.join(', ')} FROM ${this.table}`;
// Add joins
if (this.joinStatements.length > 0) {
const joins = this.joinStatements.map(join =>
`${join.type} JOIN ${join.table} ON ${join.condition}`
);
query += ' ' + joins.join(' ');
}
// Add where conditions
if (this.whereConditions.length > 0) {
query += ' WHERE ' + this.whereConditions.join(' AND ');
}
// Add group by
if (this.groupByFields.length > 0) {
query += ' GROUP BY ' + this.groupByFields.join(', ');
}
// Add order by
if (this.orderByFields.length > 0) {
const orderBy = this.orderByFields.map(order =>
`${order.field} ${order.direction}`
);
query += ' ORDER BY ' + orderBy.join(', ');
}
// Add limit
if (this.limitValue !== null) {
query += ` LIMIT ${this.limitValue}`;
}
// Add offset
if (this.offsetValue !== null) {
query += ` OFFSET ${this.offsetValue}`;
}
return query;
}
}
// Usage
const query = new QueryBuilder()
.select(['id', 'name', 'email'])
.from('users')
.where('status = "active"')
.where('created_at > "2023-01-01"')
.orderBy('name')
.limit(10)
.build();
console.log(query);
// Output: SELECT id, name, email FROM users WHERE status = "active" AND created_at > "2023-01-01" ORDER BY name ASC LIMIT 10
Structural Patterns
Structural patterns deal with object composition, creating relationships between objects to form larger structures while keeping these structures flexible and efficient.
Adapter Pattern
The Adapter pattern allows objects with incompatible interfaces to collaborate. It acts as a bridge between two incompatible interfaces.
Adapter Pattern
When to Use the Adapter Pattern
- When you need to use an existing class with an interface that doesn't match your requirements
- When you want to reuse existing subclasses that lack certain common functionality
- When you need to create a reusable class that cooperates with unrelated classes
Example: Adapter
// Target interface
class PaymentProcessor {
processPayment(amount) {
throw new Error("Method 'processPayment()' must be implemented");
}
}
// Adaptee: Legacy payment service with incompatible interface
class LegacyPaymentService {
makePayment(amount, currency) {
console.log(`Processing payment of ${amount} ${currency} through legacy system`);
return { success: true, reference: `REF-${Date.now()}` };
}
}
// Adapter
class LegacyPaymentAdapter extends PaymentProcessor {
constructor(legacyService) {
super();
this.legacyService = legacyService;
this.defaultCurrency = 'USD';
}
processPayment(amount) {
// Adapt the call to the legacy service
const result = this.legacyService.makePayment(amount, this.defaultCurrency);
return {
successful: result.success,
transactionId: result.reference
};
}
}
// Another adaptee: Modern payment API with different interface
class StripePaymentAPI {
async createCharge(paymentDetails) {
console.log(`Creating Stripe charge for $${paymentDetails.amount}`);
return {
id: `ch_${Math.random().toString(36).substring(2, 10)}`,
status: 'succeeded'
};
}
}
// Adapter for Stripe
class StripePaymentAdapter extends PaymentProcessor {
constructor(stripeAPI) {
super();
this.stripeAPI = stripeAPI;
}
async processPayment(amount) {
// Adapt the call to the Stripe API
const charge = await this.stripeAPI.createCharge({
amount,
currency: 'usd',
source: 'default_source'
});
return {
successful: charge.status === 'succeeded',
transactionId: charge.id
};
}
}
// Client code that works with PaymentProcessor interface
class PaymentService {
constructor(paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
async processOrder(order) {
console.log(`Processing order ${order.id}`);
const result = await this.paymentProcessor.processPayment(order.total);
if (result.successful) {
console.log(`Payment successful with transaction ID: ${result.transactionId}`);
return { success: true, order, payment: result };
} else {
console.log('Payment failed');
return { success: false };
}
}
}
// Usage
// With legacy payment service
const legacyService = new LegacyPaymentService();
const legacyAdapter = new LegacyPaymentAdapter(legacyService);
const paymentService1 = new PaymentService(legacyAdapter);
paymentService1.processOrder({ id: 'ORD-1234', total: 99.99 });
// With Stripe payment service
const stripeAPI = new StripePaymentAPI();
const stripeAdapter = new StripePaymentAdapter(stripeAPI);
const paymentService2 = new PaymentService(stripeAdapter);
paymentService2.processOrder({ id: 'ORD-5678', total: 149.99 });
Decorator Pattern
The Decorator pattern attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
Decorator Pattern
When to Use the Decorator Pattern
- When you need to add responsibilities to objects without modifying their code
- When extension by subclassing is impractical or impossible
- When you want to add responsibilities that can be withdrawn dynamically
Example: Decorator
// Component interface
class DataSource {
readData() {
throw new Error("Method 'readData()' must be implemented");
}
writeData(data) {
throw new Error("Method 'writeData()' must be implemented");
}
}
// Concrete component
class FileDataSource extends DataSource {
constructor(filename) {
super();
this.filename = filename;
}
readData() {
console.log(`Reading data from file: ${this.filename}`);
return `Data from ${this.filename}`;
}
writeData(data) {
console.log(`Writing data to file: ${this.filename}`);
console.log(`Data: ${data}`);
}
}
// Base decorator
class DataSourceDecorator extends DataSource {
constructor(wrappee) {
super();
this.wrappee = wrappee;
}
readData() {
return this.wrappee.readData();
}
writeData(data) {
this.wrappee.writeData(data);
}
}
// Concrete decorators
class EncryptionDecorator extends DataSourceDecorator {
readData() {
const encryptedData = this.wrappee.readData();
return this.decrypt(encryptedData);
}
writeData(data) {
const encryptedData = this.encrypt(data);
this.wrappee.writeData(encryptedData);
}
encrypt(data) {
console.log('Encrypting data');
// Simplified encryption simulation
return `ENCRYPTED[${data}]`;
}
decrypt(data) {
console.log('Decrypting data');
// Simplified decryption simulation
if (data.startsWith('ENCRYPTED[') && data.endsWith(']')) {
return data.substring(10, data.length - 1);
}
return data;
}
}
class CompressionDecorator extends DataSourceDecorator {
readData() {
const compressedData = this.wrappee.readData();
return this.decompress(compressedData);
}
writeData(data) {
const compressedData = this.compress(data);
this.wrappee.writeData(compressedData);
}
compress(data) {
console.log('Compressing data');
// Simplified compression simulation
return `COMPRESSED[${data}]`;
}
decompress(data) {
console.log('Decompressing data');
// Simplified decompression simulation
if (data.startsWith('COMPRESSED[') && data.endsWith(']')) {
return data.substring(11, data.length - 1);
}
return data;
}
}
class LoggingDecorator extends DataSourceDecorator {
readData() {
console.log(`Log: Reading data at ${new Date().toISOString()}`);
return this.wrappee.readData();
}
writeData(data) {
console.log(`Log: Writing data at ${new Date().toISOString()}`);
this.wrappee.writeData(data);
}
}
// Usage
let source = new FileDataSource("data.txt");
// Wrap source with decorators
source = new CompressionDecorator(source);
source = new EncryptionDecorator(source);
source = new LoggingDecorator(source);
// Writing data goes through all decorators
source.writeData("Hello, World!");
// Reading data goes through all decorators
const data = source.readData();
console.log(`Final data: ${data}`);
Composite Pattern
The Composite pattern lets you compose objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions of objects uniformly.
Composite Pattern
When to Use the Composite Pattern
- When you want to represent part-whole hierarchies of objects
- When clients should be able to ignore the differences between individual objects and compositions
- When you want the structure to be extensible with new component types
Example: Composite
// Component interface
class FileSystemComponent {
constructor(name) {
this.name = name;
}
getSize() {
throw new Error("Method 'getSize()' must be implemented");
}
print(indent = 0) {
throw new Error("Method 'print()' must be implemented");
}
}
// Leaf
class File extends FileSystemComponent {
constructor(name, size) {
super(name);
this.size = size;
}
getSize() {
return this.size;
}
print(indent = 0) {
console.log(`${' '.repeat(indent)}📄 ${this.name} (${this.size} bytes)`);
}
}
// Composite
class Directory extends FileSystemComponent {
constructor(name) {
super(name);
this.children = [];
}
add(component) {
this.children.push(component);
return this;
}
remove(component) {
const index = this.children.indexOf(component);
if (index !== -1) {
this.children.splice(index, 1);
}
}
getSize() {
return this.children.reduce((total, child) => total + child.getSize(), 0);
}
print(indent = 0) {
console.log(`${' '.repeat(indent)}📁 ${this.name} (${this.getSize()} bytes)`);
this.children.forEach(child => child.print(indent + 2));
}
}
// Usage
// Create file system structure
const root = new Directory('root');
const home = new Directory('home');
const user1 = new Directory('user1');
const user2 = new Directory('user2');
const documents = new Directory('documents');
const pictures = new Directory('pictures');
const file1 = new File('file1.txt', 100);
const file2 = new File('file2.txt', 200);
const photo1 = new File('photo1.jpg', 1500);
const photo2 = new File('photo2.jpg', 2000);
const resume = new File('resume.pdf', 300);
// Build the structure
root.add(home);
home.add(user1).add(user2);
user1.add(documents).add(pictures);
documents.add(file1).add(file2).add(resume);
pictures.add(photo1).add(photo2);
// Print the file system
root.print();
// Get size of a specific directory
console.log(`Size of pictures: ${pictures.getSize()} bytes`);
console.log(`Size of user1: ${user1.getSize()} bytes`);
console.log(`Total size: ${root.getSize()} bytes`);
Behavioral Patterns
Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects.
Observer Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
Observer Pattern
When to Use the Observer Pattern
- When a change to one object requires changing others, and you don't know how many objects need to change
- When an object should be able to notify other objects without making assumptions about them
- When you need a one-to-many relationship between objects that is loosely coupled
Example: Observer
// Observer interface
class Observer {
update(subject) {
throw new Error("Method 'update()' must be implemented");
}
}
// Subject interface
class Subject {
constructor() {
this.observers = [];
}
addObserver(observer) {
const isExist = this.observers.includes(observer);
if (!isExist) {
this.observers.push(observer);
}
return this;
}
removeObserver(observer) {
const observerIndex = this.observers.indexOf(observer);
if (observerIndex !== -1) {
this.observers.splice(observerIndex, 1);
}
return this;
}
notifyObservers() {
for (const observer of this.observers) {
observer.update(this);
}
}
}
// Concrete subject
class WeatherStation extends Subject {
constructor() {
super();
this.temperature = 0;
this.humidity = 0;
this.pressure = 0;
}
setMeasurements(temperature, humidity, pressure) {
this.temperature = temperature;
this.humidity = humidity;
this.pressure = pressure;
this.notifyObservers();
}
getTemperature() {
return this.temperature;
}
getHumidity() {
return this.humidity;
}
getPressure() {
return this.pressure;
}
}
// Concrete observers
class TemperatureDisplay extends Observer {
constructor(weatherStation) {
super();
this.weatherStation = weatherStation;
this.weatherStation.addObserver(this);
}
update(subject) {
if (subject === this.weatherStation) {
console.log(`Temperature Display: ${subject.getTemperature()}°C`);
}
}
}
class HumidityDisplay extends Observer {
constructor(weatherStation) {
super();
this.weatherStation = weatherStation;
this.weatherStation.addObserver(this);
}
update(subject) {
if (subject === this.weatherStation) {
console.log(`Humidity Display: ${subject.getHumidity()}%`);
}
}
}
class WeatherApp extends Observer {
constructor(weatherStation) {
super();
this.weatherStation = weatherStation;
this.weatherStation.addObserver(this);
}
update(subject) {
if (subject === this.weatherStation) {
console.log(`Weather App: Temperature ${subject.getTemperature()}°C, ` +
`Humidity ${subject.getHumidity()}%, ` +
`Pressure ${subject.getPressure()} hPa`);
}
}
}
// Usage
const weatherStation = new WeatherStation();
const temperatureDisplay = new TemperatureDisplay(weatherStation);
const humidityDisplay = new HumidityDisplay(weatherStation);
const weatherApp = new WeatherApp(weatherStation);
// Weather changes will notify all observers
weatherStation.setMeasurements(25, 65, 1013);
// Remove one observer
weatherStation.removeObserver(humidityDisplay);
// Only remaining observers get notified
weatherStation.setMeasurements(26, 70, 1015);
Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it.
Strategy Pattern
When to Use the Strategy Pattern
- When you want to define a class that will have one behavior that's similar to other behaviors in a list
- When you need different variants of an algorithm
- When an algorithm uses data that clients shouldn't know about
- When a class defines many behaviors as conditional statements
Example: Strategy
// Strategy interface
class ValidationStrategy {
validate(value) {
throw new Error("Method 'validate()' must be implemented");
}
}
// Concrete strategies
class RequiredFieldStrategy extends ValidationStrategy {
validate(value) {
return !!value;
}
}
class EmailValidationStrategy extends ValidationStrategy {
validate(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
}
class MinLengthStrategy extends ValidationStrategy {
constructor(minLength) {
super();
this.minLength = minLength;
}
validate(value) {
return value && value.length >= this.minLength;
}
}
class NumericValidationStrategy extends ValidationStrategy {
validate(value) {
return !isNaN(value) && isFinite(value);
}
}
// Context
class FormField {
constructor(name, value) {
this.name = name;
this.value = value;
this.validationStrategies = [];
this.errors = [];
}
addValidationStrategy(strategy) {
this.validationStrategies.push(strategy);
return this;
}
validate() {
this.errors = [];
for (const strategy of this.validationStrategies) {
const isValid = strategy.validate(this.value);
if (!isValid) {
this.errors.push(this.getErrorMessage(strategy));
}
}
return this.errors.length === 0;
}
getErrorMessage(strategy) {
if (strategy instanceof RequiredFieldStrategy) {
return `${this.name} is required`;
} else if (strategy instanceof EmailValidationStrategy) {
return `${this.name} must be a valid email address`;
} else if (strategy instanceof MinLengthStrategy) {
return `${this.name} must be at least ${strategy.minLength} characters`;
} else if (strategy instanceof NumericValidationStrategy) {
return `${this.name} must be a number`;
} else {
return `${this.name} is invalid`;
}
}
getErrors() {
return this.errors;
}
}
// Usage
const emailField = new FormField('Email', 'test@example.com')
.addValidationStrategy(new RequiredFieldStrategy())
.addValidationStrategy(new EmailValidationStrategy());
const passwordField = new FormField('Password', '123')
.addValidationStrategy(new RequiredFieldStrategy())
.addValidationStrategy(new MinLengthStrategy(8));
const ageField = new FormField('Age', 'twenty')
.addValidationStrategy(new RequiredFieldStrategy())
.addValidationStrategy(new NumericValidationStrategy());
// Validate fields
console.log(`Email valid: ${emailField.validate()}`);
console.log(`Email errors: ${emailField.getErrors().join(', ')}`);
console.log(`Password valid: ${passwordField.validate()}`);
console.log(`Password errors: ${passwordField.getErrors().join(', ')}`);
console.log(`Age valid: ${ageField.validate()}`);
console.log(`Age errors: ${ageField.getErrors().join(', ')}`);
Command Pattern
The Command pattern encapsulates a request as an object, allowing parametrization of clients with different requests, queuing of requests, and logging of operations.
Command Pattern
When to Use the Command Pattern
- When you want to parameterize objects with operations
- When you want to queue, specify, and execute requests at different times
- When you need to support undoable operations
- When you want to structure a system around high-level operations
Example: Command
// Command interface
class Command {
execute() {
throw new Error("Method 'execute()' must be implemented");
}
undo() {
throw new Error("Method 'undo()' must be implemented");
}
}
// Receiver
class Light {
constructor(location) {
this.location = location;
this.isOn = false;
}
turnOn() {
this.isOn = true;
console.log(`${this.location} light turned on`);
}
turnOff() {
this.isOn = false;
console.log(`${this.location} light turned off`);
}
}
// Concrete commands
class LightOnCommand extends Command {
constructor(light) {
super();
this.light = light;
}
execute() {
this.light.turnOn();
}
undo() {
this.light.turnOff();
}
}
class LightOffCommand extends Command {
constructor(light) {
super();
this.light = light;
}
execute() {
this.light.turnOff();
}
undo() {
this.light.turnOn();
}
}
// Composite command
class MacroCommand extends Command {
constructor(commands) {
super();
this.commands = commands;
}
execute() {
for (const command of this.commands) {
command.execute();
}
}
undo() {
// Execute commands in reverse order for undo
for (let i = this.commands.length - 1; i >= 0; i--) {
this.commands[i].undo();
}
}
}
// Invoker
class RemoteControl {
constructor() {
this.commands = {};
this.history = [];
}
setCommand(buttonName, command) {
this.commands[buttonName] = command;
}
pressButton(buttonName) {
const command = this.commands[buttonName];
if (command) {
command.execute();
this.history.push(command);
} else {
console.log(`Button ${buttonName} not configured`);
}
}
undoLastCommand() {
if (this.history.length > 0) {
const command = this.history.pop();
command.undo();
} else {
console.log('No commands to undo');
}
}
}
// Usage
const kitchenLight = new Light('Kitchen');
const bedroomLight = new Light('Bedroom');
const livingRoomLight = new Light('Living Room');
const kitchenLightOn = new LightOnCommand(kitchenLight);
const kitchenLightOff = new LightOffCommand(kitchenLight);
const bedroomLightOn = new LightOnCommand(bedroomLight);
const bedroomLightOff = new LightOffCommand(bedroomLight);
const livingRoomLightOn = new LightOnCommand(livingRoomLight);
const livingRoomLightOff = new LightOffCommand(livingRoomLight);
// Create macro commands
const allLightsOn = new MacroCommand([
kitchenLightOn,
bedroomLightOn,
livingRoomLightOn
]);
const allLightsOff = new MacroCommand([
kitchenLightOff,
bedroomLightOff,
livingRoomLightOff
]);
const remote = new RemoteControl();
// Configure remote buttons
remote.setCommand('kitchen_on', kitchenLightOn);
remote.setCommand('kitchen_off', kitchenLightOff);
remote.setCommand('bedroom_on', bedroomLightOn);
remote.setCommand('bedroom_off', bedroomLightOff);
remote.setCommand('all_on', allLightsOn);
remote.setCommand('all_off', allLightsOff);
// Use the remote
remote.pressButton('kitchen_on');
remote.pressButton('bedroom_on');
remote.undoLastCommand();
remote.pressButton('all_on');
remote.pressButton('all_off');
remote.undoLastCommand();
Combining Design Patterns
Design patterns are often most effective when used together. Here's a simple example combining multiple patterns:
// Combines Factory, Strategy, Observer, and Decorator patterns
// for a flexible logging system
// Product interface (for Factory pattern)
class Logger {
log(message) {
throw new Error("Method 'log()' must be implemented");
}
}
// Concrete loggers
class ConsoleLogger extends Logger {
log(message) {
console.log(`[Console] ${message}`);
}
}
class FileLogger extends Logger {
constructor(filename) {
super();
this.filename = filename;
}
log(message) {
console.log(`[File: ${this.filename}] ${message}`);
// In a real implementation, this would write to a file
}
}
class DatabaseLogger extends Logger {
constructor(connection) {
super();
this.connection = connection;
}
log(message) {
console.log(`[Database] ${message}`);
// In a real implementation, this would write to a database
}
}
// Strategy pattern for log levels
class LogLevelStrategy {
shouldLog(level, messageLevel) {
throw new Error("Method 'shouldLog()' must be implemented");
}
}
class DebugLogLevelStrategy extends LogLevelStrategy {
shouldLog(level, messageLevel) {
const levels = { debug: 0, info: 1, warn: 2, error: 3 };
return levels[messageLevel] >= levels[level];
}
}
class ProductionLogLevelStrategy extends LogLevelStrategy {
shouldLog(level, messageLevel) {
const levels = { debug: 0, info: 1, warn: 2, error: 3 };
// In production, only log warnings and errors
return levels[messageLevel] >= 2;
}
}
// Factory for creating loggers (Factory pattern)
class LoggerFactory {
static createLogger(type, options = {}) {
switch (type) {
case 'console':
return new ConsoleLogger();
case 'file':
return new FileLogger(options.filename || 'app.log');
case 'database':
return new DatabaseLogger(options.connection || 'default');
default:
throw new Error(`Logger type '${type}' not supported`);
}
}
}
// Decorator pattern for enhancing loggers
class LoggerDecorator extends Logger {
constructor(logger) {
super();
this.wrappee = logger;
}
log(message) {
this.wrappee.log(message);
}
}
class TimestampDecorator extends LoggerDecorator {
log(message) {
const timestamp = new Date().toISOString();
this.wrappee.log(`[${timestamp}] ${message}`);
}
}
class LevelDecorator extends LoggerDecorator {
constructor(logger, level) {
super(logger);
this.level = level || 'info';
}
debug(message) {
this.logWithLevel('debug', message);
}
info(message) {
this.logWithLevel('info', message);
}
warn(message) {
this.logWithLevel('warn', message);
}
error(message) {
this.logWithLevel('error', message);
}
logWithLevel(level, message) {
this.wrappee.log(`[${level.toUpperCase()}] ${message}`);
}
}
// Observer pattern for log events
class LogObserver {
update(message, level) {
throw new Error("Method 'update()' must be implemented");
}
}
class LogManager extends Logger {
constructor(strategy) {
super();
this.loggers = [];
this.observers = [];
this.strategy = strategy;
}
addLogger(logger) {
this.loggers.push(logger);
return this;
}
addObserver(observer) {
this.observers.push(observer);
return this;
}
removeObserver(observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
return this;
}
notifyObservers(message, level) {
for (const observer of this.observers) {
observer.update(message, level);
}
}
log(message, level = 'info') {
if (this.strategy.shouldLog(this.level, level)) {
for (const logger of this.loggers) {
logger.log(message);
}
this.notifyObservers(message, level);
}
}
debug(message) {
this.log(message, 'debug');
}
info(message) {
this.log(message, 'info');
}
warn(message) {
this.log(message, 'warn');
}
error(message) {
this.log(message, 'error');
}
}
// Concrete observers
class EmailAlertObserver extends LogObserver {
constructor(email) {
super();
this.email = email;
}
update(message, level) {
if (level === 'error') {
console.log(`Sending email alert to ${this.email}: ${message}`);
}
}
}
class MetricsObserver extends LogObserver {
constructor() {
super();
this.counts = { debug: 0, info: 0, warn: 0, error: 0 };
}
update(message, level) {
this.counts[level]++;
console.log(`Metrics updated - ${level}: ${this.counts[level]}`);
}
getMetrics() {
return this.counts;
}
}
// Usage
// Create strategy based on environment
const isDevelopment = process.env.NODE_ENV !== 'production';
const logStrategy = isDevelopment
? new DebugLogLevelStrategy()
: new ProductionLogLevelStrategy();
// Create and configure the log manager
const logManager = new LogManager(logStrategy);
// Create and decorate loggers
let consoleLogger = LoggerFactory.createLogger('console');
consoleLogger = new TimestampDecorator(consoleLogger);
let fileLogger = LoggerFactory.createLogger('file', { filename: 'app.log' });
fileLogger = new TimestampDecorator(fileLogger);
// Add loggers to manager
logManager.addLogger(consoleLogger);
logManager.addLogger(fileLogger);
// Add observers
const emailObserver = new EmailAlertObserver('admin@example.com');
const metricsObserver = new MetricsObserver();
logManager.addObserver(emailObserver);
logManager.addObserver(metricsObserver);
// Use the logging system
logManager.debug('This is a debug message');
logManager.info('Application started');
logManager.warn('Configuration file not found, using defaults');
logManager.error('Database connection failed');
// Check metrics
console.log('Logging metrics:', metricsObserver.getMetrics());
Choosing the Right Pattern
Factors to Consider When Choosing a Pattern
The Problem
What specific issue are you trying to solve?
Context
What's the broader context in which this problem exists?
Trade-offs
What are the costs and benefits of each pattern?
Simplicity
Is the pattern the simplest solution to the problem?
Maintainability
How will the pattern affect long-term maintenance?
Remember that design patterns are tools, not goals. Use them when they provide a clear benefit, and avoid overengineering by applying patterns unnecessarily.
Anti-Patterns to Avoid
While design patterns provide solutions to common problems, anti-patterns represent common mistakes:
- God Object: Concentrating too much functionality in a single class
- Spaghetti Code: Tangled, unstructured code with poor separation of concerns
- Golden Hammer: Using a familiar pattern for every problem, regardless of fit
- Reinventing the Wheel: Creating custom solutions for solved problems
- Premature Optimization: Optimizing code before understanding performance requirements
Conclusion
Design patterns provide proven solutions to common software design problems. By understanding and applying these patterns, you can create more maintainable, flexible, and robust applications.
Remember that patterns should be applied judiciously, based on your specific needs and context. The goal is not to use as many patterns as possible, but to solve problems effectively while maintaining clean, understandable code.
As you become more familiar with design patterns, you'll recognize situations where they can be applied, and develop an intuition for which pattern best fits a particular problem.