Episode 9 — System Design / 9.2 — Design Principles
9.2.a — SOLID Principles
In one sentence: SOLID is a set of five design principles — coined by Robert C. Martin (Uncle Bob) — that guide you toward writing classes and modules that are easy to understand, flexible to change, and safe to extend, forming the backbone of professional object-oriented and modular design.
Navigation: ← Overview · Next → DRY and Other Principles
Table of Contents
- Why SOLID Matters
- S — Single Responsibility Principle (SRP)
- O — Open/Closed Principle (OCP)
- L — Liskov Substitution Principle (LSP)
- I — Interface Segregation Principle (ISP)
- D — Dependency Inversion Principle (DIP)
- SOLID in Node.js/Express Applications
- SOLID Principles Summary Table
- Common Violations in Real-World Code
- Key Takeaways
- Explain-It Challenge
1. Why SOLID Matters
Every codebase begins small. The problems start when it grows:
Small codebase → "Everything works, who cares about structure?"
Medium codebase → "Changing one thing breaks two other things."
Large codebase → "Nobody wants to touch this file."
Unmaintainable mess → "Let's rewrite from scratch."
SOLID principles exist to break this cycle. They don't add features — they protect your ability to add features later.
The Cost of Ignoring SOLID
| Symptom | Root Cause | SOLID Principle Violated |
|---|---|---|
| One change cascades through many files | Classes do too many things | SRP |
| Adding a feature requires modifying stable code | Code is not open for extension | OCP |
| Substituting a subclass causes bugs | Subtypes break parent contracts | LSP |
| Classes forced to implement unused methods | Interfaces are too fat | ISP |
| High-level logic breaks when low-level details change | Direct dependencies on implementations | DIP |
SOLID Is Not Just for OOP
While SOLID was born in the OOP world, the principles apply everywhere:
- Functions — a function that does one thing (SRP)
- Modules — a module that exposes a focused API (ISP)
- Microservices — a service that has one reason to change (SRP)
- React components — a component closed for modification, open for extension via props (OCP)
2. S — Single Responsibility Principle (SRP)
"A class should have one, and only one, reason to change." — Robert C. Martin
SRP does not mean "a class does one thing." It means a class is responsible to one actor — one stakeholder or group of stakeholders who might request changes.
The Problem: A God Class
// BEFORE: Violates SRP — this class has FOUR reasons to change
class UserService {
// Reason 1: User data validation rules change
validateUser(data: { email: string; password: string }) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
throw new Error('Invalid email');
}
if (data.password.length < 8) {
throw new Error('Password too short');
}
}
// Reason 2: Database schema or ORM changes
async saveUser(data: { email: string; password: string }) {
const hashedPassword = await bcrypt.hash(data.password, 10);
return db.query(
'INSERT INTO users (email, password) VALUES ($1, $2)',
[data.email, hashedPassword]
);
}
// Reason 3: Email provider or template changes
async sendWelcomeEmail(email: string) {
const template = `<h1>Welcome!</h1><p>Thanks for signing up.</p>`;
await emailProvider.send({
to: email,
subject: 'Welcome!',
html: template,
});
}
// Reason 4: Logging format or destination changes
logActivity(userId: string, action: string) {
const timestamp = new Date().toISOString();
fs.appendFileSync('activity.log', `${timestamp} | ${userId} | ${action}\n`);
}
}
What goes wrong:
- The email team changes the template — you modify
UserService. - The DBA changes the schema — you modify
UserService. - The security team changes validation rules — you modify
UserService. - Every change risks breaking unrelated functionality.
The Fix: Separate Responsibilities
// AFTER: Each class has ONE reason to change
class UserValidator {
validate(data: { email: string; password: string }): void {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
throw new Error('Invalid email');
}
if (data.password.length < 8) {
throw new Error('Password too short');
}
}
}
class UserRepository {
async save(data: { email: string; password: string }): Promise<void> {
const hashedPassword = await bcrypt.hash(data.password, 10);
await db.query(
'INSERT INTO users (email, password) VALUES ($1, $2)',
[data.email, hashedPassword]
);
}
async findByEmail(email: string): Promise<User | null> {
const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
return result.rows[0] || null;
}
}
class WelcomeEmailSender {
async send(email: string): Promise<void> {
const template = `<h1>Welcome!</h1><p>Thanks for signing up.</p>`;
await emailProvider.send({
to: email,
subject: 'Welcome!',
html: template,
});
}
}
class ActivityLogger {
log(userId: string, action: string): void {
const timestamp = new Date().toISOString();
fs.appendFileSync('activity.log', `${timestamp} | ${userId} | ${action}\n`);
}
}
// Coordinator — orchestrates the workflow, doesn't own the details
class UserRegistrationService {
constructor(
private validator: UserValidator,
private repository: UserRepository,
private emailSender: WelcomeEmailSender,
private logger: ActivityLogger
) {}
async register(data: { email: string; password: string }): Promise<void> {
this.validator.validate(data);
await this.repository.save(data);
await this.emailSender.send(data.email);
this.logger.log(data.email, 'REGISTERED');
}
}
SRP at the Function Level
SRP applies to functions too:
// BAD: Function does validation AND formatting AND saving
function processOrder(order: Order) {
// validate
if (!order.items.length) throw new Error('Empty order');
if (!order.address) throw new Error('No address');
// calculate
let total = 0;
for (const item of order.items) {
total += item.price * item.quantity;
}
const tax = total * 0.1;
// format
const receipt = `Order Total: $${(total + tax).toFixed(2)}`;
// save
db.orders.insert({ ...order, total, tax });
return receipt;
}
// GOOD: Each function does one thing
function validateOrder(order: Order): void {
if (!order.items.length) throw new Error('Empty order');
if (!order.address) throw new Error('No address');
}
function calculateTotal(items: OrderItem[]): { subtotal: number; tax: number } {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return { subtotal, tax: subtotal * 0.1 };
}
function formatReceipt(subtotal: number, tax: number): string {
return `Order Total: $${(subtotal + tax).toFixed(2)}`;
}
function processOrder(order: Order): string {
validateOrder(order);
const { subtotal, tax } = calculateTotal(order.items);
db.orders.insert({ ...order, total: subtotal, tax });
return formatReceipt(subtotal, tax);
}
How to Identify SRP Violations
Ask yourself:
- "Who would request changes to this class?" If the answer includes multiple teams/stakeholders, it violates SRP.
- "Can I describe what this class does without using 'and'?" If not, it probably does too much.
- "If I change this one method, could it break other methods in this class?" If yes, the responsibilities are tangled.
3. O — Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification." — Bertrand Meyer
You should be able to add new behavior without changing existing, tested code.
The Problem: A Switch Statement That Never Stops Growing
// BEFORE: Violates OCP — adding a new payment method requires modifying this function
class PaymentProcessor {
processPayment(method: string, amount: number): void {
switch (method) {
case 'credit_card':
console.log(`Charging credit card: $${amount}`);
// credit card specific logic...
break;
case 'paypal':
console.log(`Charging PayPal: $${amount}`);
// paypal specific logic...
break;
case 'stripe':
console.log(`Charging Stripe: $${amount}`);
// stripe specific logic...
break;
// Every new payment method = modify this class
// What about Apple Pay? Google Pay? Crypto? Bank transfer?
default:
throw new Error(`Unknown payment method: ${method}`);
}
}
}
What goes wrong:
- Adding Apple Pay means editing
PaymentProcessor— a class that already works. - The
switchgrows into a 500-line monster. - Every change risks breaking existing payment methods.
- Testing becomes harder as all cases are tangled in one function.
The Fix: Polymorphism + Strategy
// AFTER: Open for extension, closed for modification
// Define the contract
interface PaymentStrategy {
readonly name: string;
pay(amount: number): Promise<PaymentResult>;
validate(details: PaymentDetails): boolean;
}
// Each payment method is its own class
class CreditCardPayment implements PaymentStrategy {
readonly name = 'credit_card';
async pay(amount: number): Promise<PaymentResult> {
console.log(`Charging credit card: $${amount}`);
// Credit card specific logic
return { success: true, transactionId: 'cc_123' };
}
validate(details: PaymentDetails): boolean {
return !!details.cardNumber && !!details.cvv;
}
}
class PayPalPayment implements PaymentStrategy {
readonly name = 'paypal';
async pay(amount: number): Promise<PaymentResult> {
console.log(`Charging PayPal: $${amount}`);
return { success: true, transactionId: 'pp_456' };
}
validate(details: PaymentDetails): boolean {
return !!details.email;
}
}
// Adding Apple Pay? Just create a new class. ZERO changes to existing code.
class ApplePayPayment implements PaymentStrategy {
readonly name = 'apple_pay';
async pay(amount: number): Promise<PaymentResult> {
console.log(`Charging Apple Pay: $${amount}`);
return { success: true, transactionId: 'ap_789' };
}
validate(details: PaymentDetails): boolean {
return !!details.applePayToken;
}
}
// Processor never changes — it's CLOSED for modification
class PaymentProcessor {
private strategies = new Map<string, PaymentStrategy>();
register(strategy: PaymentStrategy): void {
this.strategies.set(strategy.name, strategy);
}
async process(method: string, amount: number, details: PaymentDetails): Promise<PaymentResult> {
const strategy = this.strategies.get(method);
if (!strategy) {
throw new Error(`Unknown payment method: ${method}`);
}
if (!strategy.validate(details)) {
throw new Error(`Invalid payment details for ${method}`);
}
return strategy.pay(amount);
}
}
// Usage — extend by registration, not modification
const processor = new PaymentProcessor();
processor.register(new CreditCardPayment());
processor.register(new PayPalPayment());
processor.register(new ApplePayPayment()); // New! No existing code changed.
OCP with Higher-Order Functions
You don't always need classes. Functions can be open/closed too:
// BEFORE: Closed to extension — must modify to add new filters
function filterProducts(products: Product[], filterType: string) {
switch (filterType) {
case 'cheap': return products.filter(p => p.price < 20);
case 'inStock': return products.filter(p => p.stock > 0);
case 'rated': return products.filter(p => p.rating >= 4);
}
}
// AFTER: Open for extension via function composition
type ProductFilter = (product: Product) => boolean;
const cheap: ProductFilter = (p) => p.price < 20;
const inStock: ProductFilter = (p) => p.stock > 0;
const highlyRated: ProductFilter = (p) => p.rating >= 4;
// New filter? Just write a new function. No modification needed.
const organic: ProductFilter = (p) => p.tags.includes('organic');
function filterProducts(products: Product[], ...filters: ProductFilter[]): Product[] {
return products.filter(product => filters.every(f => f(product)));
}
// Compose freely
const results = filterProducts(products, cheap, inStock, organic);
Recognizing OCP Violations
Look for:
- Switch/if-else chains on type — usually means you need polymorphism
- Functions with a
typeparameter that controls behavior - Adding features requires editing existing functions instead of adding new ones
- The same conditional appearing in multiple places
4. L — Liskov Substitution Principle (LSP)
"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application." — Barbara Liskov
If your code works with a base type, it must also work with any derived type — no surprises, no special cases, no broken assumptions.
The Classic Violation: Square and Rectangle
// BEFORE: Violates LSP — Square "is-a" Rectangle, but breaks its contract
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number): void {
this.width = w;
}
setHeight(h: number): void {
this.height = h;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
constructor(side: number) {
super(side, side);
}
// Override to maintain the square invariant
setWidth(w: number): void {
this.width = w;
this.height = w; // Surprise! Setting width also changes height
}
setHeight(h: number): void {
this.width = h; // Surprise! Setting height also changes width
this.height = h;
}
}
// This function works perfectly with Rectangle, but BREAKS with Square
function testRectangle(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(4);
// We expect area = 5 * 4 = 20
console.log(rect.getArea()); // Rectangle: 20 ✅ Square: 16 ❌
}
testRectangle(new Rectangle(10, 10)); // 20 ✅
testRectangle(new Square(10)); // 16 ❌ — LSP VIOLATED
The Fix: Proper Abstraction
// AFTER: Respects LSP — model what they actually share
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
constructor(
private width: number,
private height: number
) {}
getArea(): number {
return this.width * this.height;
}
// Rectangle-specific methods
resize(width: number, height: number): Rectangle {
return new Rectangle(width, height);
}
}
class Square implements Shape {
constructor(private side: number) {}
getArea(): number {
return this.side * this.side;
}
// Square-specific methods
resize(side: number): Square {
return new Square(side);
}
}
// Now any Shape can be used safely
function printArea(shape: Shape) {
console.log(`Area: ${shape.getArea()}`);
}
printArea(new Rectangle(5, 4)); // Area: 20 ✅
printArea(new Square(5)); // Area: 25 ✅
A Real-World Violation: The "Throwing" Subclass
// BEFORE: Violates LSP — subclass throws where parent doesn't
class FileStorage {
save(key: string, data: string): void {
fs.writeFileSync(`./storage/${key}`, data);
}
read(key: string): string {
return fs.readFileSync(`./storage/${key}`, 'utf-8');
}
delete(key: string): void {
fs.unlinkSync(`./storage/${key}`);
}
}
class ReadOnlyStorage extends FileStorage {
save(key: string, data: string): void {
throw new Error('Cannot save to read-only storage'); // SURPRISE!
}
delete(key: string): void {
throw new Error('Cannot delete from read-only storage'); // SURPRISE!
}
}
// Code that works with FileStorage breaks with ReadOnlyStorage
function backupAndClean(storage: FileStorage) {
const data = storage.read('important');
storage.save('backup', data); // 💥 Throws with ReadOnlyStorage
storage.delete('important'); // 💥 Throws with ReadOnlyStorage
}
// AFTER: Separate interfaces for separate capabilities
interface Readable {
read(key: string): string;
}
interface Writable {
save(key: string, data: string): void;
}
interface Deletable {
delete(key: string): void;
}
class FileStorage implements Readable, Writable, Deletable {
read(key: string): string {
return fs.readFileSync(`./storage/${key}`, 'utf-8');
}
save(key: string, data: string): void {
fs.writeFileSync(`./storage/${key}`, data);
}
delete(key: string): void {
fs.unlinkSync(`./storage/${key}`);
}
}
class ReadOnlyFileStorage implements Readable {
read(key: string): string {
return fs.readFileSync(`./storage/${key}`, 'utf-8');
}
// No save or delete — they simply don't exist on this type
}
// Type system enforces correctness
function backupAndClean(storage: Readable & Writable & Deletable) {
const data = storage.read('important');
storage.save('backup', data);
storage.delete('important');
}
// backupAndClean(new ReadOnlyFileStorage()); // ❌ Compile error! Not a Writable.
LSP Rules of Thumb
| Rule | Meaning |
|---|---|
| Preconditions can't be strengthened | Subtypes can't demand more from callers |
| Postconditions can't be weakened | Subtypes can't return less than promised |
| Invariants must be preserved | Subtypes must maintain parent's guarantees |
| No new exceptions | Subtypes shouldn't throw exceptions the parent doesn't |
| History constraint | Subtypes shouldn't change state in ways the parent wouldn't |
5. I — Interface Segregation Principle (ISP)
"No client should be forced to depend on interfaces it does not use." — Robert C. Martin
Keep interfaces small and focused. A class should never be forced to implement methods it doesn't need.
The Problem: A Fat Interface
// BEFORE: Violates ISP — one fat interface forces all implementations
// to deal with methods they don't need
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
writeReport(): void;
codeReview(): void;
}
class Developer implements Worker {
work(): void { console.log('Writing code'); }
eat(): void { console.log('Eating lunch'); }
sleep(): void { console.log('Sleeping'); }
attendMeeting(): void { console.log('In standup'); }
writeReport(): void { console.log('Writing sprint report'); }
codeReview(): void { console.log('Reviewing PRs'); }
}
class Intern implements Worker {
work(): void { console.log('Learning and assisting'); }
eat(): void { console.log('Eating lunch'); }
sleep(): void { console.log('Sleeping'); }
attendMeeting(): void { console.log('Observing meetings'); }
// Forced to implement methods that don't apply
writeReport(): void {
throw new Error('Interns do not write reports'); // ❌ Smell!
}
codeReview(): void {
throw new Error('Interns do not review code'); // ❌ Smell!
}
}
class Robot implements Worker {
work(): void { console.log('Assembling parts'); }
// Robots don't eat, sleep, attend meetings, or write reports!
eat(): void { /* no-op */ } // ❌ Nonsense
sleep(): void { /* no-op */ } // ❌ Nonsense
attendMeeting(): void { /* no-op */ } // ❌ Nonsense
writeReport(): void { /* no-op */ } // ❌ Nonsense
codeReview(): void { /* no-op */ } // ❌ Nonsense
}
The Fix: Segregated Interfaces
// AFTER: Small, focused interfaces — implement only what you need
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
interface MeetingAttendee {
attendMeeting(): void;
}
interface ReportWriter {
writeReport(): void;
}
interface CodeReviewer {
codeReview(): void;
}
// Developer implements everything relevant
class Developer implements Workable, Feedable, Sleepable, MeetingAttendee, ReportWriter, CodeReviewer {
work(): void { console.log('Writing code'); }
eat(): void { console.log('Eating lunch'); }
sleep(): void { console.log('Sleeping'); }
attendMeeting(): void { console.log('In standup'); }
writeReport(): void { console.log('Writing sprint report'); }
codeReview(): void { console.log('Reviewing PRs'); }
}
// Intern implements only what applies
class Intern implements Workable, Feedable, Sleepable, MeetingAttendee {
work(): void { console.log('Learning and assisting'); }
eat(): void { console.log('Eating lunch'); }
sleep(): void { console.log('Sleeping'); }
attendMeeting(): void { console.log('Observing meetings'); }
// No writeReport or codeReview — they simply don't apply
}
// Robot implements only Workable
class Robot implements Workable {
work(): void { console.log('Assembling parts'); }
// Nothing else — clean and honest
}
// Functions declare exactly what they need
function assignWork(worker: Workable): void {
worker.work();
}
function scheduleLunch(feedable: Feedable): void {
feedable.eat();
}
ISP in TypeScript: Practical API Design
// BEFORE: One massive config object that most callers don't need fully
interface DatabaseConfig {
host: string;
port: number;
username: string;
password: string;
database: string;
ssl: boolean;
poolSize: number;
replicationHosts: string[];
readReplica: boolean;
migrationPath: string;
seedPath: string;
logging: boolean;
slowQueryThreshold: number;
}
// Every function requires the full config, even if it only needs a few fields
function connect(config: DatabaseConfig) { /* uses host, port, user, pass, db */ }
function migrate(config: DatabaseConfig) { /* uses migrationPath */ }
function seed(config: DatabaseConfig) { /* uses seedPath */ }
// AFTER: Segregated interfaces
interface ConnectionConfig {
host: string;
port: number;
username: string;
password: string;
database: string;
}
interface PoolConfig {
poolSize: number;
}
interface ReplicationConfig {
replicationHosts: string[];
readReplica: boolean;
}
interface MigrationConfig {
migrationPath: string;
}
interface LoggingConfig {
logging: boolean;
slowQueryThreshold: number;
}
// Each function takes only what it needs
function connect(config: ConnectionConfig & Partial<PoolConfig>): void { /* ... */ }
function migrate(config: ConnectionConfig & MigrationConfig): void { /* ... */ }
function setupLogging(config: LoggingConfig): void { /* ... */ }
6. D — Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions." — Robert C. Martin
DIP flips the traditional dependency direction. Instead of business logic depending on database code, both depend on an abstraction (interface) that the business logic defines.
The Problem: Direct Dependencies
// BEFORE: Violates DIP — high-level depends on low-level
// Low-level module
class MySQLDatabase {
query(sql: string): any[] {
console.log(`Executing MySQL query: ${sql}`);
return [];
}
}
// Low-level module
class SendGridEmailer {
send(to: string, subject: string, body: string): void {
console.log(`Sending via SendGrid to ${to}`);
}
}
// High-level module — directly depends on MySQL and SendGrid
class OrderService {
private db = new MySQLDatabase(); // ❌ Hard-coded dependency
private emailer = new SendGridEmailer(); // ❌ Hard-coded dependency
async placeOrder(order: Order): Promise<void> {
this.db.query(`INSERT INTO orders ...`);
this.emailer.send(order.email, 'Order Placed', 'Thanks!');
}
}
What goes wrong:
- Want to switch from MySQL to PostgreSQL? Rewrite
OrderService. - Want to switch from SendGrid to Mailgun? Rewrite
OrderService. - Want to test
OrderServicewithout a real database? Impossible without hacks. OrderService(business logic) is enslaved by implementation details.
The Fix: Depend on Abstractions
// AFTER: Both high-level and low-level depend on abstractions
// Abstractions (interfaces) — owned by the business layer
interface Database {
query(sql: string, params?: any[]): Promise<any[]>;
insert(table: string, data: Record<string, any>): Promise<void>;
}
interface EmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
// High-level module — depends on abstractions, not implementations
class OrderService {
constructor(
private db: Database, // ✅ Depends on interface
private emailer: EmailService // ✅ Depends on interface
) {}
async placeOrder(order: Order): Promise<void> {
await this.db.insert('orders', order);
await this.emailer.send(order.email, 'Order Placed', 'Thanks!');
}
}
// Low-level: MySQL implementation
class MySQLDatabase implements Database {
async query(sql: string, params?: any[]): Promise<any[]> {
console.log(`MySQL: ${sql}`);
return [];
}
async insert(table: string, data: Record<string, any>): Promise<void> {
console.log(`MySQL INSERT INTO ${table}`);
}
}
// Low-level: PostgreSQL implementation
class PostgresDatabase implements Database {
async query(sql: string, params?: any[]): Promise<any[]> {
console.log(`Postgres: ${sql}`);
return [];
}
async insert(table: string, data: Record<string, any>): Promise<void> {
console.log(`Postgres INSERT INTO ${table}`);
}
}
// Low-level: email implementations
class SendGridEmailer implements EmailService {
async send(to: string, subject: string, body: string): Promise<void> {
console.log(`SendGrid → ${to}`);
}
}
class MailgunEmailer implements EmailService {
async send(to: string, subject: string, body: string): Promise<void> {
console.log(`Mailgun → ${to}`);
}
}
// Wire up — switch implementations without touching business logic
const orderService = new OrderService(
new PostgresDatabase(), // Swap databases freely
new MailgunEmailer() // Swap email providers freely
);
// Test with mocks — no real database or email needed
class MockDatabase implements Database {
public queries: string[] = [];
async query(sql: string): Promise<any[]> {
this.queries.push(sql);
return [];
}
async insert(table: string, data: Record<string, any>): Promise<void> {
this.queries.push(`INSERT INTO ${table}`);
}
}
class MockEmailer implements EmailService {
public sentEmails: { to: string; subject: string }[] = [];
async send(to: string, subject: string, body: string): Promise<void> {
this.sentEmails.push({ to, subject });
}
}
// Clean, isolated testing
const mockDb = new MockDatabase();
const mockEmail = new MockEmailer();
const testService = new OrderService(mockDb, mockEmail);
The Dependency Inversion Diagram
BEFORE (traditional):
┌─────────────┐ ┌─────────────┐
│ OrderService │ ──────► │ MySQL DB │
│ (high-level) │ │ (low-level) │
└─────────────┘ └─────────────┘
│
└────────────────► ┌─────────────┐
│ SendGrid │
│ (low-level) │
└─────────────┘
AFTER (inverted):
┌─────────────┐ ┌─────────────┐
│ OrderService │ ──────► │ Database │ ◄──── MySQLDatabase
│ (high-level) │ │ (interface) │ ◄──── PostgresDatabase
└─────────────┘ └─────────────┘
│
└────────────────► ┌──────────────┐
│ EmailService │ ◄──── SendGridEmailer
│ (interface) │ ◄──── MailgunEmailer
└──────────────┘
Key insight: The arrows now point from low-level modules TO the abstraction, not from high-level to low-level. That's the "inversion."
7. SOLID in Node.js/Express Applications
Express Routes: SRP
// BEFORE: Route handler does everything
app.post('/api/users', async (req, res) => {
// Validation
if (!req.body.email || !req.body.password) {
return res.status(400).json({ error: 'Missing fields' });
}
if (req.body.password.length < 8) {
return res.status(400).json({ error: 'Password too short' });
}
// Business logic
const hashedPassword = await bcrypt.hash(req.body.password, 10);
// Database
const user = await db.query(
'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
[req.body.email, hashedPassword]
);
// Email
await transporter.sendMail({
to: req.body.email,
subject: 'Welcome',
html: '<h1>Welcome!</h1>',
});
// Response
res.status(201).json(user.rows[0]);
});
// AFTER: Layered architecture — each layer has one responsibility
// Validation middleware (SRP: validation only)
const validateCreateUser = (req: Request, res: Response, next: NextFunction) => {
const { error } = createUserSchema.validate(req.body);
if (error) return res.status(400).json({ error: error.message });
next();
};
// Controller (SRP: HTTP concerns only)
class UserController {
constructor(private userService: UserService) {}
create = async (req: Request, res: Response) => {
try {
const user = await this.userService.register(req.body);
res.status(201).json(user);
} catch (err) {
res.status(500).json({ error: 'Registration failed' });
}
};
}
// Service (SRP: business logic only)
class UserService {
constructor(
private repo: UserRepository,
private emailer: EmailService
) {}
async register(data: CreateUserDTO): Promise<User> {
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await this.repo.create({ ...data, password: hashedPassword });
await this.emailer.sendWelcome(user.email);
return user;
}
}
// Repository (SRP: data access only)
class UserRepository {
async create(data: CreateUserData): Promise<User> {
const result = await db.query(
'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
[data.email, data.password]
);
return result.rows[0];
}
}
// Route — thin, just wiring
app.post('/api/users', validateCreateUser, userController.create);
Express Middleware: OCP
// Express middleware chain is a perfect example of OCP
// The core app is CLOSED — you EXTEND it by adding middleware
// Each middleware extends behavior without modifying the core
app.use(cors()); // Extension: CORS
app.use(helmet()); // Extension: Security headers
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 })); // Extension: Rate limiting
app.use(morgan('combined')); // Extension: Logging
app.use(express.json()); // Extension: JSON parsing
// Custom middleware — extends without modifying existing code
const authenticate: RequestHandler = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
// Stack middleware — each one is independent
app.get('/api/profile', authenticate, cacheFor(300), userController.getProfile);
DIP in Express: Swappable Services
// Container pattern for DIP in Express
class Container {
private services = new Map<string, any>();
register<T>(key: string, instance: T): void {
this.services.set(key, instance);
}
resolve<T>(key: string): T {
const service = this.services.get(key);
if (!service) throw new Error(`Service not registered: ${key}`);
return service;
}
}
// Bootstrap
const container = new Container();
if (process.env.NODE_ENV === 'test') {
container.register<Database>('db', new InMemoryDatabase());
container.register<EmailService>('email', new MockEmailer());
} else {
container.register<Database>('db', new PostgresDatabase());
container.register<EmailService>('email', new SendGridEmailer());
}
// Services resolve their dependencies from the container
const userRepo = new UserRepository(container.resolve<Database>('db'));
const userService = new UserService(userRepo, container.resolve<EmailService>('email'));
const userController = new UserController(userService);
8. SOLID Principles Summary Table
| Principle | One-Liner | Violation Smell | Fix Pattern |
|---|---|---|---|
| SRP | One reason to change | God classes, methods doing multiple things | Extract classes/functions |
| OCP | Add, don't modify | Growing switch/if-else chains | Strategy pattern, polymorphism |
| LSP | Subtypes must be substitutable | Throwing NotImplementedError, broken overrides | Proper abstraction hierarchy |
| ISP | Small, focused interfaces | Empty/no-op method implementations | Split into role interfaces |
| DIP | Depend on abstractions | new ConcreteClass() in business logic | Constructor injection, interfaces |
Quick Decision Flow
When writing a new class/module, ask:
1. Does it have one reason to change? → SRP ✅
2. Can I extend it without modifying it? → OCP ✅
3. Can subtypes replace parents safely? → LSP ✅
4. Are interfaces small and focused? → ISP ✅
5. Does it depend on abstractions, not concretes? → DIP ✅
9. Common Violations in Real-World Code
Violation 1: The "Utils" File (SRP)
// utils.ts — a dumping ground for everything
export function formatDate(date: Date): string { /* ... */ }
export function hashPassword(password: string): string { /* ... */ }
export function validateEmail(email: string): boolean { /* ... */ }
export function calculateTax(amount: number): number { /* ... */ }
export function sendSlackNotification(msg: string): void { /* ... */ }
export function generatePDF(data: any): Buffer { /* ... */ }
// Fix: Group by responsibility
// dateUtils.ts — date formatting
// authUtils.ts — password hashing, token generation
// validationUtils.ts — input validation
// taxService.ts — tax calculations
// notificationService.ts — Slack, email, SMS
// pdfService.ts — PDF generation
Violation 2: The instanceof Check (LSP/OCP)
// BAD: Checking concrete types defeats the purpose of abstraction
function calculateShipping(item: ShippableItem): number {
if (item instanceof Book) {
return item.weight * 0.5; // Books get media mail rate
} else if (item instanceof Electronics) {
return item.weight * 2.0 + 5; // Electronics need insurance
} else if (item instanceof Furniture) {
return item.weight * 3.0 + item.dimensions.volume * 0.1;
}
return item.weight * 1.0;
}
// GOOD: Let each type handle its own shipping calculation
interface ShippableItem {
calculateShipping(): number;
}
class Book implements ShippableItem {
calculateShipping(): number {
return this.weight * 0.5;
}
}
class Electronics implements ShippableItem {
calculateShipping(): number {
return this.weight * 2.0 + 5;
}
}
Violation 3: The "God Service" in Express (SRP + DIP)
// BAD: One service that knows about EVERYTHING
class AppService {
async handleUserSignup(req: Request, res: Response) { /* validation + db + email + analytics + logging */ }
async handleOrderPlacement(req: Request, res: Response) { /* validation + payment + inventory + shipping */ }
async generateReport(req: Request, res: Response) { /* query + calculation + PDF + S3 upload */ }
async processWebhook(req: Request, res: Response) { /* parse + verify + route + update */ }
}
// Each method touches 4-5 different concerns.
// The file is 2000 lines. Nobody wants to touch it.
10. Key Takeaways
- SRP is about cohesion — group things that change for the same reason, separate things that change for different reasons.
- OCP is about extension — use polymorphism, strategies, and composition so new features are new code, not modified code.
- LSP is about correctness — subtypes must honor the contract of their parent. If they can't, they shouldn't inherit.
- ISP is about focus — small interfaces keep dependencies narrow and prevent "implement everything" bloat.
- DIP is about flexibility — by depending on abstractions, you can swap, mock, and extend without cascading changes.
- SOLID is not dogma — applying it rigidly to a 50-line script is overkill. Apply it proportionally to the codebase's size and expected lifespan.
- Start simple, refactor toward SOLID — you don't need perfect architecture on day one. Recognize violations when they cause pain, then fix them.
11. Explain-It Challenge
Test your understanding by explaining these concepts in your own words:
- To a junior developer: "Why can't I just put all my logic in one big class? It works fine."
- To a product manager: "Why should we spend time refactoring this service into smaller pieces? It already works."
- In a code review: A teammate has a
NotificationServicethat sends emails, SMS, push notifications, and Slack messages — all in one class with a switch statement on notification type. What feedback would you give? - Architecture decision: You're building an e-commerce platform. The CEO says "We only need credit cards for now, but we might add PayPal and crypto later." How do you design the payment module using OCP and DIP?