Episode 9 — System Design / 9.3 — Creational Design Patterns
9.3.b Factory Method Pattern
The Problem It Solves
You need to create objects, but you don't know at compile time which exact class to instantiate. The decision depends on runtime input -- user type, configuration, environment, API response, etc.
Without a factory, your code fills up with conditional creation logic:
// BEFORE: Creation logic scattered everywhere
function sendNotification(type, message) {
let notification;
if (type === 'email') {
notification = new EmailNotification();
} else if (type === 'sms') {
notification = new SMSNotification();
} else if (type === 'push') {
notification = new PushNotification();
} else if (type === 'slack') {
notification = new SlackNotification();
}
notification.send(message);
}
// Problem: Adding a new type means editing THIS function
// Problem: This if/else chain is duplicated in 5 other places
The Factory Method pattern says: "Don't decide here. Delegate to a factory."
UML Diagram
+---------------------+ +---------------------+
| Creator | | Product |
|---------------------| |---------------------|
| + factoryMethod() |--------->| + operation() |
| + someOperation() | +---------------------+
+---------------------+ ^
^ |
| +-----------+-----------+
+---------------------+ | | |
| ConcreteCreator | ProductA ProductB ProductC
|---------------------|
| + factoryMethod() |
+---------------------+
Creator defines the interface.
ConcreteCreator decides WHICH product to create.
Products all share the same interface.
Factory Function vs Factory Class
JavaScript gives you two styles. Both are valid; pick based on complexity.
Style 1: Factory Function (Simple, Idiomatic JS)
// --- Products ---
class EmailNotification {
send(message) {
console.log(`EMAIL: ${message}`);
}
getChannel() { return 'email'; }
}
class SMSNotification {
send(message) {
console.log(`SMS: ${message}`);
}
getChannel() { return 'sms'; }
}
class PushNotification {
send(message) {
console.log(`PUSH: ${message}`);
}
getChannel() { return 'push'; }
}
// --- Factory Function ---
function createNotification(type) {
const notifications = {
email: EmailNotification,
sms: SMSNotification,
push: PushNotification,
};
const NotificationClass = notifications[type];
if (!NotificationClass) {
throw new Error(`Unknown notification type: "${type}"`);
}
return new NotificationClass();
}
// --- Usage ---
const notif = createNotification('email');
notif.send('Your order has shipped!');
// EMAIL: Your order has shipped!
const sms = createNotification('sms');
sms.send('Verification code: 8823');
// SMS: Verification code: 8823
Pros: Simple, no class boilerplate, easy to understand. Cons: Harder to extend without modifying the function (unless using a registry).
Style 2: Factory Class (Extensible, OOP-Friendly)
class NotificationFactory {
// Registry of creators
static registry = new Map();
// Register new types dynamically
static register(type, creator) {
NotificationFactory.registry.set(type, creator);
}
// Factory method
static create(type, options = {}) {
const Creator = NotificationFactory.registry.get(type);
if (!Creator) {
throw new Error(`No notification registered for type: "${type}"`);
}
return typeof Creator === 'function' && Creator.prototype
? new Creator(options)
: Creator(options); // Support both classes and factory functions
}
}
// Register product types
NotificationFactory.register('email', EmailNotification);
NotificationFactory.register('sms', SMSNotification);
NotificationFactory.register('push', PushNotification);
// --- Usage ---
const emailNotif = NotificationFactory.create('email');
emailNotif.send('Welcome aboard!');
// Later, a new team adds Slack support WITHOUT editing the factory:
class SlackNotification {
send(message) {
console.log(`SLACK: ${message}`);
}
getChannel() { return 'slack'; }
}
NotificationFactory.register('slack', SlackNotification);
const slack = NotificationFactory.create('slack');
slack.send('Build succeeded!');
// SLACK: Build succeeded!
Pros: Open/Closed Principle -- extend without modifying existing code. Cons: More ceremony for simple use cases.
Real-World Example 1: Payment Processor Factory
// --- Common interface ---
class PaymentProcessor {
charge(amount) {
throw new Error('charge() must be implemented');
}
refund(transactionId) {
throw new Error('refund() must be implemented');
}
getName() {
throw new Error('getName() must be implemented');
}
}
// --- Concrete processors ---
class StripeProcessor extends PaymentProcessor {
constructor(apiKey) {
super();
this.apiKey = apiKey;
this.name = 'Stripe';
}
charge(amount) {
console.log(`[Stripe] Charging $${amount}`);
return { success: true, transactionId: `stripe_${Date.now()}`, amount };
}
refund(transactionId) {
console.log(`[Stripe] Refunding ${transactionId}`);
return { success: true, refundId: `ref_${transactionId}` };
}
getName() { return this.name; }
}
class PayPalProcessor extends PaymentProcessor {
constructor(clientId) {
super();
this.clientId = clientId;
this.name = 'PayPal';
}
charge(amount) {
console.log(`[PayPal] Charging $${amount}`);
return { success: true, transactionId: `pp_${Date.now()}`, amount };
}
refund(transactionId) {
console.log(`[PayPal] Refunding ${transactionId}`);
return { success: true, refundId: `ref_${transactionId}` };
}
getName() { return this.name; }
}
class CryptoProcessor extends PaymentProcessor {
constructor(walletAddress) {
super();
this.walletAddress = walletAddress;
this.name = 'Crypto';
}
charge(amount) {
console.log(`[Crypto] Charging ${amount} USDC`);
return { success: true, transactionId: `crypto_${Date.now()}`, amount };
}
refund(transactionId) {
console.log(`[Crypto] Refunding ${transactionId}`);
return { success: true, refundId: `ref_${transactionId}` };
}
getName() { return this.name; }
}
// --- Factory ---
class PaymentFactory {
static create(method, config = {}) {
switch (method) {
case 'stripe':
return new StripeProcessor(config.apiKey || 'sk_test_xxx');
case 'paypal':
return new PayPalProcessor(config.clientId || 'pp_client_xxx');
case 'crypto':
return new CryptoProcessor(config.walletAddress || '0x000');
default:
throw new Error(`Unsupported payment method: "${method}"`);
}
}
}
// --- Usage ---
function processCheckout(cart, paymentMethod) {
const processor = PaymentFactory.create(paymentMethod, cart.paymentConfig);
console.log(`Processing with: ${processor.getName()}`);
const result = processor.charge(cart.total);
if (result.success) {
console.log(`Transaction ID: ${result.transactionId}`);
}
return result;
}
processCheckout({ total: 49.99, paymentConfig: {} }, 'stripe');
// [Stripe] Charging $49.99
// Transaction ID: stripe_1712345678
processCheckout({ total: 99.00, paymentConfig: {} }, 'paypal');
// [PayPal] Charging $99
// Transaction ID: pp_1712345679
Real-World Example 2: Notification Factory with Config
class NotificationService {
constructor() {
this.factory = new Map();
this.defaults = {};
}
register(type, CreatorClass, defaults = {}) {
this.factory.set(type, { CreatorClass, defaults });
}
create(type, overrides = {}) {
const entry = this.factory.get(type);
if (!entry) {
throw new Error(`Unknown notification type: ${type}`);
}
const config = { ...entry.defaults, ...overrides };
return new entry.CreatorClass(config);
}
send(type, message, overrides = {}) {
const notification = this.create(type, overrides);
return notification.send(message);
}
}
// --- Concrete notifications with config ---
class ConfigurableEmail {
constructor(config) {
this.from = config.from || 'noreply@app.com';
this.retries = config.retries || 3;
}
send(message) {
console.log(`Email from ${this.from}: ${message} (retries: ${this.retries})`);
}
}
class ConfigurableSMS {
constructor(config) {
this.provider = config.provider || 'twilio';
this.countryCode = config.countryCode || '+1';
}
send(message) {
console.log(`SMS via ${this.provider} (${this.countryCode}): ${message}`);
}
}
// --- Setup ---
const service = new NotificationService();
service.register('email', ConfigurableEmail, { from: 'team@startup.io', retries: 5 });
service.register('sms', ConfigurableSMS, { provider: 'twilio', countryCode: '+1' });
// --- Usage ---
service.send('email', 'Welcome to our platform!');
// Email from team@startup.io: Welcome to our platform! (retries: 5)
service.send('sms', 'Your code is 1234', { countryCode: '+44' });
// SMS via twilio (+44): Your code is 1234
Factory Method vs Constructor: When to Use Which
+---------------------+----------------------------+----------------------------+
| | Direct Constructor | Factory Method |
+---------------------+----------------------------+----------------------------+
| Creation logic | Caller decides the class | Factory decides the class |
| Coupling | Caller knows concrete class | Caller knows only interface |
| Flexibility | Low -- hard-coded class | High -- swap at runtime |
| Adding new types | Edit every call site | Edit only the factory |
| Readability | Clear for simple cases | Better for complex creation |
| Testing | Must mock the constructor | Mock or swap the factory |
+---------------------+----------------------------+----------------------------+
Rule of thumb:
- 1 type, simple creation --> Constructor
- 2+ types, chosen at runtime --> Factory Method
Before / After
Before: Tight Coupling
// Every route handler imports and instantiates directly
const express = require('express');
const router = express.Router();
router.post('/notify', (req, res) => {
const { type, message } = req.body;
// Duplicated conditional logic
if (type === 'email') {
const n = new EmailNotification(req.body.emailConfig);
n.send(message);
} else if (type === 'sms') {
const n = new SMSNotification(req.body.smsConfig);
n.send(message);
}
// Adding a new type? Edit THIS file AND 4 other handlers.
res.json({ sent: true });
});
After: Factory Method
const express = require('express');
const router = express.Router();
const { NotificationFactory } = require('./factories/notificationFactory');
router.post('/notify', (req, res) => {
const { type, message, ...config } = req.body;
const notification = NotificationFactory.create(type, config);
notification.send(message);
// Adding a new type? Register it in the factory. Done.
res.json({ sent: true });
});
Common Mistake: Overusing Factories
// DON'T: Factory for a single class with no variants
class UserFactory {
static create(name, email) {
return new User(name, email); // This adds zero value
}
}
// Just use the constructor directly:
const user = new User('Alice', 'alice@example.com');
A factory is justified when:
- There are multiple possible classes for the same interface.
- The creation logic is complex (requires validation, defaults, async work).
- You want to decouple the caller from the concrete class.
Parameterized Factory with Validation
class ShapeFactory {
static create(type, params) {
// Validate before creating
ShapeFactory._validate(type, params);
switch (type) {
case 'circle':
return new Circle(params.radius);
case 'rectangle':
return new Rectangle(params.width, params.height);
case 'triangle':
return new Triangle(params.base, params.height);
default:
throw new Error(`Unknown shape: ${type}`);
}
}
static _validate(type, params) {
const required = {
circle: ['radius'],
rectangle: ['width', 'height'],
triangle: ['base', 'height'],
};
const fields = required[type];
if (!fields) return;
for (const field of fields) {
if (params[field] == null || params[field] <= 0) {
throw new Error(`${type} requires a positive "${field}" parameter`);
}
}
}
}
class Circle {
constructor(radius) { this.radius = radius; }
area() { return Math.PI * this.radius ** 2; }
describe() { return `Circle(r=${this.radius}), area=${this.area().toFixed(2)}`; }
}
class Rectangle {
constructor(width, height) { this.width = width; this.height = height; }
area() { return this.width * this.height; }
describe() { return `Rectangle(${this.width}x${this.height}), area=${this.area()}`; }
}
class Triangle {
constructor(base, height) { this.base = base; this.height = height; }
area() { return 0.5 * this.base * this.height; }
describe() { return `Triangle(b=${this.base}, h=${this.height}), area=${this.area()}`; }
}
// --- Usage ---
const c = ShapeFactory.create('circle', { radius: 5 });
console.log(c.describe()); // Circle(r=5), area=78.54
const r = ShapeFactory.create('rectangle', { width: 4, height: 6 });
console.log(r.describe()); // Rectangle(4x6), area=24
// ShapeFactory.create('circle', { radius: -1 });
// Error: circle requires a positive "radius" parameter
Key Takeaways
- Factory Method decouples object creation from usage -- the caller doesn't need to know which concrete class is instantiated.
- Use a factory function for simple cases and a factory class with registry when extensibility matters (Open/Closed Principle).
- Factories shine when the type is determined at runtime (user input, config files, API responses).
- Don't wrap a constructor in a factory if there's only one product class -- that's over-engineering.
- Combine factories with validation to centralize creation rules in one place.
Explain-It Challenge: Your e-commerce app supports credit card, PayPal, Apple Pay, and Google Pay. New payment methods are added quarterly. Explain to a coworker why a Factory Method beats a switch statement in the checkout handler, in two sentences.