Design Patterns
Common software design patterns for backend development - Repository, Factory, Singleton, and more.
Why Design Patterns?#
Design patterns are proven solutions to recurring problems in software development. They emerged from decades of collective experience and represent best practices that have been tested across countless projects.
Benefits of using design patterns:
- Reusability - Proven solutions mean you don't reinvent the wheel. When you recognize a problem that fits a pattern, you have a ready-made solution.
- Maintainability - Code that follows established patterns is easier to understand and modify. New team members can quickly grasp the architecture.
- Testability - Many patterns (especially DI and Repository) create loosely coupled code that's much easier to unit test.
- Communication - Patterns provide a shared vocabulary. Saying "we use the Repository pattern" instantly communicates your data access strategy.
When NOT to use patterns:
Patterns add complexity. Don't use a pattern just because it exists. Use it when:
- You have a clear problem that the pattern solves
- The added structure provides more benefit than the overhead
- Your team understands the pattern
A simple script doesn't need dependency injection. A small app with one database doesn't need a repository layer. Start simple and add patterns when complexity demands them.
Repository Pattern#
The Problem#
Imagine your codebase has database queries scattered everywhere:
// In controller
const user = await prisma.user.findUnique({ where: { id } });
// In another controller
const users = await prisma.user.findMany({ where: { active: true } });
// In a service
const user = await prisma.user.findUnique({ where: { email } });
Problems with this approach:
- If you switch from Prisma to Mongoose, you change every file
- Testing requires mocking Prisma everywhere
- Query logic is duplicated (pagination, filtering, sorting)
- No single place to add caching or logging for database operations
The Solution#
The Repository pattern creates a single layer that handles all data access for a specific entity:
// src/repositories/userRepository.js
import { prisma } from '../lib/prisma.js';
export const UserRepository = {
async findById(id) {
return prisma.user.findUnique({ where: { id } });
},
async findByEmail(email) {
return prisma.user.findUnique({ where: { email } });
},
async findAll({ page = 1, limit = 20, where = {} }) {
const [users, total] = await prisma.$transaction([
prisma.user.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
return { users, total, page, limit };
},
async create(data) {
return prisma.user.create({ data });
},
async update(id, data) {
return prisma.user.update({ where: { id }, data });
},
async delete(id) {
return prisma.user.delete({ where: { id } });
},
async existsByEmail(email) {
const count = await prisma.user.count({ where: { email } });
return count > 0;
},
};
Using the Repository#
Now your services and controllers only know about the repository, not the database:
// src/services/userService.js
import { UserRepository } from '../repositories/userRepository.js';
export const UserService = {
async register(data) {
// Business logic stays clean - no database details here
if (await UserRepository.existsByEmail(data.email)) {
throw new Error('Email already exists');
}
const hashedPassword = await hashPassword(data.password);
return UserRepository.create({ ...data, password: hashedPassword });
},
};
Benefits#
- Single source of truth - All user queries live in one place
- Easy to test - Mock
UserRepositoryinstead ofprisma - Database agnostic - Switch from Prisma to raw SQL by changing only the repository
- Centralized concerns - Add logging, caching, or soft-delete logic in one place
When to Use#
- Medium to large applications with multiple services
- When you might switch databases or ORMs
- When you need to test business logic without database connections
- When multiple parts of your app access the same data
Factory Pattern#
The Problem#
Your app needs to send notifications. Sometimes email, sometimes SMS, sometimes push notifications. Without a pattern, you end up with code like:
// Scattered conditionals everywhere
if (user.notificationPreference === 'email') {
const emailService = new EmailNotification(config);
await emailService.send();
} else if (user.notificationPreference === 'sms') {
const smsService = new SMSNotification(config);
await smsService.send();
} else if (user.notificationPreference === 'push') {
// ... you get the idea
}
This logic gets repeated anywhere you send notifications. Adding a new notification type means updating every location.
The Solution#
A Factory centralizes object creation. The code that needs a notification doesn't care how it's created - it just asks the factory:
// src/factories/notificationFactory.js
import { EmailNotification } from '../notifications/email.js';
import { SMSNotification } from '../notifications/sms.js';
import { PushNotification } from '../notifications/push.js';
import { SlackNotification } from '../notifications/slack.js';
export function createNotification(type, config) {
const notifications = {
email: () => new EmailNotification(config),
sms: () => new SMSNotification(config),
push: () => new PushNotification(config),
slack: () => new SlackNotification(config),
};
const creator = notifications[type];
if (!creator) {
throw new Error(`Unknown notification type: ${type}`);
}
return creator();
}
Now sending a notification is simple anywhere in your app:
// Clean usage anywhere
const notification = createNotification(user.preference, {
to: user.contact,
subject: 'Welcome!',
body: 'Thanks for signing up.',
});
await notification.send();
Abstract Factory#
When you have families of related objects (like different payment providers each with their own checkout, subscription, and refund implementations), use an Abstract Factory:
// src/factories/paymentFactory.js
const paymentProviders = {
stripe: {
createCheckout: (amount) => new StripeCheckout(amount),
createSubscription: (plan) => new StripeSubscription(plan),
createRefund: (chargeId) => new StripeRefund(chargeId),
},
paypal: {
createCheckout: (amount) => new PayPalCheckout(amount),
createSubscription: (plan) => new PayPalSubscription(plan),
createRefund: (chargeId) => new PayPalRefund(chargeId),
},
};
export function getPaymentProvider(name = process.env.PAYMENT_PROVIDER) {
const provider = paymentProviders[name];
if (!provider) throw new Error(`Unknown payment provider: ${name}`);
return provider;
}
When to Use#
- Object creation logic is complex or varies based on conditions
- You need to support multiple implementations of an interface
- You want to decouple object creation from object usage
- Adding new types should not require changing existing code
Singleton Pattern#
The Problem#
Some resources should only exist once in your application:
- Database connections (you don't want 100 connection pools)
- Configuration loaders (read once, use everywhere)
- Logger instances (consistent logging throughout)
Without control, you might accidentally create multiple instances:
// Oops - each import creates a new connection!
const db1 = new Database(); // Connection 1
const db2 = new Database(); // Connection 2
// Each file importing this creates another connection...
The Solution#
A Singleton ensures only one instance exists, no matter how many times it's requested:
// src/lib/database.js
import { PrismaClient } from '@prisma/client';
class Database {
constructor() {
// If instance already exists, return it instead of creating new
if (Database.instance) {
return Database.instance;
}
this.prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
});
Database.instance = this;
}
getClient() {
return this.prisma;
}
async connect() {
await this.prisma.$connect();
}
async disconnect() {
await this.prisma.$disconnect();
}
}
export const database = new Database();
export const prisma = database.getClient();
Simpler Approach: Module Caching#
In Node.js, modules are cached after first import. This gives you singleton behavior naturally:
// src/lib/prisma.js
import { PrismaClient } from '@prisma/client';
// This runs once, on first import
// Subsequent imports return the cached module
const globalForPrisma = globalThis;
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
// Prevent multiple instances in development (hot reloading)
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
Warning: Use Sparingly#
Singletons can be problematic:
- Testing difficulty - Global state makes tests interdependent
- Hidden dependencies - Code that uses singletons has implicit dependencies
- Thread safety - Not a concern in Node.js, but important in other languages
Prefer dependency injection where possible. Use singletons only for truly global resources like database connections.
Strategy Pattern#
The Problem#
You have an algorithm that needs to vary based on context. For example, pricing calculations that differ by customer type:
// Messy conditionals
function calculatePrice(basePrice, quantity, customerType, promoCode) {
let price = basePrice * quantity;
if (customerType === 'bulk') {
if (quantity >= 100) price *= 0.8;
else if (quantity >= 50) price *= 0.9;
} else if (customerType === 'subscriber') {
if (tier === 'pro') price *= 0.85;
// More nested conditions...
} else if (promoCode) {
// Even more conditions...
}
return price;
}
This function will grow endlessly. Each new pricing rule adds more conditionals.
The Solution#
Extract each algorithm into its own "strategy" and swap them at runtime:
// src/strategies/pricing.js
const pricingStrategies = {
standard: {
name: 'Standard Pricing',
calculate: (basePrice, quantity) => basePrice * quantity,
},
bulk: {
name: 'Bulk Discount',
calculate: (basePrice, quantity) => {
// 20% off for 100+, 10% off for 50+
if (quantity >= 100) return basePrice * quantity * 0.8;
if (quantity >= 50) return basePrice * quantity * 0.9;
return basePrice * quantity;
},
},
subscription: {
name: 'Subscriber Discount',
calculate: (basePrice, quantity, tier) => {
const discounts = { basic: 0.95, pro: 0.85, enterprise: 0.7 };
return basePrice * quantity * (discounts[tier] || 1);
},
},
promotional: {
name: 'Promo Code',
calculate: (basePrice, quantity, promoCode) => {
const promos = { SAVE20: 0.8, SAVE30: 0.7, HALFOFF: 0.5 };
return basePrice * quantity * (promos[promoCode] || 1);
},
},
};
export function calculatePrice(strategy, basePrice, quantity, ...args) {
const pricingStrategy = pricingStrategies[strategy];
if (!pricingStrategy) {
throw new Error(`Unknown pricing strategy: ${strategy}`);
}
return pricingStrategy.calculate(basePrice, quantity, ...args);
}
Using Strategies#
// Clean, clear intent
calculatePrice('standard', 10, 5); // 50
calculatePrice('bulk', 10, 100); // 800 (20% off)
calculatePrice('subscription', 10, 5, 'pro'); // 42.5 (15% off)
calculatePrice('promotional', 10, 5, 'SAVE20'); // 40 (20% off)
When to Use#
- You have multiple algorithms that do similar things
- The algorithm choice depends on runtime conditions
- You want to avoid long if/else or switch statements
- You need to add new variations without modifying existing code
Dependency Injection#
The Problem#
When a class creates its own dependencies, it becomes impossible to test in isolation:
// HARD TO TEST
class UserService {
constructor() {
this.db = new Database(); // Creates real database connection
this.mailer = new EmailService(); // Creates real email service
}
async register(data) {
const user = await this.db.users.create(data);
await this.mailer.send(user.email, 'Welcome!'); // Sends real email!
return user;
}
}
To test register(), you need a real database and your tests will send real emails. That's not unit testing - that's integration testing with real side effects.
The Solution#
Pass dependencies in from outside (inject them):
// EASY TO TEST
class UserService {
constructor(db, mailer) {
this.db = db; // Received from outside
this.mailer = mailer; // Received from outside
}
async register(data) {
const user = await this.db.users.create(data);
await this.mailer.send(user.email, 'Welcome!');
return user;
}
}
Production vs Testing#
// Production - inject real services
const userService = new UserService(database, emailService);
// Testing - inject mocks
const mockDb = {
users: {
create: vi.fn().mockResolvedValue({ id: 1, email: 'test@test.com' })
}
};
const mockMailer = { send: vi.fn() };
const userService = new UserService(mockDb, mockMailer);
// Now test business logic without real database or emails
test('register sends welcome email', async () => {
await userService.register({ email: 'test@test.com' });
expect(mockMailer.send).toHaveBeenCalledWith('test@test.com', 'Welcome!');
});
Simple DI Container#
For larger apps, a container manages all the wiring:
// src/lib/container.js
class Container {
constructor() {
this.services = new Map();
this.singletons = new Map();
}
register(name, factory, singleton = false) {
this.services.set(name, { factory, singleton });
}
resolve(name) {
const service = this.services.get(name);
if (!service) throw new Error(`Service not found: ${name}`);
if (service.singleton) {
if (!this.singletons.has(name)) {
this.singletons.set(name, service.factory(this));
}
return this.singletons.get(name);
}
return service.factory(this);
}
}
export const container = new Container();
// Wire up your application
container.register('database', () => prisma, true);
container.register('mailer', () => new EmailService(), true);
container.register('userRepository', (c) => new UserRepository(c.resolve('database')));
container.register('userService', (c) => new UserService(
c.resolve('userRepository'),
c.resolve('mailer')
));
When to Use#
- You want to unit test code that has dependencies
- You need different implementations in different environments
- You want to swap implementations without changing consuming code
Observer Pattern#
The Problem#
When something happens (user signs up), multiple things need to respond:
- Send welcome email
- Create default settings
- Track analytics
- Notify admin
Without a pattern, the signup function becomes bloated:
async function registerUser(data) {
const user = await createUser(data);
// All these concerns mixed together
await sendWelcomeEmail(user);
await createDefaultSettings(user);
await trackAnalytics('signup', user);
await notifyAdmin(user);
// Add more here as features grow...
return user;
}
The Solution#
The Observer pattern decouples event producers from event consumers:
// src/lib/eventEmitter.js
class EventEmitter {
constructor() {
this.listeners = new Map();
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
// Return unsubscribe function
return () => this.off(event, callback);
}
emit(event, data) {
const callbacks = this.listeners.get(event) || [];
callbacks.forEach(callback => callback(data));
}
}
export const events = new EventEmitter();
Registering Listeners#
Each concern subscribes to the events it cares about:
// src/listeners/userListeners.js
import { events } from '../lib/eventEmitter.js';
// Email concern
events.on('user.created', async (user) => {
await sendWelcomeEmail(user);
});
// Settings concern
events.on('user.created', async (user) => {
await createDefaultSettings(user);
});
// Analytics concern
events.on('user.created', async (user) => {
await trackAnalytics('signup', user);
});
Emitting Events#
Now registration is clean - it just emits an event:
async function registerUser(data) {
const user = await createUser(data);
events.emit('user.created', user); // All listeners respond
return user;
}
Benefits#
- Loose coupling - Registration doesn't know about emails, analytics, etc.
- Easy to extend - Add new listeners without touching existing code
- Single responsibility - Each listener handles one concern
- Easy to test - Test listeners independently
Key Takeaways#
| Pattern | Use When | Benefit |
|---|---|---|
| Repository | Abstracting data access | Database-agnostic, testable |
| Factory | Creating varied objects | Centralized creation logic |
| Singleton | Single shared resource | Controlled instance |
| Strategy | Swappable algorithms | No conditionals |
| DI | Testable code | Mock dependencies |
| Observer | Event-driven reactions | Loose coupling |
Remember: Patterns are tools, not rules. Use them when they solve a real problem in your codebase. The best code is often the simplest code that gets the job done.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.