All topics
General · Learning hub

SOLID Principles notes for developers

Master SOLID Principles with a curated set of 5 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore General notes
SOLID Principles

Single Responsibility Principle

Single Responsibility Principle (SRP) "A class should have one, and only one, reason to change." The Single Responsibility Principle states that a class should

Single Responsibility Principle (SRP)

"A class should have one, and only one, reason to change."

The Single Responsibility Principle states that a class should have only one job or responsibility. If a class has multiple responsibilities, it becomes coupled, making it harder to change and maintain.

The Problem: Violating SRP

// ❌ BAD: This class has multiple responsibilities
class Employee {
  constructor(
    public name: string,
    public email: string,
    public salary: number
  ) {}

  // Responsibility 1: Business logic
  calculateBonus(): number {
    return this.salary * 0.10;
  }

  // Responsibility 2: Database operations
  save(): void {
    const connection = openDatabaseConnection();
    connection.execute(
      `INSERT INTO employees VALUES ('${this.name}', '${this.email}', ${this.salary})`
    );
    connection.close();
  }

  // Responsibility 3: Email operations
  sendWelcomeEmail(): void {
    const smtp = new SMTPClient();
    smtp.connect();
    smtp.send(this.email, `Welcome ${this.name}!`);
    smtp.disconnect();
  }

  // Responsibility 4: Report generation
  generatePayslip(): string {
    return `
      PAYSLIP
      Name: ${this.name}
      Salary: $${this.salary}
      Bonus: $${this.calculateBonus()}
      Total: $${this.salary + this.calculateBonus()}
    `;
  }
}

// Why is this bad?
// 1. If database changes, we modify Employee class
// 2. If email system changes, we modify Employee class
// 3. If payslip format changes, we modify Employee class
// 4. Hard to test - need database and email setup for every test
// 5. Can't reuse email logic or database logic elsewhere

The Solution: Applying SRP

// ✅ GOOD: Each class has a single, well-defined responsibility

// Responsibility 1: Employee data and business logic
class Employee {
  constructor(
    public readonly id: string,
    public name: string,
    public email: string,
    public salary: number
  ) {}

  calculateBonus(): number {
    return this.salary * 0.10;
  }

  getTotalCompensation(): number {
    return this.salary + this.calculateBonus();
  }
}

// Responsibility 2: Database operations
class EmployeeRepository {
  private db: Database;

  constructor(database: Database) {
    this.db = database;
  }

  save(employee: Employee): void {
    this.db.execute(
      `INSERT INTO employees (id, name, email, salary) 
       VALUES (?, ?, ?, ?)`,
      [employee.id, employee.name, employee.email, employee.salary]
    );
  }

  find(id: string): Employee | null {
    const result = this.db.query(
      `SELECT * FROM employees WHERE id = ?`,
      [id]
    );
    if (result.length === 0) return null;
    
    return new Employee(
      result[0].id,
      result[0].name,
      result[0].email,
      result[0].salary
    );
  }

  delete(id: string): void {
    this.db.execute(`DELETE FROM employees WHERE id = ?`, [id]);
  }
}

// Responsibility 3: Email operations
class EmailService {
  private smtp: SMTPClient;

  constructor(smtpConfig: SMTPConfig) {
    this.smtp = new SMTPClient(smtpConfig);
  }

  sendWelcomeEmail(employee: Employee): void {
    this.smtp.send(
      employee.email,
      'Welcome!',
      `Welcome to the company, ${employee.name}!`
    );
  }

  sendPayslipEmail(employee: Employee, payslip: string): void {
    this.smtp.send(
      employee.email,
      'Monthly Payslip',
      payslip
    );
  }
}

// Responsibility 4: Report generation
class PayslipGenerator {
  generate(employee: Employee): string {
    return `
      ═══════════════════════════════
              MONTHLY PAYSLIP
      ═══════════════════════════════
      Employee: ${employee.name}
      ID: ${employee.id}
      
      Base Salary:  $${employee.salary.toFixed(2)}
      Bonus:        $${employee.calculateBonus().toFixed(2)}
      ───────────────────────────────
      Total:        $${employee.getTotalCompensation().toFixed(2)}
      ═══════════════════════════════
    `;
  }
}

// Usage: Each class is focused and easy to test
const employee = new Employee('1', 'John Doe', 'john@example.com', 50000);

const repository = new EmployeeRepository(database);
repository.save(employee);

const emailService = new EmailService(smtpConfig);
emailService.sendWelcomeEmail(employee);

const payslipGen = new PayslipGenerator();
const payslip = payslipGen.generate(employee);
emailService.sendPayslipEmail(employee, payslip);

Benefits of SRP

  • Easier to understand: Each class does one thing, making code more readable

  • Easier to test: Can test each responsibility in isolation

  • Lower coupling: Classes are less dependent on each other

  • Easier to modify: Changes to one responsibility don't affect others

  • Better reusability: Focused classes can be reused in different contexts

Real-World Example: Order Processing

