JavaScript Design patterns

JavaScript Design Patterns : The Ultimate Guide

Victor Preston
16 min readJan 19, 2024

--

JavaScript, with its widespread adoption and versatility, has become a cornerstone of modern web development.

What Is JavaScript?

JavaScript is a lightweight, interpreted, object-oriented programming language with first-class functions most commonly known as a scripting language for web pages.

The above definition means that JavaScript code has a low memory footprint, easy to implement and easy to learn, with a syntax similar to popular languages such as C++ and Java. It is a scripting language, which means that its code is interpreted instead of compiled. It has support for procedural, object-oriented, and functional programming styles, which makes it very flexible for developers.

Prerequisite

To understand the concepts and techniques discussed in this article, you are expected to have an understanding of the fundamentals of JavaScript. Familiarity with concepts like variables, functions, data types, object-oriented programming, etc. is essential.

What is A Design Pattern in JavaScript?

These patterns are not algorithms or specific implementations. They are more like ideas, opinions, and abstractions that can be useful in certain situations to solve a particular kind of problem.

The specific implementation of the patterns may vary depending on many different factors. But what’s important is the concepts behind them, and how they might help us achieve a better solution for our problem.

Importance of patterns in JavaScript development

During any language’s lifespan, many such reusable solutions are made and tested by a large number of developers from that language’s community. It is because of this combined experience of many developers that such solutions are so useful because they help us write code in an optimized way while at the same time solving the problem at hand.

The main benefits we get from design patterns are the following:

  • They are proven solutions: Because design patterns are often used by many developers, you can be certain that they work. And not only that, you can be certain that they were revised multiple times and optimizations were probably implemented.
  • They are easily reusable: Design patterns document a reusable solution which can be modified to solve multiple particular problems, as they are not tied to a specific problem.
  • They are expressive: Design patterns can explain a large solution quite elegantly.
  • They ease communication: When developers are familiar with design patterns, they can more easily communicate with one another about potential solutions to a given problem.
  • They prevent the need for refactoring code: If an application is written with design patterns in mind, it is often the case that you won’t need to refactor the code later on because applying the correct design pattern to a given problem is already an optimal solution.
  • They lower the size of the codebase: Because design patterns are usually elegant and optimal solutions, they usually require less code than other solutions.

Design Pattern Categorization

Design patterns can be categorized in multiple ways, but the most popular one is the following:

  • Creational design patterns
  • Structural design patterns
  • Behavioral design patterns
  • Concurrency design patterns
  • Architectural design patterns

Creational Design Patterns

These patterns deal with object creation mechanisms which optimize object creation compared to a basic approach. The basic form of object creation could result in design problems or in added complexity to the design. Creational design patterns solve this problem by somehow controlling object creation. Some of the popular design patterns in this category are:

  • Factory method
  • Abstract factory
  • Builder
  • Prototype
  • Singleton

Structural Design Patterns

These patterns deal with object relationships. They ensure that if one part of a system changes, the entire system doesn’t need to change along with it. The most popular patterns in this category are:

  • Adapter
  • Bridge
  • Composite
  • Decorator
  • Facade
  • Flyweight
  • Proxy

Behavioral Design Patterns

These types of patterns recognize, implement, and improve communication between disparate objects in a system. They help ensure that disparate parts of a system have synchronized information. Popular examples of these patterns are:

  • Chain of responsibility
  • Command
  • Iterator
  • Mediator
  • Memento
  • Observer
  • State
  • Strategy
  • Visitor

Concurrency Design Patterns

These types of design patterns deal with multi-threaded programming paradigms. Some of the popular ones are:

  • Active object
  • Nuclear reaction
  • Scheduler

Architectural Design Patterns

Design patterns which are used for architectural purposes. Some of the most famous ones are:

  • MVC (Model-View-Controller)
  • MVP (Model-View-Presenter)
  • MVVM (Model-View-ViewModel)

In the following section, we are going to take a closer look at some of the aforementioned design patterns with examples provided for better understanding.

Design Pattern Examples

1. Singleton Pattern

The singleton pattern is used in scenarios when we need exactly one instance of a class. For example, we need to have an object which contains some configuration for something. In these cases, it is not necessary to create a new object whenever the configuration object is required somewhere in the system.

