Design Patterns in JavaScript with Examples
Design patterns are proven solutions to typical problems in software development. For a JavaScript developer, knowledge of patterns is especially important, as the language has a flexible object model and is widely used both on the client and server side (Node.js). In this article, we will look at four key patterns with practical examples in JavaScript.
Why study design patterns?
Patterns help:
- Simplify code maintenance and scaling
- Make code more readable and predictable
- Avoid "reinventing the wheel"
- Improve team communication (common language)
1. Singleton
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. In Node.js, this pattern is often used for database connections, configuration management, or logging.
class DatabaseConnection { constructor() { if (DatabaseConnection.instance) { return DatabaseConnection.instance; } this.connection = null; DatabaseConnection.instance = this; }
connect() { // Simulating a DB connection this.connection = "connected"; console.log("Database connection established"); }
query(sql) { if (!this.connection) { throw new Error("No database connection"); } console.log(`Executing query: ${sql}`); return { data: [] }; }}
// Usageconst db1 = new DatabaseConnection();const db2 = new DatabaseConnection();
db1.connect();console.log(db1 === db2); // true - both point to the same instancedb2.query("SELECT * FROM users");When to use: managing shared resources (DB, configuration, cache). Caution: excessive use can lead to tight code coupling.
2. Factory
The Factory Method defines an interface for creating an object but allows subclasses to alter the type of object that will be created. In JavaScript, this is often implemented through regular functions or classes.
class User { constructor(name, role) { this.name = name; this.role = role; }
getPermissions() { return []; }}
class Admin extends User { getPermissions() { return ["read", "write", "delete", "manage"]; }}
class Editor extends User { getPermissions() { return ["read", "write"]; }}
class Viewer extends User { getPermissions() { return ["read"]; }}
// Factoryclass UserFactory { createUser(name, role) { switch (role) { case "admin": return new Admin(name, role); case "editor": return new Editor(name, role); case "viewer": return new Viewer(name, role); default: throw new Error("Unknown role"); } }}
// Usageconst factory = new UserFactory();const admin = factory.createUser("Anna", "admin");const viewer = factory.createUser("Ivan", "viewer");
console.log(admin.getPermissions()); // ["read", "write", "delete", "manage"]console.log(viewer.getPermissions()); // ["read"]Advantages: centralized object creation management, ease of adding new types, hiding instantiation logic.
3. Observer
The Observer pattern defines a one-to-many dependency between objects. When one object (the subject) changes its state, all dependent objects (observers) are automatically notified. This is the foundation of event-driven programming in Node.js.
class EventEmitter { constructor() { this.events = {}; }
on(eventName, listener) { if (!this.events[eventName]) { this.events[eventName] = []; } this.events[eventName].push(listener); }
emit(eventName, data) { const listeners = this.events[eventName]; if (listeners) { listeners.forEach(listener => listener(data)); } }
off(eventName, listenerToRemove) { const listeners = this.events[eventName]; if (listeners) { this.events[eventName] = listeners.filter( listener => listener !== listenerToRemove ); } }}
// Usageconst emitter = new EventEmitter();
function onUserLoggedIn(user) { console.log(`User logged in: ${user.name}`);}
emitter.on("userLogin", onUserLoggedIn);emitter.emit("userLogin", { name: "Maria" });// User logged in: Maria
emitter.off("userLogin", onUserLoggedIn);When to use: event handling, notification systems, updating UI components when data changes.