// ❌ BAD: Order class doing everything
class Order {
  processOrder() {
    // Validate order
    // Calculate total
    // Process payment
    // Update inventory
    // Send confirmation email
    // Generate invoice
    // Update analytics
  }
}

// ✅ GOOD: Separate responsibilities
class Order {
  constructor(
    public id: string,
    public items: OrderItem[],
    public customerId: string
  ) {}

  calculateTotal(): number {
    return this.items.reduce((sum, item) => sum + item.getPrice(), 0);
  }
}

class OrderValidator {
  validate(order: Order): ValidationResult {
    // Check if items are in stock
    // Validate customer exists
    // Check for minimum order amount
    return { isValid: true, errors: [] };
  }
}

class PaymentProcessor {
  process(order: Order, paymentMethod: PaymentMethod): PaymentResult {
    // Process payment through gateway
    return { success: true, transactionId: 'TX123' };
  }
}

class InventoryManager {
  updateStock(order: Order): void {
    order.items.forEach(item => {
      // Decrease stock for each item
    });
  }
}

class OrderNotificationService {
  sendConfirmation(order: Order): void {
    // Send confirmation email to customer
  }
}

class InvoiceGenerator {
  generate(order: Order): Invoice {
    // Generate PDF invoice
    return new Invoice(order);
  }
}

class AnalyticsTracker {
  trackOrderPlaced(order: Order): void {
    // Send event to analytics service
  }
}

// Coordinator that uses all services
class OrderProcessor {
  constructor(
    private validator: OrderValidator,
    private paymentProcessor: PaymentProcessor,
    private inventory: InventoryManager,
    private notifications: OrderNotificationService,
    private invoiceGen: InvoiceGenerator,
    private analytics: AnalyticsTracker
  ) {}

  async process(order: Order, payment: PaymentMethod): Promise<void> {
    const validation = this.validator.validate(order);
    if (!validation.isValid) {
      throw new Error('Order validation failed');
    }

    const paymentResult = await this.paymentProcessor.process(order, payment);
    if (!paymentResult.success) {
      throw new Error('Payment failed');
    }

    this.inventory.updateStock(order);
    this.notifications.sendConfirmation(order);
    this.invoiceGen.generate(order);
    this.analytics.trackOrderPlaced(order);
  }
}

How to Identify SRP Violations

  • Class name contains "and" (e.g., UserAndPermissionManager)

  • Class has too many methods (rule of thumb: >10 methods)

  • Class changes for multiple unrelated reasons

  • Difficult to name the class concisely

  • Class depends on many different libraries or frameworks

SOLID Principles

Open/Closed Principle

Open/Closed Principle (OCP) "Software entities should be open for extension but closed for modification." The Open/Closed Principle means you should be able to

Open/Closed Principle (OCP)

"Software entities should be open for extension but closed for modification."

The Open/Closed Principle means you should be able to add new functionality without changing existing code. This prevents introducing bugs in tested code when adding features.

The Problem: Violating OCP

// ❌ BAD: Must modify code to add new payment types
class PaymentProcessor {
  processPayment(amount: number, type: string): void {
    if (type === 'credit') {
      console.log(`Processing credit card payment: $${amount}`);
      // Credit card processing logic
      const fee = amount * 0.029;
      console.log(`Fee: $${fee}`);
    } else if (type === 'paypal') {
      console.log(`Processing PayPal payment: $${amount}`);
      // PayPal processing logic
      const fee = amount * 0.034;
      console.log(`Fee: $${fee}`);
    } else if (type === 'bitcoin') {
      console.log(`Processing Bitcoin payment: $${amount}`);
      // Bitcoin processing logic
      const fee = 0.5; // Flat fee
      console.log(`Fee: $${fee}`);
    }
    // To add a new payment type, we must modify this class!
    // Risk of breaking existing functionality
  }
}

// Problems:
// 1. Every new payment type requires modifying this class
// 2. Risk of introducing bugs in existing payment methods
// 3. Violates single responsibility (handles all payment types)
// 4. Hard to test each payment type in isolation

The Solution: Applying OCP

// ✅ GOOD: Open for extension, closed for modification

// Define interface (closed for modification)
interface PaymentMethod {
  processPayment(amount: number): void;
  calculateFee(amount: number): number;
}

// Concrete implementations (open for extension)
class CreditCardPayment implements PaymentMethod {
  processPayment(amount: number): void {
    console.log(`Processing credit card payment: $${amount}`);
    const fee = this.calculateFee(amount);
    console.log(`Fee: $${fee}`);
    // Credit card specific logic
  }

  calculateFee(amount: number): number {
    return amount * 0.029 + 0.30; // 2.9% + $0.30
  }
}

class PayPalPayment implements PaymentMethod {
  processPayment(amount: number): void {
    console.log(`Processing PayPal payment: $${amount}`);
    const fee = this.calculateFee(amount);
    console.log(`Fee: $${fee}`);
    // PayPal specific logic
  }

  calculateFee(amount: number): number {
    return amount * 0.034 + 0.49; // 3.4% + $0.49
  }
}

