Advanced JavaScript (ES6+)

Design Patterns

13 min Lesson 34 of 40

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:

  1. Create a Logger singleton that can log messages at different levels (info, warning, error)
  2. Add observers that react to logs (ConsoleObserver, FileObserver)
  3. 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!