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

  1. Why SOLID Matters
  2. S — Single Responsibility Principle (SRP)
  3. O — Open/Closed Principle (OCP)
  4. L — Liskov Substitution Principle (LSP)
  5. I — Interface Segregation Principle (ISP)
  6. D — Dependency Inversion Principle (DIP)
  7. SOLID in Node.js/Express Applications
  8. SOLID Principles Summary Table
  9. Common Violations in Real-World Code
  10. Key Takeaways
  11. 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

SymptomRoot CauseSOLID Principle Violated
One change cascades through many filesClasses do too many thingsSRP
Adding a feature requires modifying stable codeCode is not open for extensionOCP
Substituting a subclass causes bugsSubtypes break parent contractsLSP
Classes forced to implement unused methodsInterfaces are too fatISP
High-level logic breaks when low-level details changeDirect dependencies on implementationsDIP

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:

  1. "Who would request changes to this class?" If the answer includes multiple teams/stakeholders, it violates SRP.
  2. "Can I describe what this class does without using 'and'?" If not, it probably does too much.
  3. "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 switch grows 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 type parameter 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

RuleMeaning
Preconditions can't be strengthenedSubtypes can't demand more from callers
Postconditions can't be weakenedSubtypes can't return less than promised
Invariants must be preservedSubtypes must maintain parent's guarantees
No new exceptionsSubtypes shouldn't throw exceptions the parent doesn't
History constraintSubtypes 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 OrderService without 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

PrincipleOne-LinerViolation SmellFix Pattern
SRPOne reason to changeGod classes, methods doing multiple thingsExtract classes/functions
OCPAdd, don't modifyGrowing switch/if-else chainsStrategy pattern, polymorphism
LSPSubtypes must be substitutableThrowing NotImplementedError, broken overridesProper abstraction hierarchy
ISPSmall, focused interfacesEmpty/no-op method implementationsSplit into role interfaces
DIPDepend on abstractionsnew ConcreteClass() in business logicConstructor 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

  1. SRP is about cohesion — group things that change for the same reason, separate things that change for different reasons.
  2. OCP is about extension — use polymorphism, strategies, and composition so new features are new code, not modified code.
  3. LSP is about correctness — subtypes must honor the contract of their parent. If they can't, they shouldn't inherit.
  4. ISP is about focus — small interfaces keep dependencies narrow and prevent "implement everything" bloat.
  5. DIP is about flexibility — by depending on abstractions, you can swap, mock, and extend without cascading changes.
  6. 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.
  7. 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:

  1. To a junior developer: "Why can't I just put all my logic in one big class? It works fine."
  2. To a product manager: "Why should we spend time refactoring this service into smaller pieces? It already works."
  3. In a code review: A teammate has a NotificationService that sends emails, SMS, push notifications, and Slack messages — all in one class with a switch statement on notification type. What feedback would you give?
  4. 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?

Next → 9.2.b — DRY and Other Principles