class BitcoinPayment implements PaymentMethod {
  processPayment(amount: number): void {
    console.log(`Processing Bitcoin payment: $${amount}`);
    const fee = this.calculateFee(amount);
    console.log(`Fee: $${fee}`);
    // Bitcoin specific logic
  }

  calculateFee(amount: number): number {
    return 0.5; // Flat fee
  }
}

// Easy to add new payment types without modifying existing code!
class ApplePayPayment implements PaymentMethod {
  processPayment(amount: number): void {
    console.log(`Processing Apple Pay: $${amount}`);
    const fee = this.calculateFee(amount);
    console.log(`Fee: $${fee}`);
  }

  calculateFee(amount: number): number {
    return amount * 0.015; // 1.5%
  }
}

// Payment processor is now closed for modification
class PaymentProcessor {
  processPayment(amount: number, method: PaymentMethod): void {
    method.processPayment(amount);
  }
}

// Usage
const processor = new PaymentProcessor();
processor.processPayment(100, new CreditCardPayment());
processor.processPayment(100, new PayPalPayment());
processor.processPayment(100, new BitcoinPayment());
processor.processPayment(100, new ApplePayPayment()); // New type, no changes to processor!

Real-World Example: Reporting System

// ❌ BAD: Must modify code for each new report format
class ReportGenerator {
  generate(data: any[], format: string): string {
    if (format === 'pdf') {
      return this.generatePDF(data);
    } else if (format === 'excel') {
      return this.generateExcel(data);
    } else if (format === 'html') {
      return this.generateHTML(data);
    }
    return '';
  }

  private generatePDF(data: any[]): string {
    // PDF generation logic
    return 'PDF content';
  }

  private generateExcel(data: any[]): string {
    // Excel generation logic
    return 'Excel content';
  }

  private generateHTML(data: any[]): string {
    // HTML generation logic
    return 'HTML content';
  }
}

// ✅ GOOD: Open for extension
interface ReportFormatter {
  format(data: any[]): string;
  getFileExtension(): string;
}

class PDFFormatter implements ReportFormatter {
  format(data: any[]): string {
    // PDF-specific formatting
    return `PDF Report with ${data.length} records`;
  }

  getFileExtension(): string {
    return '.pdf';
  }
}

class ExcelFormatter implements ReportFormatter {
  format(data: any[]): string {
    // Excel-specific formatting
    return `Excel Report with ${data.length} records`;
  }

  getFileExtension(): string {
    return '.xlsx';
  }
}

class HTMLFormatter implements ReportFormatter {
  format(data: any[]): string {
    return `<html>
      <body>
        <h1>Report</h1>
        <p>Records: ${data.length}</p>
      </body>
    </html>`;
  }

  getFileExtension(): string {
    return '.html';
  }
}

// Adding new format is easy - no changes to existing code!
class CSVFormatter implements ReportFormatter {
  format(data: any[]): string {
    if (data.length === 0) return '';
    
    const headers = Object.keys(data[0]).join(',');
    const rows = data.map(row => 
      Object.values(row).join(',')
    ).join('\n');
    
    return `${headers}\n${rows}`;
  }

  getFileExtension(): string {
    return '.csv';
  }
}

class JSONFormatter implements ReportFormatter {
  format(data: any[]): string {
    return JSON.stringify(data, null, 2);
  }

  getFileExtension(): string {
    return '.json';
  }
}

// Report generator is closed for modification
class ReportGenerator {
  generate(data: any[], formatter: ReportFormatter): { content: string; extension: string } {
    return {
      content: formatter.format(data),
      extension: formatter.getFileExtension()
    };
  }
}

// Usage
const data = [
  { id: 1, name: 'Alice', sales: 10000 },
  { id: 2, name: 'Bob', sales: 15000 }
];

const generator = new ReportGenerator();

const pdfReport = generator.generate(data, new PDFFormatter());
const excelReport = generator.generate(data, new ExcelFormatter());
const csvReport = generator.generate(data, new CSVFormatter());
const jsonReport = generator.generate(data, new JSONFormatter());

Using Abstraction to Achieve OCP

// Shape calculator example
interface Shape {
  calculateArea(): number;
}

class Circle implements Shape {
  constructor(private radius: number) {}

  calculateArea(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}

  calculateArea(): number {
    return this.width * this.height;
  }
}

class Triangle implements Shape {
  constructor(private base: number, private height: number) {}

  calculateArea(): number {
    return (this.base * this.height) / 2;
  }
}

// New shape - no changes to AreaCalculator!
class Hexagon implements Shape {
  constructor(private side: number) {}

  calculateArea(): number {
    return (3 * Math.sqrt(3) * this.side ** 2) / 2;
  }
}

// This class never needs to change when adding new shapes
class AreaCalculator {
  calculateTotalArea(shapes: Shape[]): number {
    return shapes.reduce((total, shape) => total + shape.calculateArea(), 0);
  }

  displayAreas(shapes: Shape[]): void {
    shapes.forEach((shape, index) => {
      console.log(`Shape ${index + 1}: ${shape.calculateArea().toFixed(2)} sq units`);
    });
  }
}