var singleton = (function() {
// private singleton value which gets initialized only once
var config;

function initializeConfiguration(values){
this.randomNumber = Math.random();
values = values || {};
this.number = values.number || 5;
this.size = values.size || 10;
}

// we export the centralized method for retrieving the singleton value
return {
getConfig: function(values) {
// we initialize the singleton value only once
if (config === undefined) {
config = new initializeConfiguration(values);
}

// and return the same config value wherever it is asked for
return config;
}
};
})();

var configObject = singleton.getConfig({ "size": 8 });
// prints number: 5, size: 8, randomNumber: someRandomDecimalValue
console.log(configObject);
var configObject1 = singleton.getConfig({ "number": 8 });
// prints number: 5, size: 8, randomNumber: same randomDecimalValue as in first config
console.log(configObject1);

2. Factory Pattern

The Factory Pattern provides a way to create objects without specifying their concrete classes. It encapsulates the object creation logic in a separate factory method, allowing flexibility and decoupling between the creator and the created objects.

// Implementation example of the Factory Pattern
class Car {
constructor(make, model) {
this.make = make;
this.model = model;
}
}

class CarFactory {
createCar(make, model) {
return new Car(make, model);
}
}

const factory = new CarFactory();
const myCar = factory.createCar("Tope", "Model 1");

In this example, a CarFactory instance is created using new CarFactory(), and then the createCar method is invoked on the factory with the arguments "Tope" and "Model 1". This creates a new Car object with the make "Tope" and model "Model 1", which is assigned to the myCar variable.

3. Constructor Pattern

The Constructor Pattern creates objects from a constructor function using the new keyword. It allows you to define and initialize object properties within the constructor function.

// Implementation example of the Constructor Pattern
function Person(name, age) {
this.name = name;
this.age = age;
}

const tope = new Person("Tope", 24);

The above code defines a constructor function called Person that takes two parameters: name and age. Inside the function, the name and age values are assigned to the respective properties of the newly created object using the this keyword.

Later, a new instance of the Person object is created by invoking the Person function with the arguments “Tope” and 24. This creates a new object with the name property set to “Tope” and the age property set to 24, which is then assigned to the variable tope. The output of this code is that Tope holds an object representing a person with the name “Tope” and the age of 24.

4. Builder Pattern

In the Builder pattern, a builder class or object is responsible for constructing the final object. It provides a set of methods to configure and set the properties of the object being built. The construction process typically involves invoking these methods in a specific order to gradually build the object.

class CarBuilder {
constructor() {
this.car = new Car();
}

setMake(make) {
this.car.make = make;
return this;
}

setModel(model) {
this.car.model = model;
return this;
}

setEngine(engine) {
this.car.engine = engine;
return this;
}

setWheels(wheels) {
this.car.wheels = wheels;
return this;
}

build() {
return this.car;
}
}

class Car {
constructor() {
this.make = "";
this.model = "";
this.engine = "";
this.wheels = 0;
}

displayInfo() {
console.log(`Make: ${this.make}, Model: ${this.model}, Engine: ${this.engine}, Wheels: ${this.wheels}`);
}
}

// Usage
const carBuilder = new CarBuilder();
const car = carBuilder.setMake("Toyota").setModel("Camry").setEngine("V6").setWheels(4).build();
car.displayInfo(); // Output: Make: Toyota, Model: Camry, Engine: V6, Wheels: 4

In this example, the CarBuilder class allows for the construction of Car objects with different properties. By calling setMake, setModel, setEngine, setWheels methods, the properties of the Car object are set. The build method finalizes the construction and returns the fully built Car object. The Car class represents a car and includes a displayInfo method to log its details. By creating a carBuilder instance and chaining the property-setting methods, a car object is constructed with specific make, model, engine, and wheel values. Invoking car.displayInfo() displays the car's information.

5. Prototype Pattern

The Prototype pattern in JavaScript focuses on creating objects by cloning or extending existing objects as prototypes. It allows us to create new instances without explicitly defining their classes. In this pattern, objects act as prototypes for creating new objects, enabling inheritance and the sharing of properties and methods among multiple objects.

