7 min read

Design Patterns: The Secret Weapon for Developers

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.

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 static instance property to store the single instance. The constructor checks if instance 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 a createVehicle method responsible for creating either a Car or Truck object based on the vehicleType.

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 methods addItem and getTotal.

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 invoking wrapped 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 of observers. When fire is called, it notifies all observers by calling their update 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. The Calculator 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!