// Usage
const shapes: Shape[] = [
  new Circle(5),
  new Rectangle(4, 6),
  new Triangle(3, 4),
  new Hexagon(3)
];

const calculator = new AreaCalculator();
calculator.displayAreas(shapes);
console.log(`Total area: ${calculator.calculateTotalArea(shapes).toFixed(2)}`);

Benefits of OCP

  • Reduces risk: Existing code remains untouched and tested

  • Easier to maintain: New features don't affect old code

  • Better testability: Each extension can be tested independently

  • Flexibility: Easy to add new functionality without breaking existing features

SOLID Principles

Liskov Substitution Principle

Liskov Substitution Principle (LSP) "Objects of a superclass should be replaceable with objects of its subclasses without breaking the application." The Liskov

Liskov Substitution Principle (LSP)

"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."

The Liskov Substitution Principle means that if class B is a subtype of class A, we should be able to replace A with B without disrupting the behavior of our program.

The Problem: Violating LSP

// ❌ BAD: Square violates LSP
class Rectangle {
  constructor(
    protected width: number,
    protected height: number
  ) {}

  setWidth(width: number): void {
    this.width = width;
  }

  setHeight(height: number): void {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }

  // Problem: Square overrides behavior in unexpected way
  override setWidth(width: number): void {
    this.width = width;
    this.height = width; // Must keep square property
  }

  override setHeight(height: number): void {
    this.width = height; // Must keep square property
    this.height = height;
  }
}

// This function works correctly with Rectangle
function testRectangle(rect: Rectangle): void {
  rect.setWidth(5);
  rect.setHeight(4);
  
  console.log(`Expected area: 20`);
  console.log(`Actual area: ${rect.getArea()}`);
  
  // Expected: 20, but with Square it will be 16!
}

testRectangle(new Rectangle(0, 0)); // ✓ Works: Area = 20
testRectangle(new Square(0));       // ✗ Breaks: Area = 16

// Problem: Substituting Rectangle with Square breaks expected behavior!

The Solution: Applying LSP

// ✅ GOOD: Use composition or separate interfaces

// Option 1: Separate hierarchies
interface Shape {
  getArea(): number;
  getPerimeter(): number;
}

class Rectangle implements Shape {
  constructor(
    private width: number,
    private height: number
  ) {}

  setWidth(width: number): void {
    this.width = width;
  }

  setHeight(height: number): void {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }

  getPerimeter(): number {
    return 2 * (this.width + this.height);
  }
}

class Square implements Shape {
  constructor(private side: number) {}

  setSide(side: number): void {
    this.side = side;
  }

  getArea(): number {
    return this.side ** 2;
  }

  getPerimeter(): number {
    return 4 * this.side;
  }
}

// Now both work correctly with the Shape interface
function displayShapeInfo(shape: Shape): void {
  console.log(`Area: ${shape.getArea()}`);
  console.log(`Perimeter: ${shape.getPerimeter()}`);
}

displayShapeInfo(new Rectangle(5, 4)); // ✓ Works correctly
displayShapeInfo(new Square(5));       // ✓ Works correctly

Real-World Example: Birds

// ❌ BAD: Penguin can't fly, violates LSP
class Bird {
  fly(): void {
    console.log("Flying in the sky");
  }

  eat(): void {
    console.log("Eating food");
  }
}

class Sparrow extends Bird {
  // Inherits fly() - works fine
}

class Penguin extends Bird {
  // Problem: Penguins can't fly!
  override fly(): void {
    throw new Error("Penguins cannot fly");
  }
}

function makeBirdFly(bird: Bird): void {
  bird.fly(); // Works with Sparrow, crashes with Penguin!
}

// ✅ GOOD: Use proper interfaces
interface Animal {
  eat(): void;
  move(): void;
}

interface Flyable {
  fly(): void;
}

interface Swimmable {
  swim(): void;
}

class Sparrow implements Animal, Flyable {
  eat(): void {
    console.log("Sparrow eating seeds");
  }

  move(): void {
    this.fly();
  }

  fly(): void {
    console.log("Sparrow flying");
  }
}

class Penguin implements Animal, Swimmable {
  eat(): void {
    console.log("Penguin eating fish");
  }

  move(): void {
    this.swim();
  }

  swim(): void {
    console.log("Penguin swimming");
  }
}

class Duck implements Animal, Flyable, Swimmable {
  eat(): void {
    console.log("Duck eating");
  }

  move(): void {
    this.fly();
  }

  fly(): void {
    console.log("Duck flying");
  }

  swim(): void {
    console.log("Duck swimming");
  }
}

// Now we can work with appropriate interfaces
function makeFly(bird: Flyable): void {
  bird.fly(); // Only accepts birds that can fly
}

function makeSwim(animal: Swimmable): void {
  animal.swim(); // Only accepts animals that can swim
}

makeFly(new Sparrow());  // ✓ Works
makeFly(new Duck());     // ✓ Works
// makeFly(new Penguin()); // ✓ Compile error - Penguin doesn't implement Flyable

