Design patterns are reusable solutions applied to commonly occurring problems when writing JavaScript web applications.
They offer developers an organized and structured approach to coding, which makes applications more efficient, scalable, and maintainable.
This comprehensive guide will explore seven popular JavaScript design patterns, divided into three categories: creational, structural, and behavioral patterns.
By understanding and implementing these patterns, developers can write organized, efficient, and well-structured code that addresses common challenges in web applications.
1. Creational Patterns
Creational design patterns deal with object creation mechanisms and provide ways to create objects in a system without specifying their concrete classes.
These patterns allow developers to create objects flexibly and efficiently.
Constructor Pattern
The constructor pattern is a method used to initialize newly created objects once memory is allocated. It allows developers to create and initialize objects in a single step.
function Animal(name, species) {
this.name = name;
this.species = species;
}
Animal.prototype.speak = function () {
console.log(`${this.name} says: Hello!`);
};
const dog = new Animal('Max', 'Dog');
dog.speak(); // Max says: Hello!
The constructor pattern does not support inheritance.
Prototype Pattern
The prototype pattern is based on prototypical inheritance, where objects are created to act as prototypes for other objects.
Prototypes serve as blueprints for each object constructor created.
const animalPrototype = {
speak() {
console.log(`${this.name} says: Hello!`);
},
};
function createAnimal(name, species) {
const animal = Object.create(animalPrototype);
animal.name = name;
animal.species = species;
return animal;
}
const cat = createAnimal('Milo', 'Cat');
cat.speak(); // Milo says: Hello!
In this example, we define a prototype object (animalPrototype
) that contains the shared methods and properties we want our animal instances to have. We then create a factory function (createAnimal
) to create new animal instances that inherit from the animalPrototype
.
Class Pattern
class Animal {
constructor(name, species) {
this.name = name;
this.species = species;
}
speak() {
console.log(`${this.name} says: Hello!`);
}
}
const dog = new Animal('Max', 'Dog');
dog.speak(); // Max says: Hello!
In the Class Pattern, we use the class
keyword to define a class, which is essentially a blueprint for creating objects with a specific structure, properties, and methods. In this example, we have the Animal
class with a constructor that initializes the name
and species
properties and a speak
method.
To create instances of the class, we use the new
keyword followed by the class name and pass the required arguments, as shown in the example:
const dog = new Animal('Max', 'Dog');
The Class Pattern is popular in JavaScript because it provides a clean and organized way to define and create objects, making the code more readable and maintainable.
Although it is syntactic sugar over JavaScript’s prototype-based inheritance, it provides a familiar syntax for developers coming from class-based languages like Java or C#.
2. Structural Patterns
Structural design patterns are concerned with object composition and the structure of objects and classes.
These patterns ensure that the elements in a system are organized in a way that allows them to work together effectively.
Module Pattern
The module pattern improves on the prototype pattern by setting different types of modifiers (both private and public) in the module.
Developers can create similar functions or properties without conflicts, and they can rename functions publicly.
However, this pattern does not allow developers to override created functions from the outside environment.
function AnimalContainer() {
const container = [];
function addAnimal(name) {
container.push(name);
}
function getAllAnimals() {
return container;
}
function removeAnimal(name) {
const index = container.indexOf(name);
if (index < 1) {
throw new Error("Animal not found in container");
}
container.splice(index, 1);
}
return {
add: addAnimal,
get: getAllAnimals,
remove: removeAnimal,
};
}
const container = AnimalContainer();
container.add("Hen");
container.add("Goat");
container.add("Sheep");
console.log(container.get()); // Array(3) ["Hen", "Goat", "Sheep"]
container.remove("Sheep");
console.log(container.get()); // Array(2) ["Hen", "Goat"]
Singleton Pattern
The singleton pattern is useful when only one instance of an object needs to be created, such as a database connection.
This pattern ensures that only one instance can be created at a time and that the connection is closed before opening a new one.
One drawback of this pattern is its difficulty in testing due to hidden dependencies, which are not easily isolated for testing.
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
Database.instance = this;
}
connect() {
// Connection logic
}
}
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true
3. Behavioral Patterns
Behavioral design patterns define the ways in which objects communicate with one another. These patterns ensure that objects can work together effectively while maintaining a clear separation of concerns.
Factory Pattern
The Factory Pattern is a creational design pattern that provides an interface for creating objects in a superclass without specifying their concrete classes.
It allows the creation of objects without exposing the instantiation logic to the client.
This pattern is particularly useful when object creation is complex, when the environment influences object creation, when working with many small objects with shared properties, or when decoupling is required.
// Dealer A
const DealerA = {
title() {
return "Dealer A";
},
pay(amount) {
console.log(`Set up configuration using username: ${this.username} and password: ${this.password}`);
return `Payment for service ${amount} is successful using ${this.title()}`;
}
};
// Dealer B
const DealerB = {
title() {
return "Dealer B";
},
pay(amount) {
console.log(`Set up configuration using username: ${this.username} and password: ${this.password}`);
return `Payment for service ${amount} is successful using ${this.title()}`;
}
};
function DealerFactory(DealerOption, config = {}) {
const dealer = Object.create(DealerOption);
Object.assign(dealer, config);
return dealer;
}
const dealerFactoryA = DealerFactory(DealerA, {
username: "user",
password: "pass"
});
console.log(dealerFactoryA.title());
console.log(dealerFactoryA.pay(12));
const dealerFactoryB = DealerFactory(DealerB, {
username: "user2",
password: "pass2"
});
console.log(dealerFactoryB.title());
console.log(dealerFactoryB.pay(50));
In this example, we define two dealer objects, DealerA
and DealerB
, each with their own title
and pay
methods. We then create a DealerFactory
function that takes a dealer option and configuration as arguments.
The factory function creates a new dealer object with the specified dealer option as its prototype and assigns the given configuration to the new dealer object.
This allows us to create different dealer instances with different configurations while maintaining a consistent interface.
Observer Pattern
The observer pattern is useful when objects need to communicate with other sets of objects simultaneously.
This pattern allows objects to modify the current state of data without unnecessary push and pull of events across states.
function Observer() {
this.observerContainer = [];
}
Observer.prototype.subscribe = function (element) {
this.observerContainer.push(element);
};
// The following removes an element from the container
Observer.prototype.unsubscribe = function (element) {
const elementIndex = this.observerContainer.indexOf(element);
if (elementIndex > -1) {
this.observerContainer.splice(elementIndex, 1);
}
};
// Notify elements added to the container by calling each subscribed component
Observer.prototype.notifyAll = function (element) {
this.observerContainer.forEach(function (observerElement
) {
observerElement(element);
});
};
// Example of the observer pattern
const observer = new Observer();
function logElement(element) {
console.log("Logging the element: ", element);
}
function alertElement(element) {
console.log("Alerting the element: ", element);
}
function storeElement(element) {
console.log("Storing the element: ", element);
}
observer.subscribe(logElement);
observer.subscribe(alertElement);
observer.subscribe(storeElement);
observer.notifyAll("Hello!"); // Logs, alerts, and stores "Hello!"
observer.unsubscribe(alertElement);
observer.notifyAll("Goodbye!"); // Logs and stores "Goodbye!", but does not alert
Mediator Pattern
The mediator pattern is a behavioral pattern that allows objects to communicate through a central hub, reducing the dependencies between objects.
This pattern is useful when there are multiple objects interacting with one another, and direct communication would result in a tight coupling of objects.
function Chatroom() {
this.users = {};
}
Chatroom.prototype.registerUser = function (username) {
this.users[username] = new User(username, this);
};
Chatroom.prototype.send = function (message, fromUser, toUser) {
if (this.users[toUser]) {
this.users[toUser].receive(message, fromUser);
} else {
console.log(`${toUser} is not registered in the chatroom.`);
}
};
function User(username, chatroom) {
this.username = username;
this.chatroom = chatroom;
}
User.prototype.send = function (message, toUser) {
this.chatroom.send(message, this.username, toUser);
};
User.prototype.receive = function (message, fromUser) {
console.log(`${fromUser} to ${this.username}: ${message}`);
};
const chatroom = new Chatroom();
chatroom.registerUser("Alice");
chatroom.registerUser("Bob");
chatroom.registerUser("Charlie");
chatroom.users["Alice"].send("Hello, Bob!", "Bob");
chatroom.users["Bob"].send("Hey, Alice!", "Alice");
chatroom.users["Charlie"].send("Hi, everyone!", "Bob");
A Real Life application example
Now let’s build a simple online store application using a combination of the design patterns previously discussed. This application will allow users to browse products, add them to their cart, and process payments. We’ll demonstrate how to use each design pattern in the context of this application.
Constructor Pattern: Product
We’ll use the constructor pattern to create instances of the Product
class. Each product will have a name, price, and description.
function Product(name, price, description) {
this.name = name;
this.price = price;
this.description = description;
}
const product1 = new Product("Laptop", 1000, "A high-performance laptop");
const product2 = new Product("Headphones", 150, "Noise-canceling headphones");
Module Pattern: ShoppingCart
For the shopping cart functionality, we’ll use the module pattern. It allows us to encapsulate private variables and expose public methods for adding, removing, and viewing the items in the cart.
const ShoppingCart = (function () {
const items = [];
function addItem(product, quantity) {
items.push({ product, quantity });
}
function removeItem(product) {
const index = items.findIndex((item) => item.product === product);
if (index > -1) {
items.splice(index, 1);
}
}
function getItems() {
return items;
}
return {
addItem,
removeItem,
getItems,
};
})();
ShoppingCart.addItem(product1, 1);
ShoppingCart.addItem(product2, 2);
Singleton Pattern: PaymentProcessor
For processing payments, we’ll use the singleton pattern to ensure that only one instance of the PaymentProcessor
class exists. This prevents multiple instances from interfering with each other.
const PaymentProcessor = (function () {
let instance;
function createInstance() {
return {
processPayment: function (paymentDetails) {
console.log("Processing payment:", paymentDetails);
},
};
}
return {
getInstance: function () {
if (!instance) {
instance = createInstance();
}
return instance;
},
};
})();
const paymentProcessor = PaymentProcessor.getInstance();
Factory Pattern: User
We’ll use the factory pattern to create instances of the User
class. Users can be of different types, such as regular customers, premium customers, or administrators.
function UserFactory() {}
UserFactory.prototype.createUser = function (type, userDetails) {
let user;
if (type === "regular") {
user = new RegularUser(userDetails);
} else if (type === "premium") {
user = new PremiumUser(userDetails);
} else if (type === "admin") {
user = new AdminUser(userDetails);
} else {
throw new Error("Invalid user type");
}
return user;
};
const userFactory = new UserFactory();
const regularUser = userFactory.createUser("regular", { name: "John Doe" });
Observer Pattern: Order Tracking
We can use the observer pattern to implement order tracking. When an order is placed, multiple actions should be taken, such as updating the inventory, sending notifications, and creating invoices.
function OrderTracker() {
this.observers = new Observer();
}
OrderTracker.prototype.placeOrder = function (orderDetails) {
this.observers.notifyAll(orderDetails);
};
const orderTracker = new OrderTracker();
function updateInventory(orderDetails) {
console.log("Updating inventory for order:", orderDetails);
}
function sendNotification(orderDetails) {
console.log("Sending order notification:", order
Details);
}
function createInvoice(orderDetails) {
console.log("Creating invoice for order:", orderDetails);
}
orderTracker.observers.subscribe(updateInventory);
orderTracker.observers.subscribe(sendNotification);
orderTracker.observers.subscribe(createInvoice);
Command Pattern: User Actions
We can use the command pattern to encapsulate user actions like browsing products, adding items to the cart, and processing payments.
const userActions = {
browseProducts: function (products) {
console.log("Browsing products:", products);
},
addToCart: function (product, quantity) {
ShoppingCart.addItem(product, quantity);
console.log(`Added ${quantity} ${product.name} to cart`);
},
processPayment: function (paymentDetails) {
paymentProcessor.processPayment(paymentDetails);
},
};
function executeAction(action, ...args) {
action(...args);
}
executeAction(userActions.browseProducts, [product1, product2]);
executeAction(userActions.addToCart, product1, 1);
executeAction(userActions.processPayment, { amount: 1000, method: "Credit Card" });
Prototype Pattern: Adding Discounts
We can use the prototype pattern to add discount functionality to the Product
class. This allows us to create discounted products without modifying the original Product
class.
Product.prototype.getDiscountedPrice = function (discount) {
return this.price - this.price * discount;
};
const discountedProduct = Object.create(product1);
discountedProduct.discount = 0.1;
console.log(`Discounted price: ${discountedProduct.getDiscountedPrice(discountedProduct.discount)}`);
Conclusion
In summary, we have demonstrated how to use various JavaScript Design Patterns in the context of a simple online store application. By utilizing these patterns, we can create a more maintainable, flexible, and scalable application. These design patterns can help developers better structure their code, communicate more effectively, and avoid common pitfalls.