Design Patterns - “The Essential Toolkit” for Developers
In the programming world, Design Patterns are seen as “secret techniques,” helping developers solve common software design problems in an efficient and optimized way. These patterns are proven solutions, refined over time, drawn from the experience of expert developers. This article will take you through the world of Design Patterns in JavaScript, delving into some common patterns and how to apply them in practice.
What Are Design Patterns?
Simply put, Design Patterns are general, reusable solutions to common software design problems. They are not code snippets to copy and paste but “templates” that guide how to solve problems and organize code to be clean, readable, and maintainable.
Benefits of Using Design Patterns:
- Speed up development: Save time and effort by not “reinventing the wheel.”
- Improve code quality: Code becomes cleaner, easier to read, understand, maintain, and extend.
- Increase reusability: Patterns can be applied flexibly across different projects.
- Facilitate communication: They create a common “language” for developers, making design discussions more effective.
Categories of Design Patterns
Design Patterns are generally categorized into three main groups:
- Creational Patterns: Focus on the process of object creation, making object initialization more flexible and efficient.
- Structural Patterns: Focus on organizing classes and objects to form larger, more complex structures while maintaining flexibility and efficiency.
- Behavioral Patterns: Concerned with communication and responsibility distribution among objects.
Some Popular Design Patterns in JavaScript (With Examples)
Below are some commonly used Design Patterns with examples in JavaScript:
1. Creational Patterns:
a) Singleton Pattern:
- Purpose: Ensures that a class has only one instance and provides a global point of access to that instance.
- Example: Managing database connections or application configurations.
class Database {
constructor(connectionString) {
if (Database.instance) {
return Database.instance;
}
this.connectionString = connectionString;
Database.instance = this;
}
connect() {
console.log(`Connecting to database: ${this.connectionString}`);
}
}
const db1 = new Database("mongodb://localhost:27017/mydb");
db1.connect(); // Output: Connecting to database: mongodb://localhost:27017/mydb
const db2 = new Database("another-connection-string"); // Returns the same instance as db1
db2.connect(); // Output: Connecting to database: mongodb://localhost:27017/mydb
console.log(db1 === db2); // Output: true
- Explanation: The
Database
class uses a staticinstance
property to store the single instance. The constructor checks ifinstance
already exists, and if so, it returns that instance; otherwise, it creates a new one.
b) Factory Pattern:
- Purpose: Defines an interface for creating objects, allowing subclasses to decide which class to instantiate. The Factory Pattern delegates object creation to subclasses.
- Example: Creating different “Product” objects (e.g., Car, Bike, Truck) based on an input parameter.
class Car {
constructor(options) {
this.doors = options.doors || 4;
this.state = options.state || "brand new";
this.color = options.color || "silver";
}
}
class Truck {
constructor(options) {
this.state = options.state || "used";
this.wheelSize = options.wheelSize || "large";
this.color = options.color || "blue";
}
}
class VehicleFactory {
createVehicle(options) {
let vehicleClass;
if (options.vehicleType === "car") {
vehicleClass = Car;
} else if (options.vehicleType === "truck") {
vehicleClass = Truck;
}
return new vehicleClass(options);
}
}
const factory = new VehicleFactory();
const car = factory.createVehicle({
vehicleType: "car",
color: "red",
doors: 4,
});
const truck = factory.createVehicle({
vehicleType: "truck",
color: "black",
wheelSize: "medium",
});
console.log(car); // Output: Car { doors: 4, state: 'brand new', color: 'red' }
console.log(truck); // Output: Truck { state: 'used', wheelSize: 'medium', color: 'black' }
- Explanation: The
VehicleFactory
class has acreateVehicle
method responsible for creating either aCar
orTruck
object based on thevehicleType
.
2. Structural Patterns:
a) Module Pattern:
- Purpose: Encapsulates implementation details (private members) while exposing only necessary methods, leading to better code organization and avoiding name conflicts.
- Example: Creating a shopping cart module.
const ShoppingCart = (function () {
let items = []; // Private variable
function addItem(item) {
items.push(item);
}
function getTotal() {
return items.reduce((total, item) => total + item.price, 0);
}
return {
addItem: addItem, // Public method
getTotal: getTotal, // Public method
};
})();
ShoppingCart.addItem({ name: "Product 1", price: 10 });
ShoppingCart.addItem({ name: "Product 2", price: 20 });
console.log(ShoppingCart.getTotal()); // Output: 30
- Explanation: The use of an IIFE (Immediately Invoked Function Expression) creates a closure where
items
is a private variable, accessible only through the public methodsaddItem
andgetTotal
.
b) Decorator Pattern:
- Purpose: Adds functionality to an object dynamically by “wrapping” it in a decorator object.
- Example: Adding features (like logging or caching) to a function.
function doSomething(name) {
console.log("Hello, " + name);
}
function loggingDecorator(wrapped) {
return function () {
console.log("Starting");
const result = wrapped.apply(this, arguments);
console.log("Finished");
return result;
};
}
const wrapped = loggingDecorator(doSomething);
wrapped("World");
// Output:
// Starting
// Hello, World
// Finished
- Explanation: The
loggingDecorator
takes a function (wrapped
) and returns a new function that logs “Starting” before invokingwrapped
and “Finished” after it executes.
3. Behavioral Patterns:
a) Observer Pattern:
- Purpose: Defines a one-to-many dependency between objects so that when one object changes state, all its dependent objects are notified and updated automatically.
- Example: Building a notification system.
class Subject {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter((obs) => obs !== observer);
}
fire(action) {
this.observers.forEach((observer) => {
observer.update(action);
});
}
}
class Observer {
constructor(name) {
this.name = name;
}
update(action) {
console.log(`${this.name} received action: ${action}`);
}
}
const subject = new Subject();
const observer1 = new Observer("Observer 1");
const observer2 = new Observer("Observer 2");
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.fire("New message!");
// Output:
// Observer 1 received action: New message!
// Observer 2 received action: New message!
- Explanation: The
Subject
class manages a list ofobservers
. Whenfire
is called, it notifies allobservers
by calling theirupdate
method.
b) Command Pattern:
- Purpose: Encapsulates a request as an object, allowing for parameterization of clients with different requests, queuing of requests, logging of requests, and supporting undoable operations.
- Example: Building an undo/redo function.
class Calculator {
constructor() {
this.value = 0;
this.history = [];
}
executeCommand(command) {
this.value = command.execute(this.value);
this.history.push(command);
}
undo() {
const command = this.history.pop();
this.value = command.undo(this.value);
}
}
class AddCommand {
constructor(value) {
this.value = value;
}
execute(currentValue) {
return currentValue + this.value;
}
undo(currentValue) {
return currentValue - this.value;
}
}
// Using the calculator and commands
const calculator = new Calculator();
const add5 = new AddCommand(5);
const add10 = new AddCommand(10);
calculator.executeCommand(add5);
console.log(calculator.value); // Output: 5
calculator.executeCommand(add10);
console.log(calculator.value); // Output: 15
calculator.undo();
console.log(calculator.value); // Output: 5
- Explanation: The
AddCommand
class encapsulates the action of adding a value. TheCalculator
class uses these commands to perform operations and store the history for undo.
Conclusion
Design Patterns are an essential part of every developer’s toolkit. Understanding and applying them skillfully will help you design software that is cleaner, easier to maintain, and scalable. Think of this article as a starting point, and continue exploring other Design Patterns to become a “master” of coding!