makeSwim(new Penguin()); // ✓ Works
makeSwim(new Duck());    // ✓ Works

LSP and Preconditions/Postconditions

Subclasses must not strengthen preconditions or weaken postconditions.

// ❌ BAD: Subclass strengthens preconditions
class Account {
  withdraw(amount: number): void {
    // Precondition: amount > 0
    if (amount <= 0) {
      throw new Error("Amount must be positive");
    }
    console.log(`Withdrew $${amount}`);
  }
}

class PremiumAccount extends Account {
  override withdraw(amount: number): void {
    // Strengthened precondition: amount > 0 AND amount <= 1000
    if (amount <= 0 || amount > 1000) {
      throw new Error("Amount must be between $0 and $1000");
    }
    console.log(`Withdrew $${amount}`);
  }
}

function processWithdrawal(account: Account): void {
  account.withdraw(5000); // Works with Account, fails with PremiumAccount
}

// ✅ GOOD: Subclass maintains or weakens preconditions
class Account {
  protected balance: number = 10000;

  withdraw(amount: number): void {
    if (amount <= 0) {
      throw new Error("Amount must be positive");
    }
    if (amount > this.balance) {
      throw new Error("Insufficient funds");
    }
    this.balance -= amount;
    console.log(`Withdrew $${amount}. Balance: $${this.balance}`);
  }
}

class PremiumAccount extends Account {
  private overdraftLimit: number = 5000;

  override withdraw(amount: number): void {
    // Same or weaker precondition (allows overdraft)
    if (amount <= 0) {
      throw new Error("Amount must be positive");
    }
    if (amount > this.balance + this.overdraftLimit) {
      throw new Error("Exceeds overdraft limit");
    }
    this.balance -= amount;
    console.log(`Withdrew $${amount}. Balance: $${this.balance}`);
  }
}

function processWithdrawal(account: Account): void {
  account.withdraw(500); // Works with both Account and PremiumAccount
}

Key Rules for LSP

  • Subclasses must accept the same input parameters as parent class

  • Subclasses must return the same types (or more specific) as parent class

  • Subclasses must not throw new exceptions parent doesn't throw

  • Subclasses must maintain class invariants

  • Subclasses should not require callers to have additional knowledge

SOLID Principles

Interface Segregation Principle

Interface Segregation Principle (ISP) "No client should be forced to depend on methods it does not use." The Interface Segregation Principle states that large i

Interface Segregation Principle (ISP)

"No client should be forced to depend on methods it does not use."

The Interface Segregation Principle states that large interfaces should be split into smaller, more specific ones so that clients only need to know about methods that are relevant to them.

The Problem: Violating ISP

// ❌ BAD: Fat interface forces implementations to include unused methods
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  getSalary(): number;
  attendMeeting(): void;
  code(): void;
}

class Developer implements Worker {
  work(): void {
    console.log("Writing code");
  }

  eat(): void {
    console.log("Eating lunch");
  }

  sleep(): void {
    console.log("Sleeping");
  }

  getSalary(): number {
    return 80000;
  }

  attendMeeting(): void {
    console.log("Attending standup");
  }

  code(): void {
    console.log("Coding features");
  }
}

class Robot implements Worker {
  work(): void {
    console.log("Assembling parts");
  }

  // Problem: Robot doesn't eat or sleep!
  eat(): void {
    throw new Error("Robots don't eat");
  }

  sleep(): void {
    throw new Error("Robots don't sleep");
  }

  getSalary(): number {
    return 0; // Robots don't get paid
  }

  attendMeeting(): void {
    throw new Error("Robots don't attend meetings");
  }

  code(): void {
    throw new Error("Robots don't code");
  }
}

// Problems:
// 1. Robot forced to implement methods it doesn't need
// 2. Methods throw errors at runtime instead of compile time
// 3. Interface is too large and inflexible

The Solution: Applying ISP

// ✅ GOOD: Segregated interfaces
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

interface Salaried {
  getSalary(): number;
}

interface Attendee {
  attendMeeting(): void;
}

interface Programmer {
  code(): void;
}

// Developer implements only relevant interfaces
class Developer implements Workable, Eatable, Sleepable, Salaried, Attendee, Programmer {
  work(): void {
    console.log("Working on tasks");
  }

  eat(): void {
    console.log("Eating lunch");
  }

  sleep(): void {
    console.log("Sleeping");
  }

  getSalary(): number {
    return 80000;
  }

  attendMeeting(): void {
    console.log("Attending standup");
  }

  code(): void {
    console.log("Coding features");
  }
}

// Robot implements only what it needs
class Robot implements Workable {
  work(): void {
    console.log("Assembling parts");
  }
  // No need to implement eat(), sleep(), etc.
}

// Manager implements their relevant interfaces
class Manager implements Workable, Eatable, Sleepable, Salaried, Attendee {
  work(): void {
    console.log("Managing team");
  }

  eat(): void {
    console.log("Eating lunch");
  }

  sleep(): void {
    console.log("Sleeping");
  }