// Prototype object
const carPrototype = {
wheels: 4,
startEngine() {
console.log("Engine started.");
},
stopEngine() {
console.log("Engine stopped.");
}
};

// Create new car instance using the prototype
const car1 = Object.create(carPrototype);
car1.make = "Toyota";
car1.model = "Camry";

// Create another car instance using the same prototype
const car2 = Object.create(carPrototype);
car2.make = "Honda";
car2.model = "Accord";

car1.startEngine(); // Output: "Engine started."
car2.stopEngine(); // Output: "Engine stopped."

In this example, car instances car1 and car2 are created using a prototype object carPrototype. car1 has the make “Toyota” and model “Camry”, while car2 has the make “Honda” and model “Accord”. When car1.startEngine() is called, it outputs "Engine started.", and when car2.stopEngine() is called, it outputs "Engine stopped.". This demonstrates the utilization of a prototype object to share properties and methods among multiple instances.

6. Module Pattern

The Module Pattern encapsulates related methods and properties into a single module, providing a clean way to organize and protect the code. It allows for private and public members, enabling information hiding and preventing global namespace pollution.

const MyModule = (function() {
// Private members
let privateVariable = "I am private";

function privateMethod() {
console.log("This is a private method");
}

// Public members
return {
publicVariable: "I am public",

publicMethod() {
console.log("This is a public method");
// Accessing private members within the module
console.log(privateVariable);
privateMethod();
}
};
})();

// Usage
console.log(MyModule.publicVariable); // Output: "I am public"
MyModule.publicMethod(); // Output: "This is a public method" "I am private" "This is a private method"

In this example, the code uses an immediately invoked function expression (IIFE) to encapsulate private and public members. The module has private variables and methods, as well as public variables and methods. When accessed, the public members provide the expected output. This pattern allows for controlled access to encapsulated private members while exposing selected public members.

7. Decorator Pattern

The Decorator Pattern allows you to add behavior or modify the existing behavior of an object dynamically. It enhances the functionality of an object by wrapping it with one or more decorators without modifying its structure.

// Implementation example of the Decorator Pattern
class Coffee {
getCost() {
return 1;
}
}
class CoffeeDecorator {
constructor(coffee) {
this.coffee = coffee;
}
getCost() {
return this.coffee.getCost() + 0.5;
}
}
const myCoffee = new Coffee();
const coffeeWithMilk = new CoffeeDecorator(myCoffee);
console.log(coffeeWithMilk.getCost()); // Output: 1.5

In this example, the CoffeeDecorator class wraps a base Coffee object and adds additional functionality. It has a getCost method that calculates the total cost by combining the cost of the base coffee with an additional cost of 0.5.
In the usage section, a myCoffee instance of the Coffee class is created. Then, a coffeeWithMilk instance of the CoffeeDecorator class is instantiated, passing myCoffee as an argument. When coffeeWithMilk.getCost() is called, it returns the total cost of the coffee with the added cost from the decorator, resulting in an output of 1.5. This example illustrates how the decorator pattern can extend the functionality of an object by dynamically adding or modifying its properties or methods.

8. Facade Pattern

The Facade Pattern provides a simplified interface to a complex subsystem, acting as a front-facing interface that hides the underlying implementation details. It offers a convenient way to interact with a complex system by providing a high-level interface.

// Implementation example of the Facade Pattern
class SubsystemA {
operationA() {
console.log("Subsystem A operation.");
}
}
class SubsystemB {
operationB() {
console.log("Subsystem B operation.");
}
}
class Facade {
constructor() {
this.subsystemA = new SubsystemA();
this.subsystemB = new SubsystemB();
}
operation() {
this.subsystemA.operationA();
this.subsystemB.operationB();
}
}
const facade = new Facade();
facade.operation(); // Output: "Subsystem A operation." "Subsystem B operation."

In this example, the code consists of three classes: SubsystemA, SubsystemB, and Facade. The SubsystemA and SubsystemB classes represent independent subsystems and have their respective operationA and operationB methods. The Facade class serves as a simplified interface that aggregates the functionality of the subsystems.

