Design Patterns in System Design
To understand how Design Patterns and System Design work together, think of building a city:
- System Design (The Macro Level): This is the city plan. It decides where the power plant goes, how the highway connects to the suburbs, and how the water supply handles a million residents (scaling, load balancing, microservices, databases).
- Design Patterns (The Micro Level): This is how you build a single building. It dictates the architectural structure of the walls, how the plumbing is laid out, and standardizing the doors so anyone can open them (code structure, maintainability, reusability).
When doing System Design, you are dividing the system into large components (like an Auth Service, a Database, and an Email Service). Design Patterns are the coding techniques you use inside those specific components so the code doesn't become a messy spaghetti as the system scales.
Let's look at 4 highly practical Design Patterns you will use in Node.js backend systems.
1. The Singleton Pattern
The Concept: Ensure that a class has only one instance and provide a global point of access to it.
System Design Use Case: Database Connection Pools or Caching Clients (Redis). Opening a new database connection for every single user request will crash your system. You want to create one connection pool and share it across your entire Node.js application.
Node.js Example: In Node.js, module caching naturally gives us a Singleton behavior.
// dbInstance.js
const { Pool } = require("pg");
class DatabaseConnection {
constructor() {
if (!DatabaseConnection.instance) {
this.pool = new Pool({
user: "admin",
host: "localhost",
database: "my_system",
password: "password",
port: 5432,
});
// Save the instance to itself
DatabaseConnection.instance = this;
}
return DatabaseConnection.instance;
}
query(text, params) {
return this.pool.query(text, params);
}
}
// Exporting an INSTANCE, not the class.
// Every file that imports this will share the exact same database pool.
const instance = new DatabaseConnection();
module.exports = instance;// server.js
const db = require("./dbInstance");
app.get("/users", async (req, res) => {
// Reuses the single global connection pool
const users = await db.query("SELECT * FROM users");
res.json(users);
});2. The Factory Pattern
The Concept: Create objects without exposing the instantiation logic to the client. Let a "Factory" function decide exactly what class to create based on the input.
System Design Use Case: Payment Gateways or Notification Services. Suppose your system sends notifications via SMS, Email, and Push notifications. The rest of your system shouldn't care how it's sent, it just wants to say send().
Node.js Example:
// notificationServices.js
class EmailService {
send(message) {
console.log(`Sending Email: ${message}`);
}
}
class SMSService {
send(message) {
console.log(`Sending SMS: ${message}`);
}
}
class PushNotificationService {
send(message) {
console.log(`Sending Push Notification: ${message}`);
}
}
// The Factory
class NotificationFactory {
static createNotifier(type) {
switch (type) {
case "email":
return new EmailService();
case "sms":
return new SMSService();
case "push":
return new PushNotificationService();
default:
throw new Error("Unknown notification type");
}
}
}
// Usage in your API route
const notificationType = user.preferredNotificationMethod; // e.g., 'sms'
// The Factory decides which class to instantiate!
const notifier = NotificationFactory.createNotifier(notificationType);
notifier.send("Your order has been shipped!");3. The Observer Pattern (Pub/Sub)
The Concept: An object (the Subject) maintains a list of its dependents (Observers) and automatically notifies them of any state changes.
System Design Use Case: Event-Driven Microservices. In modern systems, when a user registers, you need to: 1) Save to DB, 2) Send a Welcome Email, 3) Update analytics. Doing this synchronously is slow. Instead, the User Service simply "publishes" an event, and other services "observe" and react.
Node.js Example: Node.js has this built-in via the EventEmitter module.
// eventBus.js
const EventEmitter = require("events");
class EventBus extends EventEmitter {}
const eventBus = new EventBus();
module.exports = eventBus;// emailService.js (The Observer)
const eventBus = require("./eventBus");
// Listening for the event
eventBus.on("USER_REGISTERED", (user) => {
console.log(`Sending welcome email to ${user.email}`);
// Logic to send email...
});// analyticsService.js (Another Observer)
const eventBus = require("./eventBus");
eventBus.on("USER_REGISTERED", (user) => {
console.log(`Updating marketing metrics for new user: ${user.id}`);
});// userController.js (The Subject/Publisher)
const eventBus = require("./eventBus");
async function registerUser(req, res) {
const newUser = await database.save(req.body);
// Publish the event. The controller doesn't care who is listening!
// This keeps the system decoupled and highly scalable.
eventBus.emit("USER_REGISTERED", newUser);
res.status(201).send("User created successfully!");
}4. The Strategy Pattern
The Concept: Define a family of algorithms, encapsulate each one, and make them interchangeable at runtime.
System Design Use Case: Authentication or Data Compression. Think of the famous Node.js library passport.js. It uses the Strategy pattern to let you swap between Google OAuth, Facebook Auth, and Local Email/Password authentication without rewriting your core login logic.
Node.js Example (Image Storage Strategy): Imagine your system usually saves images to AWS S3, but for local development, you want to save them to the local disk.
// strategies.js
class AWSS3StorageStrategy {
save(file) {
console.log(`Uploading ${file} to AWS S3...`);
// AWS SDK logic here
}
}
class LocalDiskStorageStrategy {
save(file) {
console.log(`Saving ${file} to local C: drive...`);
// Node fs.writeFileSync logic here
}
}
// The Context class that uses the strategy
class ImageUploader {
constructor(strategy) {
this.strategy = strategy; // Inject the strategy
}
setStrategy(strategy) {
this.strategy = strategy;
}
upload(file) {
return this.strategy.save(file);
}
}
// Usage based on Environment Variable (System Design configuration)
const uploader = new ImageUploader();
if (process.env.NODE_ENV === "production") {
uploader.setStrategy(new AWSS3StorageStrategy());
} else {
uploader.setStrategy(new LocalDiskStorageStrategy());
}
uploader.upload("profile_picture.jpg");Summary
When answering a System Design interview question or building a real application:
- Draw the boxes (Microservices, DBs, Cache). (System Design)
- When the interviewer says: "How does the Notification Service handle different types of alerts?" -> You answer with the Factory Pattern.
- When they say: "How do we make sure our Database connections don't get overwhelmed?" -> You answer with connection pooling using the Singleton Pattern.
- When they say: "How do we loosely couple the Order Service and the Shipping Service?" -> You answer with the Observer (Pub/Sub) Pattern using an Event Queue like Kafka or RabbitMQ.