  getSalary(): number {
    return 100000;
  }

  attendMeeting(): void {
    console.log("Leading meeting");
  }
  // No code() method - managers don't code
}

// Now we can use specific interfaces
function makeWork(worker: Workable): void {
  worker.work();
}

function payEmployee(employee: Salaried): void {
  console.log(`Paying $${employee.getSalary()}`);
}

function scheduleLunch(person: Eatable): void {
  person.eat();
}

makeWork(new Developer());  // ✓ Works
makeWork(new Robot());      // ✓ Works
makeWork(new Manager());    // ✓ Works

payEmployee(new Developer()); // ✓ Works
// payEmployee(new Robot());   // ✓ Compile error - Robot doesn't implement Salaried

scheduleLunch(new Developer()); // ✓ Works
// scheduleLunch(new Robot());   // ✓ Compile error - Robot doesn't implement Eatable

Real-World Example: Printer Interfaces

// ❌ BAD: One large interface
interface Printer {
  print(document: string): void;
  scan(document: string): string;
  fax(document: string): void;
  staple(): void;
}

class AllInOnePrinter implements Printer {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }

  scan(document: string): string {
    return `Scanned: ${document}`;
  }

  fax(document: string): void {
    console.log(`Faxing: ${document}`);
  }

  staple(): void {
    console.log("Stapling documents");
  }
}

class SimplePrinter implements Printer {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }

  // Problem: Simple printer can't do these!
  scan(document: string): string {
    throw new Error("This printer cannot scan");
  }

  fax(document: string): void {
    throw new Error("This printer cannot fax");
  }

  staple(): void {
    throw new Error("This printer cannot staple");
  }
}

// ✅ GOOD: Segregated printer interfaces
interface Printable {
  print(document: string): void;
}

interface Scannable {
  scan(document: string): string;
}

interface Faxable {
  fax(document: string): void;
}

interface Stapleable {
  staple(): void;
}

class SimplePrinter implements Printable {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }
}

class MultiFunctionPrinter implements Printable, Scannable {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }

  scan(document: string): string {
    return `Scanned: ${document}`;
  }
}

class AllInOnePrinter implements Printable, Scannable, Faxable, Stapleable {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }

  scan(document: string): string {
    return `Scanned: ${document}`;
  }

  fax(document: string): void {
    console.log(`Faxing: ${document}`);
  }

  staple(): void {
    console.log("Stapling documents");
  }
}

// Use specific capabilities
function printDocument(printer: Printable, doc: string): void {
  printer.print(doc);
}

function scanDocument(scanner: Scannable, doc: string): string {
  return scanner.scan(doc);
}

printDocument(new SimplePrinter(), "Report");        // ✓ Works
printDocument(new MultiFunctionPrinter(), "Report"); // ✓ Works
printDocument(new AllInOnePrinter(), "Report");      // ✓ Works

// scanDocument(new SimplePrinter(), "Doc");         // ✓ Compile error
scanDocument(new MultiFunctionPrinter(), "Doc");     // ✓ Works
scanDocument(new AllInOnePrinter(), "Doc");          // ✓ Works

Database Connection Example

// ❌ BAD: Monolithic interface
interface Database {
  connect(): void;
  disconnect(): void;
  query(sql: string): any[];
  transaction(callback: () => void): void;
  backup(): void;
  restore(backupFile: string): void;
  optimize(): void;
  analyze(): void;
}

// ✅ GOOD: Segregated interfaces
interface Connectable {
  connect(): void;
  disconnect(): void;
}

interface Queryable {
  query(sql: string): any[];
}

interface Transactional {
  beginTransaction(): void;
  commit(): void;
  rollback(): void;
}

interface Backupable {
  backup(): void;
  restore(backupFile: string): void;
}

interface Optimizable {
  optimize(): void;
  analyze(): void;
}

// Simple database connection
class BasicDatabaseConnection implements Connectable, Queryable {
  connect(): void {
    console.log("Connected to database");
  }

  disconnect(): void {
    console.log("Disconnected from database");
  }

  query(sql: string): any[] {
    console.log(`Executing: ${sql}`);
    return [];
  }
}

// Advanced database connection
class AdvancedDatabaseConnection 
  implements Connectable, Queryable, Transactional, Backupable, Optimizable {
  
  connect(): void {
    console.log("Connected");
  }

  disconnect(): void {
    console.log("Disconnected");
  }

  query(sql: string): any[] {
    return [];
  }

  beginTransaction(): void {
    console.log("Transaction started");
  }

  commit(): void {
    console.log("Transaction committed");
  }

  rollback(): void {
    console.log("Transaction rolled back");
  }

  backup(): void {
    console.log("Backup created");
  }

  restore(backupFile: string): void {
    console.log(`Restored from ${backupFile}`);
  }

  optimize(): void {
    console.log("Database optimized");
  }

  analyze(): void {
    console.log("Database analyzed");
  }
}

// Read-only connection
class ReadOnlyConnection implements Connectable, Queryable {
  connect(): void {
    console.log("Connected (read-only)");
  }

