Lesson 10
Proven solutions to recurring software design problems
Design patterns are reusable solutions to problems that occur over and over in software design. They are not code you copy-paste — they are templates for solving a category of problem.
Problem: You have a growing if/switch chain to create different objects based on a type.
Solution: Replace conditional creation with a factory that maps types to constructors.
function createNotification(type, message) {
if (type === 'email') return new EmailNotification(message);
if (type === 'sms') return new SmsNotification(message);
if (type === 'push') return new PushNotification(message);
// Every new channel = modify this function
}
class NotificationFactory {
#creators = new Map();
register(type, creator) {
this.#creators.set(type, creator);
}
create(type, message) {
const creator = this.#creators.get(type);
if (!creator) throw new Error(`Unknown: ${type}`);
return creator(message);
}
}
// Registration — open for extension
const factory = new NotificationFactory();
factory.register('email', msg => new EmailNotification(msg));
factory.register('sms', msg => new SmsNotification(msg));
factory.register('push', msg => new PushNotification(msg));
// Adding Slack = just register, no modification:
factory.register('slack', msg => new SlackNotification(msg));
When to use: Object creation varies by type, and new types are added over time.
Problem: Constructing complex objects with many optional parameters leads to unreadable constructor calls or telescoping constructors.
Solution: A step-by-step builder with a fluent API.
class QueryBuilder {
#table = '';
#conditions = [];
#orderBy = null;
#limit = null;
from(table) { this.#table = table; return this; }
where(condition) { this.#conditions.push(condition); return this; }
orderBy(field) { this.#orderBy = field; return this; }
limit(n) { this.#limit = n; return this; }
build() {
let sql = `SELECT * FROM ${this.#table}`;
if (this.#conditions.length)
sql += ` WHERE ${this.#conditions.join(' AND ')}`;
if (this.#orderBy) sql += ` ORDER BY ${this.#orderBy}`;
if (this.#limit) sql += ` LIMIT ${this.#limit}`;
return sql;
}
}
// Readable, composable, self-documenting:
const query = new QueryBuilder()
.from('users')
.where('status = "active"')
.where('age >= 18')
.orderBy('created_at DESC')
.limit(10)
.build();
When to use: Object construction has many optional parameters or requires step-by-step assembly.
Problem: You need to use a class/library, but its interface does not match what your code expects.
Solution: Wrap it in an adapter that translates between the two interfaces.
// Your code expects this interface:
// { send(to, subject, body) }
// But the third-party library has:
// mailgun.messages().send({ from, to, subject, text })
class MailgunAdapter {
constructor(mailgunClient, fromAddress) {
this.client = mailgunClient;
this.from = fromAddress;
}
send(to, subject, body) {
return this.client.messages().send({
from: this.from,
to: to,
subject: subject,
text: body,
});
}
}
// Now it fits your interface:
const mailer = new MailgunAdapter(mailgun, 'noreply@app.com');
mailer.send('user@example.com', 'Welcome!', 'Hello...');
When to use: Integrating third-party libraries or legacy code with incompatible interfaces.
Problem: You need to add behavior to objects dynamically without modifying their class.
Solution: Wrap the object in a decorator that adds behavior before/after delegating to the original.
// Base logger
class ConsoleLogger {
log(message) { console.log(message); }
}
// Decorator: add timestamps
class TimestampLogger {
constructor(logger) { this.inner = logger; }
log(message) {
const ts = new Date().toISOString();
this.inner.log(`[${ts}] ${message}`);
}
}
// Decorator: add log level prefix
class LevelLogger {
constructor(logger, level) { this.inner = logger; this.level = level; }
log(message) {
this.inner.log(`${this.level}: ${message}`);
}
}
// Compose decorators:
const logger = new TimestampLogger(
new LevelLogger(new ConsoleLogger(), 'INFO')
);
logger.log('Server started');
// Output: [2025-01-15T10:30:00Z] INFO: Server started
When to use: Adding cross-cutting concerns (logging, caching, retries, auth) without modifying core logic.
Problem: A subsystem is complex with many interacting classes. Callers need a simple entry point.
Solution: A single facade class that orchestrates the subsystem behind a clean API.
// Complex subsystem: validator, repository, emailer, logger
class OrderFacade {
constructor(validator, repo, emailer, logger) {
this.validator = validator;
this.repo = repo;
this.emailer = emailer;
this.logger = logger;
}
async placeOrder(orderData) {
this.validator.validate(orderData);
const order = await this.repo.save(orderData);
await this.emailer.sendConfirmation(order);
this.logger.info('Order placed', { orderId: order.id });
return order;
}
}
// Callers see one simple method:
await orderFacade.placeOrder(data);
When to use: Simplifying access to a complex subsystem with many dependencies.
Problem: An algorithm varies by context, leading to if/switch on type with different behavior in each branch.
Solution: Encapsulate each algorithm as a strategy object and swap them at runtime.
// Define strategies
const sortStrategies = {
price: items => [...items].sort((a, b) => a.price - b.price),
rating: items => [...items].sort((a, b) => b.rating - a.rating),
newest: items => [...items].sort((a, b) => b.createdAt - a.createdAt),
popularity: items => [...items].sort((a, b) => b.sales - a.sales),
};
// Use the strategy
function sortProducts(items, strategyName) {
const strategy = sortStrategies[strategyName];
if (!strategy) throw new Error(`Unknown sort: ${strategyName}`);
return strategy(items);
}
// Adding a new sort = add one entry. No modification needed.
sortStrategies.alphabetical = items =>
[...items].sort((a, b) => a.name.localeCompare(b.name));
When to use: Replacing type-based if/switch chains with polymorphic behavior.
Problem: Multiple parts of your system need to react when something happens, but you do not want tight coupling between them.
Solution: An event emitter/subscriber model where publishers do not know about subscribers.
class EventBus {
#listeners = new Map();
on(event, callback) {
if (!this.#listeners.has(event))
this.#listeners.set(event, []);
this.#listeners.get(event).push(callback);
}
emit(event, data) {
const callbacks = this.#listeners.get(event) || [];
callbacks.forEach(cb => cb(data));
}
}
// Usage — decoupled communication:
const bus = new EventBus();
bus.on('order:placed', order => sendConfirmationEmail(order));
bus.on('order:placed', order => updateInventory(order));
bus.on('order:placed', order => notifyWarehouse(order));
// Publisher doesn't know who is listening:
bus.emit('order:placed', { id: 'ORD-1', items: [...] });
When to use: Event-driven systems, UI updates, or decoupling side effects from core logic.
Problem: Data access logic is scattered throughout the application, mixing SQL/ORM calls with business logic.
Solution: Centralize data access behind a repository interface, hiding the storage mechanism.
class UserRepository {
constructor(db) { this.db = db; }
async findById(id) {
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
async findByEmail(email) {
return this.db.query('SELECT * FROM users WHERE email = ?', [email]);
}
async save(user) {
if (user.id) {
return this.db.query('UPDATE users SET ... WHERE id = ?', [user.id]);
}
return this.db.query('INSERT INTO users ...', [user]);
}
async delete(id) {
return this.db.query('DELETE FROM users WHERE id = ?', [id]);
}
}
// Business logic never touches SQL:
const user = await userRepo.findByEmail('alice@example.com');
When to use: Any application with data persistence. Keeps business logic clean and storage swappable.
| Anti-Pattern | Symptom | Fix |
|---|---|---|
| God Class | One class does everything, 1000+ lines, touches every part of the system | Apply SRP. Extract focused classes with single responsibilities. |
| Anemic Domain Model | Classes are just data bags with getters/setters. All logic lives in service classes. | Move behavior into domain objects. order.calculateTotal() not service.calculateTotal(order) |
| Feature Envy | A method uses more data from another class than its own. It "envies" the other class. | Move the method to the class whose data it uses most. |
The biggest mistake is applying patterns where they are not needed. A 50-line script does not need a Factory, Strategy, and Observer. Patterns add indirection — only add indirection when it pays for itself in flexibility or clarity.
Start simple. When you notice a recurring pain (growing switch statements, tight coupling, scattered data access), reach for the pattern that solves that specific pain. Patterns are medicine, not vitamins — take them when sick, not prophylactically.
Which pattern replaces if/switch on type with polymorphic behavior?