In the usage section, a facade instance of the Facade class is created. Invoking facade.operation() triggers the execution of operationA from SubsystemA and operationB from SubsystemB. As a result, the output displays "Subsystem A operation." followed by "Subsystem B operation." This demonstrates how the Facade pattern provides a unified and simplified interface to interact with complex subsystems, abstracting their complexities and making them easier to use.

9. Adapter Pattern

The Adapter pattern is a structural design pattern that allows objects with incompatible interfaces to collaborate by acting as a bridge between them. It provides a way to convert the interface of one object into another interface that clients expect.

// Implementation 
class LegacyPrinter {
printLegacy(text) {
console.log(`Legacy Printing: ${text}`);
}
}
// Target interface
class Printer {
print(text) {}
}
// Adapter
class PrinterAdapter extends Printer {
constructor() {
super();
this.legacyPrinter = new LegacyPrinter();
}
print(text) {
this.legacyPrinter.printLegacy(text);
}
}
// Usage
const printer = new PrinterAdapter();
printer.print("Hello, World!"); // Output: "Legacy Printing: Hello, World!

In this code, the Adapter pattern is used to bridge the gap between the LegacyPrinter class and a desired Printer interface. The PrinterAdapter extends the Printer class and internally utilizes the LegacyPrinter to adapt the print method. When printer.print("Hello, World!") is called, it effectively triggers the legacy printing functionality with the output "Legacy Printing: Hello, World!". This shows how the Adapter pattern enables the integration of incompatible components by providing a standardized interface.

10. Bridge Pattern

The Bridge pattern is a structural design pattern that separates the abstraction and implementation of a system, allowing it to evolve independently. It introduces a bridge between the two by using an interface or abstract class. Here’s an example code snippet to illustrate the Bridge pattern:

// Example 
class Shape {
constructor(color) {
this.color = color;
}
draw() {}
}
// Concrete Abstractions
class Circle extends Shape {
draw() {
console.log(`Drawing a ${this.color} circle`);
}
}
class Square extends Shape {
draw() {
console.log(`Drawing a ${this.color} square`);
}
}
// Implementor
class Color {
getColor() {}
}
// Concrete Implementors
class RedColor extends Color {
getColor() {
return "red";
}
}
class BlueColor extends Color {
getColor() {
return "blue";
}
}
// Usage
const redCircle = new Circle(new RedColor());
redCircle.draw(); // Output: "Drawing a red circle"
const blueSquare = new Square(new BlueColor());
blueSquare.draw(); // Output: "Drawing a blue square"

In this example, we have the Abstraction represented by the Shape class, which has a color property and a draw method. The Concrete Abstractions, Circle and Square, inherit from the Shape class and implement their specific draw behavior. The Implementor is represented by the Color class, which declares the getColor method. The Concrete Implementors, RedColor, and BlueColor, inherit from the Color class and provide their respective color implementations.

In the usage section, we create instances of the Concrete Abstractions, passing the appropriate Concrete Implementor objects. This allows the Abstraction to delegate the color-related functionality to the Implementor. When we invoke the draw method, it accesses the color from the Implementor and performs the drawing operation accordingly.

11. Composite Patterns

The Composite pattern is a structural design pattern that allows you to treat individual objects and compositions of objects uniformly. It enables you to create hierarchical structures where each element can be treated as a single object or a collection of objects. The pattern uses a common interface to represent both individual objects (leaf nodes) and compositions (composite nodes), allowing clients to interact with them uniformly.

// Implementation 
class Employee {
constructor(name) {
this.name = name;
}
print() {
console.log(`Employee: ${this.name}`);
}
}
// Composite
class Manager extends Employee {
constructor(name) {
super(name);
this.employees = [];
}
add(employee) {
this.employees.push(employee);
}
remove(employee) {
const index = this.employees.indexOf(employee);
if (index !== -1) {
this.employees.splice(index, 1);
}
}
print() {
console.log(`Manager: ${this.name}`);
for (const employee of this.employees) {
employee.print();
}
}
}
// Usage
const john = new Employee("John Doe");
const jane = new Employee("Jane Smith");
const mary = new Manager("Mary Johnson");
mary.add(john);
mary.add(jane);
const peter = new Employee("Peter Brown");
const bob = new Manager("Bob Williams");
bob.add(peter);
bob.add(mary);
bob.print();