  disconnect(): void {
    console.log("Disconnected");
  }

  query(sql: string): any[] {
    if (sql.toUpperCase().includes("INSERT") || 
        sql.toUpperCase().includes("UPDATE") || 
        sql.toUpperCase().includes("DELETE")) {
      throw new Error("Write operations not allowed");
    }
    return [];
  }
}

Benefits of ISP

  • Better flexibility: Classes implement only what they need

  • Easier maintenance: Changes to one interface don't affect unrelated classes

  • Better code organization: Clear separation of concerns

  • Compile-time safety: Errors caught at compile time, not runtime

SOLID Principles

Dependency Inversion Principle

Dependency Inversion Principle (DIP) "High-level modules should not depend on low-level modules. Both should depend on abstractions." The Dependency Inversion P

Dependency Inversion Principle (DIP)

"High-level modules should not depend on low-level modules. Both should depend on abstractions."

The Dependency Inversion Principle states that we should depend on interfaces or abstract classes instead of concrete implementations. This makes our code more flexible and easier to test.

The Problem: Violating DIP

// ❌ BAD: High-level module depends on low-level module
class MySQLDatabase {
  connect(): void {
    console.log("Connected to MySQL");
  }

  query(sql: string): any[] {
    console.log(`MySQL query: ${sql}`);
    return [];
  }
}

// High-level module
class UserService {
  private database: MySQLDatabase; // Direct dependency on concrete class

  constructor() {
    this.database = new MySQLDatabase(); // Tightly coupled!
  }

