Welcome back to our series, The Everyday Backend Engineer: Practical Design Patterns. In the last post, we protected our system resources using the Singleton pattern. Today, we are staying within the Creational Patterns family to tackle a major readability and maintainability headache: The Factory Pattern.
Let’s break down how this pattern saves your codebase from drowning in nested conditional statements.
🔴 The Problem: Conditional Code Bloat
Imagine you are building a signup service for your application. When a user registers, they can choose how they want to receive their welcome notification: via Email, SMS, or WhatsApp.
As a quick first instinct, you might bundle all the logic directly inside your signup service file:
// ❌ Bad Practice: Core service is tightly coupled with every provider type
class SignupService {
async register(user, method) {
console.log("Saving user to the database...");
// The codebase smell: Bloated conditional instantiation
if (method === 'email') {
const emailService = new EmailNotification();
await emailService.send(user.email, "Welcome!");
} else if (method === 'sms') {
const smsService = new SMSNotification();
await smsService.send(user.phone, "Welcome!");
} else if (method === 'whatsapp') {
const whatsappService = new WhatsAppNotification();
await whatsappService.send(user.phone, "Welcome!");
} else {
throw new Error("Unsupported notification method");
}
}
}
Why is this fragile?
Your core business logic file (SignupService) now knows the intimate implementation details of every single notification provider.
If you want to add a new notification provider tomorrow (like Viber or Slack), you have to rip open this exact file and add another else if block. This directly violates the Open-Closed Principle (software entities should be open for extension, but closed for modification) and makes testing a nightmare.
🟢 The Solution: A Dedicated Object Factory
The core business logic should not care how a notification object is instantiated or which concrete class is being invoked. It should only care that it receives an object that exposes a .send() method.
By creating a dedicated factory module, we move the messy instantiation details completely out of the main logic path.
🏭 The Real-World Analogy: "The Restaurant Menu"
Think of a restaurant's Menu Manager. When you sit at a table, you order a "Chicken Momo" or a "Veg Burger" by passing a string to the waiter. You do not go into the kitchen, grab the raw ingredients, light the stove, and cook it yourself. The kitchen is the factory; it processes your string input and serves you a prepared dish ready to be consumed.
💻 The Clean Node.js Implementation
Let’s build a clean, decoupled system using the Factory Pattern.
First, we separate our concrete notification providers into distinct, self-contained classes:
// services/providers.js
class EmailNotification {
async send(target, message) {
console.log(`✉️ Sending Email to ${target}: ${message}`);
}
}
class SMSNotification {
async send(target, message) {
console.log(`📱 Sending SMS to ${target}: ${message}`);
}
}
class WhatsAppNotification {
async send(target, message) {
console.log(`💬 Sending WhatsApp to ${target}: ${message}`);
}
}
module.exports = { EmailNotification, SMSNotification, WhatsAppNotification };
Next, we create our factory class which handles the conditional logic in complete isolation:
// factories/notificationFactory.js
const { EmailNotification, SMSNotification, WhatsAppNotification } = require('../services/providers');
class NotificationFactory {
// Static method to serve ready-made instances based on type
static createProvider(type) {
switch (type.toLowerCase()) {
case 'email':
return new EmailNotification();
case 'sms':
return new SMSNotification();
case 'whatsapp':
return new WhatsAppNotification();
default:
throw new Error(`Notification provider type [${type}] is not supported.`);
}
}
}
module.exports = NotificationFactory;
🎯 How to use it inside your core service
Now, look at how clean, concise, and beautifully isolated our core business logic file becomes:
// services/signupService.js
const NotificationFactory = require('../factories/notificationFactory');
class SignupService {
async register(user, method) {
console.log("📂 Step 1: User data successfully saved to PostgreSQL.");
// Step 2: Request the ready-made provider instance from our factory
const notificationProvider = NotificationFactory.createProvider(method);
// Step 3: Simply execute the command. No conditional bloat!
await notificationProvider.send(user.contact, "Welcome to our platform!");
}
}
module.exports = SignupService;
🧠 Code Smell Test: When should you use a Factory?
Look out for these indicators during code reviews:
-
The Smell: A single file contains multiple
if/elseorswitchblocks dedicated solely to determining which structural class to instantiate using thenewkeyword based on user input or configurations. - The Fix: Delegate that dynamic conditional mapping responsibility entirely to a standalone Factory class.
That wraps up Step 2! Your core workflows are now completely isolated from dirty instantiation details.
Up next in our blog series is Step 3: The Builder Pattern, where we will explore how to cleanly construct complex objects with multiple optional steps without cluttering constructors.
How do you handle conditional creation in your current apps? Do you use Factories or load dynamic configurations out of JSON objects? Let me know in the comments below!
Top comments (0)