In this example, we have the Component class Employee, which represents individual employees. The Composite class Manager extends the Employee class and can contain a collection of employees. It provides methods to add and remove employees from the collection and overrides the print method to display the manager’s name and the employees under them.

In the usage section, we create a composite hierarchy where Manager objects can contain both individual employees (Employee) and other managers (Manager). We add employees to managers, constructing a hierarchical structure. Finally, we invoke the print method on the top-level manager, which recursively prints the hierarchy, showing the managers and their respective employees.

12. Observer Pattern

The Observer Pattern establishes a one-to-many relationship between objects, where multiple observers are notified of changes in the subject’s state. It enables loose coupling between objects and promotes event-driven communication.

// Implementation example of the Observer Pattern
class Subject {
constructor() {
this.observers = [];
}
addObserver(observer) {
this.observers.push(observer);
}
removeObserver(observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
notifyObservers() {
this.observers.forEach((observer) => observer.update());
}
}
class Observer {
update() {
console.log("Observer is notified of changes.");
}
}
const subject = new Subject();
const observer1 = new Observer();
const observer2 = new Observer();
subject.addObserver(observer1);
subject.addObserver(observer2);
subject.notifyObservers(); // Output: "Observer is notified of changes." "Observer is notified of changes."

In this example, the Subject class represents a subject that maintains a list of observers and provides methods to add, remove, and notify observers. The Observer class defines the behavior of an observer with its update method. In the usage section, a subject instance of the Subject class is created. Two observer instances are also created and added to the subject using the addObserver method.

When subject.notifyObservers() is invoked, it triggers the update method for each observer. As a result, the output "Observer is notified of changes." is logged twice, indicating that the observers have been notified of the changes in the subject.

13. Strategy Pattern

The Strategy Pattern allows you to encapsulate interchangeable algorithms within separate strategy objects. It enables dynamic selection of algorithms at runtime, promoting flexibility and extensibility.

// Implementation example of the Strategy Pattern
class Context {
constructor(strategy) {
this.strategy = strategy;
}
executeStrategy() {
this.strategy.execute();
}
}
class ConcreteStrategyA {
execute() {
console.log("Strategy A is executed.");
}
}
class ConcreteStrategyB {
execute() {
console.log("Strategy B is executed.");
}
}
const contextA = new Context(new ConcreteStrategyA());
contextA.executeStrategy(); // Output: "Strategy A is executed."
const contextB = new Context(new ConcreteStrategyB());
contextB.executeStrategy(); // Output: "Strategy B is executed."

In this example, the Context class represents a context that encapsulates different strategies, with a strategy property and an executeStrategy method. There are two concrete strategy classes, ConcreteStrategyA and ConcreteStrategyB, each with its own execute method that outputs a specific message.

In the usage section, a contextA instance of the Context class is created with ConcreteStrategyA as the strategy. Calling contextA.executeStrategy() invokes the execute method of ConcreteStrategyA, resulting in the output "Strategy A is executed." Similarly, a contextB instance is created with ConcreteStrategyB as the strategy, and invoking contextB.executeStrategy() triggers the execute method of ConcreteStrategyB, resulting in the output "Strategy B is executed." This demonstrates how the Strategy pattern allows for dynamic selection of behavior at runtime by encapsulating it in different strategy objects.

14. Command Pattern

The Command Pattern encapsulates a request as an object, allowing you to parameterize clients with different requests, queue or log requests, and support undo operations. It decouples the sender of a request from the receiver, promoting loose coupling and flexibility.

// Implementation 
class Receiver {
execute() {
console.log("Receiver executes the command.");
}
}
class Command {
constructor(receiver) {
this.receiver = receiver;
}
execute() {
this.receiver.execute();
}
}
class Invoker {
setCommand(command) {
this.command = command;
}
executeCommand() {
this.command.execute();
}
}
const receiver = new Receiver();
const command = new Command(receiver);
const invoker = new Invoker();
invoker.setCommand(command);
invoker.executeCommand(); // Output: "Receiver executes the command."

In this example, the Receiver class executes the command when called, and the Command class encapsulates a command and delegates execution to the receiver. The Invoker class sets and executes a command. In the usage section, a receiver, command, and invoker are created. The command is set for the invoker, and invoking invoker.executeCommand() executes the command, resulting in the output "Receiver executes the command."

15. Iterator Pattern

The Iterator pattern is a behavioral design pattern that provides a way to access the elements of an aggregate object sequentially without exposing its underlying representation. It allows you to traverse a collection of objects in a uniform manner, regardless of the specific implementation of the collection. The pattern separates the traversal logic from the collection, promoting a clean and flexible approach to iterating over elements.

// Implementation 
class Collection {
constructor() {
this.items = [];
}
addItem(item) {
this.items.push(item);
}
createIterator() {}
}
// Concrete Aggregate
class ConcreteCollection extends Collection {
createIterator() {
return new ConcreteIterator(this);
}
}
// Iterator
class Iterator {
constructor(collection) {
this.collection = collection;
this.index = 0;
}
hasNext() {}
next() {}
}
// Concrete Iterator
class ConcreteIterator extends Iterator {
hasNext() {
return this.index < this.collection.items.length;
}
next() {
return this.collection.items[this.index++];
}
}
// Usage
const collection = new ConcreteCollection();
collection.addItem("Item 1");
collection.addItem("Item 2");
collection.addItem("Item 3");
const iterator = collection.createIterator();
while (iterator.hasNext()) {
console.log(iterator.next());
}

In this code, we have the Aggregate represented by the Collection class, which defines the interface for creating an iterator object. The Concrete Aggregate, ConcreteCollection, extends the Collection class and provides a concrete implementation of the iterator creation.

The Iterator is represented by the Iterator class, which defines the interface for accessing and traversing elements. The Concrete Iterator, ConcreteIterator, extends the Iterator class and provides a concrete implementation of the iteration logic. In the usage section, we create an instance of the Concrete Aggregate, ConcreteCollection, and add items to it. We then create an iterator using the createIterator method. By using the iterator's hasNext and next methods, we iterate over the collection and print each item.

16. Mediator Pattern

The Mediator pattern simplifies object communication by introducing a mediator object that serves as a central hub for coordinating interactions between objects. It encapsulates the communication logic and provides methods for objects to register, send, and receive messages.

// Implementation 
class Mediator {
constructor() {
this.colleague1 = null;
this.colleague2 = null;
}
setColleague1(colleague) {
this.colleague1 = colleague;
}
setColleague2(colleague) {
this.colleague2 = colleague;
}
notifyColleague1(message) {
this.colleague1.receive(message);
}
notifyColleague2(message) {
this.colleague2.receive(message);
}
}
class Colleague {
constructor(mediator) {
this.mediator = mediator;
}
send(message) {
// Send a message to the mediator
this.mediator.notifyColleague2(message);
}
receive(message) {
console.log(`Received message: ${message}`);
}
}
// Usage
const mediator = new Mediator();
const colleague1 = new Colleague(mediator);
const colleague2 = new Colleague(mediator);
mediator.setColleague1(colleague1);
mediator.setColleague2(colleague2);
colleague1.send("Hello Colleague 2!"); // Output: "Received message: Hello Colleague 2!"

In this example, we have a Mediator class that acts as an intermediary between two Colleague objects. The Mediator holds references to the colleagues and provides methods to send messages between them.

Each Colleague object has a reference to the mediator and can send messages by notifying the mediator. The mediator, in turn, relays the messages to the appropriate colleagues. In this case, Colleague 1 sends a message to Colleague 2, and the latter receives and logs the message.

Conclusion

Design patterns are a very useful tool which any senior JavaScript developer should be aware of. Knowing the specifics regarding design patterns could prove incredibly useful and save you a lot of time in any project’s lifecycle, especially the maintenance part. Modifying and maintaining systems written with the help of design patterns which are a good fit for the system’s needs could prove invaluable.

By leveraging these design patterns, JavaScript developers can improve code reusability, maintainability, and overall system performance. Armed with this knowledge, we can architect robust and efficient JavaScript applications that meet the demands of modern software development.

Platform:
Website : https://victorpreston.tech
Thanks:

--

--