  getUser(id: string): any {
    this.database.connect();
    return this.database.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

// Problems:
// 1. UserService is tightly coupled to MySQLDatabase
// 2. Can't switch to PostgreSQL without modifying UserService
// 3. Hard to test UserService (requires actual MySQL)
// 4. Changes to MySQLDatabase affect UserService

The Solution: Applying DIP

// ✅ GOOD: Both depend on abstraction

// Abstraction (interface)
interface Database {
  connect(): void;
  query(sql: string): any[];
}

// Low-level modules implement the abstraction
class MySQLDatabase implements Database {
  connect(): void {
    console.log("Connected to MySQL");
  }

  query(sql: string): any[] {
    console.log(`MySQL query: ${sql}`);
    return [];
  }
}

class PostgreSQLDatabase implements Database {
  connect(): void {
    console.log("Connected to PostgreSQL");
  }

  query(sql: string): any[] {
    console.log(`PostgreSQL query: ${sql}`);
    return [];
  }
}

class MongoDatabase implements Database {
  connect(): void {
    console.log("Connected to MongoDB");
  }

  query(sql: string): any[] {
    console.log(`MongoDB query: ${sql}`);
    return [];
  }
}

// High-level module depends on abstraction
class UserService {
  constructor(private database: Database) {} // Dependency injection

  getUser(id: string): any {
    this.database.connect();
    return this.database.query(`SELECT * FROM users WHERE id = ${id}`);
  }

  getAllUsers(): any[] {
    this.database.connect();
    return this.database.query(`SELECT * FROM users`);
  }
}

// Usage - easy to switch implementations
const mysqlService = new UserService(new MySQLDatabase());
const pgService = new UserService(new PostgreSQLDatabase());
const mongoService = new UserService(new MongoDatabase());

// Easy to test with mock
class MockDatabase implements Database {
  connect(): void {}
  query(sql: string): any[] {
    return [{ id: '1', name: 'Test User' }];
  }
}

const testService = new UserService(new MockDatabase());

Real-World Example: Notification System

// ❌ BAD: Direct dependencies
class EmailSender {
  send(to: string, message: string): void {
    console.log(`Email sent to ${to}: ${message}`);
  }
}

class UserRegistration {
  private emailSender: EmailSender;

  constructor() {
    this.emailSender = new EmailSender(); // Tight coupling
  }

  register(email: string, name: string): void {
    // Registration logic
    this.emailSender.send(email, `Welcome ${name}!`);
  }
}

// ✅ GOOD: Depend on abstraction
interface NotificationService {
  send(to: string, message: string): void;
}

class EmailNotification implements NotificationService {
  send(to: string, message: string): void {
    console.log(`📧 Email to ${to}: ${message}`);
    // SMTP logic here
  }
}

class SMSNotification implements NotificationService {
  send(to: string, message: string): void {
    console.log(`📱 SMS to ${to}: ${message}`);
    // SMS API logic here
  }
}

class PushNotification implements NotificationService {
  send(to: string, message: string): void {
    console.log(`🔔 Push to ${to}: ${message}`);
    // Push notification logic here
  }
}

class SlackNotification implements NotificationService {
  send(to: string, message: string): void {
    console.log(`💬 Slack to ${to}: ${message}`);
    // Slack API logic here
  }
}

// High-level module
class UserRegistration {
  constructor(private notificationService: NotificationService) {}

  register(identifier: string, name: string): void {
    console.log(`Registering user: ${name}`);
    // Registration logic...
    
    this.notificationService.send(
      identifier,
      `Welcome ${name}! Your account has been created.`
    );
  }
}

// Flexible usage
const emailReg = new UserRegistration(new EmailNotification());
emailReg.register('user@example.com', 'John');

const smsReg = new UserRegistration(new SMSNotification());
smsReg.register('+1234567890', 'Jane');

const pushReg = new UserRegistration(new PushNotification());
pushReg.register('device-123', 'Bob');

// Multi-channel notification
class MultiChannelNotification implements NotificationService {
  constructor(private services: NotificationService[]) {}

  send(to: string, message: string): void {
    this.services.forEach(service => service.send(to, message));
  }
}

const multiChannel = new UserRegistration(
  new MultiChannelNotification([
    new EmailNotification(),
    new PushNotification()
  ])
);

Dependency Injection Patterns

// Three types of dependency injection

// 1. Constructor Injection (Recommended)
class OrderService {
  constructor(
    private database: Database,
    private emailService: NotificationService,
    private paymentProcessor: PaymentProcessor
  ) {}

  placeOrder(order: Order): void {
    this.database.query('INSERT INTO orders...');
    this.paymentProcessor.process(order);
    this.emailService.send(order.email, 'Order confirmed');
  }
}

// 2. Property Injection
class OrderService {
  database!: Database;
  emailService!: NotificationService;

  setDatabase(db: Database): void {
    this.database = db;
  }

  setEmailService(service: NotificationService): void {
    this.emailService = service;
  }
}

// 3. Method Injection
class OrderService {
  placeOrder(
    order: Order,
    database: Database,
    emailService: NotificationService
  ): void {
    database.query('INSERT INTO orders...');
    emailService.send(order.email, 'Order confirmed');
  }
}

// Dependency Injection Container (Simple Example)
class Container {
  private services: Map<string, any> = new Map();

  register<T>(name: string, implementation: new (...args: any[]) => T): void {
    this.services.set(name, implementation);
  }

  resolve<T>(name: string): T {
    const Service = this.services.get(name);
    if (!Service) {
      throw new Error(`Service ${name} not found`);
    }
    return new Service();
  }
}

// Usage
const container = new Container();
container.register('database', MySQLDatabase);
container.register('notification', EmailNotification);

const db = container.resolve<Database>('database');
const notifier = container.resolve<NotificationService>('notification');
const orderService = new OrderService(db, notifier, paymentProcessor);

Testing with DIP

// Easy to test with mocks
interface UserRepository {
  findById(id: string): User | null;
  save(user: User): void;
}

interface EmailService {
  send(to: string, subject: string, body: string): void;
}

class UserService {
  constructor(
    private userRepo: UserRepository,
    private emailService: EmailService
  ) {}

  resetPassword(userId: string): void {
    const user = this.userRepo.findById(userId);
    if (!user) {
      throw new Error('User not found');
    }

    const newPassword = this.generatePassword();
    user.password = newPassword;
    this.userRepo.save(user);

    this.emailService.send(
      user.email,
      'Password Reset',
      `Your new password is: ${newPassword}`
    );
  }

  private generatePassword(): string {
    return Math.random().toString(36).slice(-8);
  }
}

// Mock implementations for testing
class MockUserRepository implements UserRepository {
  private users: Map<string, User> = new Map();

  findById(id: string): User | null {
    return this.users.get(id) || null;
  }

  save(user: User): void {
    this.users.set(user.id, user);
  }

  addUser(user: User): void {
    this.users.set(user.id, user);
  }
}

class MockEmailService implements EmailService {
  sentEmails: Array<{ to: string; subject: string; body: string }> = [];

  send(to: string, subject: string, body: string): void {
    this.sentEmails.push({ to, subject, body });
  }
}

// Test
function testPasswordReset(): void {
  const mockRepo = new MockUserRepository();
  const mockEmail = new MockEmailService();
  const userService = new UserService(mockRepo, mockEmail);

  // Setup test data
  mockRepo.addUser({
    id: '1',
    email: 'test@example.com',
    password: 'old-password'
  });

  // Execute
  userService.resetPassword('1');

  // Verify
  console.assert(mockEmail.sentEmails.length === 1, 'Email should be sent');
  console.assert(
    mockEmail.sentEmails[0].to === 'test@example.com',
    'Email sent to correct address'
  );
  console.log('✓ Test passed');
}

testPasswordReset();

Benefits of DIP

  • Loose coupling: Modules are independent of concrete implementations

  • Easy testing: Can inject mocks and stubs for unit tests

  • Flexibility: Easy to swap implementations without changing high-level code

  • Maintainability: Changes to low-level modules don't affect high-level modules

  • Parallel development: Teams can work on different modules independently

SOLID Summary

All five SOLID principles work together to create maintainable, flexible, and testable code:

  • Single Responsibility: One class, one job

  • Open/Closed: Open for extension, closed for modification

  • Liskov Substitution: Subclasses must be substitutable

  • Interface Segregation: Many specific interfaces better than one general

  • Dependency Inversion: Depend on abstractions, not concretions

Keep your SOLID Principles knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever