Back to blog

SOLID Principles: Writing Clean, Maintainable Code

oopsolid-principlesdesign-patternstypescriptpythonjava
SOLID Principles: Writing Clean, Maintainable Code

Introduction

You've learned the fundamentals of OOP—classes, encapsulation, inheritance, and polymorphism. But knowing OOP syntax isn't enough. How do you decide when to create a new class? How should classes relate to each other? What makes code easy to change months later?

The SOLID principles answer these questions. Coined by Robert C. Martin (Uncle Bob), these five principles are the bridge between understanding OOP and writing software that's genuinely maintainable, testable, and extensible.

What You'll Learn

✅ Apply the Single Responsibility Principle to create focused classes
✅ Use the Open/Closed Principle to extend behavior without modifying code
✅ Respect the Liskov Substitution Principle for safe inheritance
✅ Design with the Interface Segregation Principle for lean contracts
✅ Implement the Dependency Inversion Principle for flexible architectures
✅ Recognize SOLID violations and refactor them step by step

Prerequisites


Why SOLID Matters

Before diving into each principle, let's understand the problems SOLID solves:

ProblemWithout SOLIDWith SOLID
Adding featuresModify existing classes, risk breaking thingsAdd new classes, existing code untouched
TestingCan't test one thing without setting up everythingEach class testable in isolation
Understanding codeOne class does 10 thingsEach class has a clear purpose
Changing requirementsChanges ripple through the entire codebaseChanges isolated to specific modules
Team collaborationEveryone edits the same filesTeams work on separate, well-defined modules

SOLID isn't about following rules blindly—it's about managing complexity as your codebase grows.


S — Single Responsibility Principle (SRP)

A class should have only one reason to change.

The SRP says each class should do one thing and do it well. If a class has multiple responsibilities, changes to one responsibility can break the other.

Violation: The God Class

// TypeScript - SRP Violation
class UserService {
  // Responsibility 1: User data management
  createUser(name: string, email: string): User {
    const user = { id: Date.now(), name, email };
    this.saveToDatabase(user);
    this.sendWelcomeEmail(user);
    this.logAction(`User created: ${email}`);
    return user;
  }
 
  // Responsibility 2: Database operations
  private saveToDatabase(user: User): void {
    // Direct SQL query
    console.log(`INSERT INTO users VALUES (${user.id}, '${user.name}', '${user.email}')`);
  }
 
  // Responsibility 3: Email sending
  private sendWelcomeEmail(user: User): void {
    // SMTP configuration and sending
    console.log(`Sending welcome email to ${user.email}`);
  }
 
  // Responsibility 4: Logging
  private logAction(message: string): void {
    const timestamp = new Date().toISOString();
    console.log(`[${timestamp}] ${message}`);
  }
}

Why is this bad? If you change the email provider, you modify UserService. If you switch databases, you modify UserService. If you change logging format, you modify UserService. One class, four reasons to change.

Refactored: Each Class, One Job

// TypeScript - SRP Applied
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
// Responsibility: Persist users
class UserRepository {
  save(user: User): void {
    console.log(`Saving user ${user.email} to database`);
  }
 
  findByEmail(email: string): User | null {
    console.log(`Finding user by email: ${email}`);
    return null;
  }
}
 
// Responsibility: Send emails
class EmailService {
  sendWelcome(user: User): void {
    console.log(`Sending welcome email to ${user.email}`);
  }
 
  sendPasswordReset(user: User): void {
    console.log(`Sending password reset to ${user.email}`);
  }
}
 
// Responsibility: Log actions
class ActionLogger {
  log(message: string): void {
    const timestamp = new Date().toISOString();
    console.log(`[${timestamp}] ${message}`);
  }
}
 
// Responsibility: Orchestrate user creation
class UserService {
  constructor(
    private repository: UserRepository,
    private emailService: EmailService,
    private logger: ActionLogger
  ) {}
 
  createUser(name: string, email: string): User {
    const user: User = { id: Date.now(), name, email };
    this.repository.save(user);
    this.emailService.sendWelcome(user);
    this.logger.log(`User created: ${email}`);
    return user;
  }
}

Now UserService has one job: orchestrating user creation. Each collaborator handles its own responsibility.

# Python - SRP Applied
from dataclasses import dataclass
from datetime import datetime
 
@dataclass
class User:
    id: int
    name: str
    email: str
 
class UserRepository:
    """Responsibility: Persist users"""
    def save(self, user: User) -> None:
        print(f"Saving user {user.email} to database")
 
    def find_by_email(self, email: str) -> User | None:
        print(f"Finding user by email: {email}")
        return None
 
class EmailService:
    """Responsibility: Send emails"""
    def send_welcome(self, user: User) -> None:
        print(f"Sending welcome email to {user.email}")
 
class ActionLogger:
    """Responsibility: Log actions"""
    def log(self, message: str) -> None:
        timestamp = datetime.now().isoformat()
        print(f"[{timestamp}] {message}")
 
class UserService:
    """Responsibility: Orchestrate user creation"""
    def __init__(
        self,
        repository: UserRepository,
        email_service: EmailService,
        logger: ActionLogger
    ):
        self._repository = repository
        self._email_service = email_service
        self._logger = logger
 
    def create_user(self, name: str, email: str) -> User:
        user = User(id=int(datetime.now().timestamp()), name=name, email=email)
        self._repository.save(user)
        self._email_service.send_welcome(user)
        self._logger.log(f"User created: {email}")
        return user
// Java - SRP Applied
public class UserRepository {
    // Responsibility: Persist users
    public void save(User user) {
        System.out.println("Saving user " + user.getEmail() + " to database");
    }
 
    public Optional<User> findByEmail(String email) {
        System.out.println("Finding user by email: " + email);
        return Optional.empty();
    }
}
 
public class EmailService {
    // Responsibility: Send emails
    public void sendWelcome(User user) {
        System.out.println("Sending welcome email to " + user.getEmail());
    }
}
 
public class ActionLogger {
    // Responsibility: Log actions
    public void log(String message) {
        String timestamp = java.time.Instant.now().toString();
        System.out.println("[" + timestamp + "] " + message);
    }
}
 
public class UserService {
    // Responsibility: Orchestrate user creation
    private final UserRepository repository;
    private final EmailService emailService;
    private final ActionLogger logger;
 
    public UserService(UserRepository repository, EmailService emailService, ActionLogger logger) {
        this.repository = repository;
        this.emailService = emailService;
        this.logger = logger;
    }
 
    public User createUser(String name, String email) {
        User user = new User(System.currentTimeMillis(), name, email);
        repository.save(user);
        emailService.sendWelcome(user);
        logger.log("User created: " + email);
        return user;
    }
}

How to Identify SRP Violations

Ask yourself:

  1. Can you describe the class with "and"? — "This class manages users and sends emails and logs actions" = violation
  2. Do unrelated changes force modifications? — Changing email provider shouldn't touch user logic
  3. Is the class hard to name? — If you need a generic name like Manager or Handler, it probably does too much

O — Open/Closed Principle (OCP)

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

You should be able to add new behavior without changing existing code. This is achieved through abstraction and polymorphism.

Violation: Switch Statements That Grow

// TypeScript - OCP Violation
class DiscountCalculator {
  calculate(customerType: string, amount: number): number {
    switch (customerType) {
      case "regular":
        return amount * 0.05;
      case "premium":
        return amount * 0.10;
      case "vip":
        return amount * 0.20;
      // Every new customer type requires modifying this class!
      default:
        return 0;
    }
  }
}

Why is this bad? Adding a new customer type (e.g., "enterprise") requires modifying DiscountCalculator. Every modification risks breaking existing discount logic.

Refactored: Open for Extension

// TypeScript - OCP Applied
 
interface DiscountStrategy {
  calculate(amount: number): number;
}
 
class RegularDiscount implements DiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.05;
  }
}
 
class PremiumDiscount implements DiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.10;
  }
}
 
class VIPDiscount implements DiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.20;
  }
}
 
// Adding new discount? Just create a new class!
class EnterpriseDiscount implements DiscountStrategy {
  calculate(amount: number): number {
    return amount * 0.25;
  }
}
 
// Seasonal discount with custom logic
class SeasonalDiscount implements DiscountStrategy {
  constructor(private baseRate: number, private seasonalBonus: number) {}
 
  calculate(amount: number): number {
    return amount * (this.baseRate + this.seasonalBonus);
  }
}
 
// DiscountCalculator never needs to change
class DiscountCalculator {
  calculate(strategy: DiscountStrategy, amount: number): number {
    return strategy.calculate(amount);
  }
}
 
// Usage
const calculator = new DiscountCalculator();
console.log(calculator.calculate(new RegularDiscount(), 100));    // 5
console.log(calculator.calculate(new VIPDiscount(), 100));        // 20
console.log(calculator.calculate(new EnterpriseDiscount(), 100)); // 25 - No changes to calculator!
# Python - OCP Applied
from abc import ABC, abstractmethod
 
class DiscountStrategy(ABC):
    @abstractmethod
    def calculate(self, amount: float) -> float:
        pass
 
class RegularDiscount(DiscountStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.05
 
class PremiumDiscount(DiscountStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.10
 
class VIPDiscount(DiscountStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.20
 
# Adding new discount - no existing code modified
class EnterpriseDiscount(DiscountStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.25
 
class SeasonalDiscount(DiscountStrategy):
    def __init__(self, base_rate: float, seasonal_bonus: float):
        self._base_rate = base_rate
        self._seasonal_bonus = seasonal_bonus
 
    def calculate(self, amount: float) -> float:
        return amount * (self._base_rate + self._seasonal_bonus)
 
class DiscountCalculator:
    def calculate(self, strategy: DiscountStrategy, amount: float) -> float:
        return strategy.calculate(amount)
 
# Usage
calculator = DiscountCalculator()
print(calculator.calculate(RegularDiscount(), 100))    # 5.0
print(calculator.calculate(VIPDiscount(), 100))        # 20.0
print(calculator.calculate(EnterpriseDiscount(), 100)) # 25.0
// Java - OCP Applied
public interface DiscountStrategy {
    double calculate(double amount);
}
 
public class RegularDiscount implements DiscountStrategy {
    @Override
    public double calculate(double amount) {
        return amount * 0.05;
    }
}
 
public class PremiumDiscount implements DiscountStrategy {
    @Override
    public double calculate(double amount) {
        return amount * 0.10;
    }
}
 
public class VIPDiscount implements DiscountStrategy {
    @Override
    public double calculate(double amount) {
        return amount * 0.20;
    }
}
 
// New discount - zero changes to existing classes
public class EnterpriseDiscount implements DiscountStrategy {
    @Override
    public double calculate(double amount) {
        return amount * 0.25;
    }
}
 
public class DiscountCalculator {
    public double calculate(DiscountStrategy strategy, double amount) {
        return strategy.calculate(amount);
    }
}

OCP in Practice

The key pattern is: use abstraction (interfaces/abstract classes) as extension points. When requirements change, you create a new implementation rather than modifying existing ones.

Common OCP applications:

  • Plugin architectures — new plugins without changing the core
  • Payment processors — new payment methods without modifying checkout
  • Export formats — new export types (PDF, CSV, XML) without changing the export engine
  • Notification channels — new channels (Slack, SMS) without modifying the notification service

L — Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without altering program correctness.

If your code works with a parent class, it should work with any child class without surprises. We touched on this in the Polymorphism and Interfaces post—now let's go deeper.

Violation: The Classic Square-Rectangle Problem

// TypeScript - LSP Violation
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(size: number) {
    super(size, size);
  }
 
  // Overriding to maintain square invariant
  setWidth(width: number): void {
    this.width = width;
    this.height = width; // Side effect!
  }
 
  setHeight(height: number): void {
    this.height = height;
    this.width = height; // Side effect!
  }
}
 
// This function expects Rectangle behavior
function resize(rect: Rectangle): void {
  rect.setWidth(10);
  rect.setHeight(5);
  // For a Rectangle: area should be 50
  // For a Square: area is 25 (height set both dimensions to 5)
  console.log(`Expected: 50, Got: ${rect.getArea()}`);
}
 
resize(new Rectangle(1, 1)); // Expected: 50, Got: 50 ✓
resize(new Square(1));        // Expected: 50, Got: 25 ✗ — LSP violation!

Why is this bad? Code that works correctly with Rectangle breaks when given a Square. The subclass changes the expected behavior of the parent.

Refactored: Redesign the Hierarchy

// TypeScript - LSP Compliant
 
// Shared interface for all shapes
interface Shape {
  getArea(): number;
}
 
// Immutable value objects — no setters to violate
class Rectangle implements Shape {
  constructor(
    readonly width: number,
    readonly height: number
  ) {}
 
  getArea(): number {
    return this.width * this.height;
  }
 
  // Return new instance instead of mutating
  withWidth(width: number): Rectangle {
    return new Rectangle(width, this.height);
  }
 
  withHeight(height: number): Rectangle {
    return new Rectangle(this.width, height);
  }
}
 
class Square implements Shape {
  constructor(readonly size: number) {}
 
  getArea(): number {
    return this.size * this.size;
  }
 
  withSize(size: number): Square {
    return new Square(size);
  }
}
 
// Works with any Shape — no surprises
function printArea(shape: Shape): void {
  console.log(`Area: ${shape.getArea()}`);
}
 
printArea(new Rectangle(10, 5)); // Area: 50
printArea(new Square(7));         // Area: 49
# Python - LSP Compliant
from abc import ABC, abstractmethod
 
class Shape(ABC):
    @abstractmethod
    def get_area(self) -> float:
        pass
 
class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self._width = width
        self._height = height
 
    @property
    def width(self) -> float:
        return self._width
 
    @property
    def height(self) -> float:
        return self._height
 
    def get_area(self) -> float:
        return self._width * self._height
 
    def with_width(self, width: float) -> "Rectangle":
        return Rectangle(width, self._height)
 
    def with_height(self, height: float) -> "Rectangle":
        return Rectangle(self._width, height)
 
class Square(Shape):
    def __init__(self, size: float):
        self._size = size
 
    @property
    def size(self) -> float:
        return self._size
 
    def get_area(self) -> float:
        return self._size * self._size
 
    def with_size(self, size: float) -> "Square":
        return Square(size)
 
# Works with any Shape
def print_area(shape: Shape) -> None:
    print(f"Area: {shape.get_area()}")
 
print_area(Rectangle(10, 5))  # Area: 50
print_area(Square(7))          # Area: 49
// Java - LSP Compliant
public interface Shape {
    double getArea();
}
 
public record Rectangle(double width, double height) implements Shape {
    @Override
    public double getArea() {
        return width * height;
    }
 
    public Rectangle withWidth(double width) {
        return new Rectangle(width, this.height);
    }
 
    public Rectangle withHeight(double height) {
        return new Rectangle(this.width, height);
    }
}
 
public record Square(double size) implements Shape {
    @Override
    public double getArea() {
        return size * size;
    }
 
    public Square withSize(double size) {
        return new Square(size);
    }
}

LSP Rules of Thumb

  1. Don't strengthen preconditions — A subclass shouldn't require more than the parent
  2. Don't weaken postconditions — A subclass should deliver at least what the parent promises
  3. Preserve invariants — A subclass shouldn't break constraints the parent guarantees
  4. No surprises — If code works with the parent, it must work with the child

Common LSP Violations

ViolationExampleFix
Throwing unexpected exceptionsReadOnlyList.add() throws UnsupportedOperationExceptionSeparate ReadableList and WritableList interfaces
Changing return semanticsStack.pop() returns different type than Collection.remove()Don't inherit from unrelated abstractions
Side effects in overridesSquare.setWidth() also changes heightUse immutable objects or remove inheritance
Ignoring parent behaviorEmpty method overrides (eat() does nothing in RobotWorker)Use interface segregation

I — Interface Segregation Principle (ISP)

No client should be forced to depend on methods it doesn't use.

Create small, focused interfaces instead of large, monolithic ones. This is about designing role-based contracts.

Violation: The Fat Interface

// TypeScript - ISP Violation
interface IDevice {
  print(document: string): void;
  scan(): string;
  fax(document: string, number: string): void;
  staple(document: string): void;
  copy(document: string): string;
}
 
// A simple home printer doesn't fax or staple!
class HomePrinter implements IDevice {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }
 
  scan(): string {
    return "Scanned document";
  }
 
  fax(document: string, number: string): void {
    throw new Error("Home printer cannot fax!"); // Forced to implement!
  }
 
  staple(document: string): void {
    throw new Error("Home printer cannot staple!"); // Forced to implement!
  }
 
  copy(document: string): string {
    return `Copy of: ${document}`;
  }
}

Why is this bad? HomePrinter is forced to implement fax() and staple() even though it can't do those things. Any code calling fax() on a HomePrinter gets a runtime error.

Refactored: Role-Based Interfaces

// TypeScript - ISP Applied
 
// Small, focused interfaces
interface IPrinter {
  print(document: string): void;
}
 
interface IScanner {
  scan(): string;
}
 
interface IFax {
  fax(document: string, number: string): void;
}
 
interface ICopier {
  copy(document: string): string;
}
 
interface IStapler {
  staple(document: string): void;
}
 
// Home printer implements only what it can do
class HomePrinter implements IPrinter, IScanner, ICopier {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }
 
  scan(): string {
    return "Scanned document";
  }
 
  copy(document: string): string {
    return `Copy of: ${document}`;
  }
}
 
// Office machine implements everything
class OfficeMachine implements IPrinter, IScanner, IFax, ICopier, IStapler {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }
 
  scan(): string {
    return "High-res scanned document";
  }
 
  fax(document: string, number: string): void {
    console.log(`Faxing to ${number}: ${document}`);
  }
 
  copy(document: string): string {
    return `Office copy of: ${document}`;
  }
 
  staple(document: string): void {
    console.log(`Stapling: ${document}`);
  }
}
 
// Functions accept only what they need
function printDocument(printer: IPrinter, doc: string): void {
  printer.print(doc);
}
 
function scanAndCopy(device: IScanner & ICopier, doc: string): void {
  const scanned = device.scan();
  const copied = device.copy(doc);
  console.log(`Scanned: ${scanned}, Copied: ${copied}`);
}
 
// Both work with printDocument
printDocument(new HomePrinter(), "Report.pdf");
printDocument(new OfficeMachine(), "Report.pdf");
 
// Both work with scanAndCopy
scanAndCopy(new HomePrinter(), "Invoice.pdf");
scanAndCopy(new OfficeMachine(), "Invoice.pdf");
# Python - ISP Applied
from abc import ABC, abstractmethod
 
class IPrinter(ABC):
    @abstractmethod
    def print_document(self, document: str) -> None:
        pass
 
class IScanner(ABC):
    @abstractmethod
    def scan(self) -> str:
        pass
 
class IFax(ABC):
    @abstractmethod
    def fax(self, document: str, number: str) -> None:
        pass
 
class ICopier(ABC):
    @abstractmethod
    def copy(self, document: str) -> str:
        pass
 
# Home printer - only printing, scanning, copying
class HomePrinter(IPrinter, IScanner, ICopier):
    def print_document(self, document: str) -> None:
        print(f"Printing: {document}")
 
    def scan(self) -> str:
        return "Scanned document"
 
    def copy(self, document: str) -> str:
        return f"Copy of: {document}"
 
# Office machine - everything
class OfficeMachine(IPrinter, IScanner, IFax, ICopier):
    def print_document(self, document: str) -> None:
        print(f"Printing: {document}")
 
    def scan(self) -> str:
        return "High-res scanned document"
 
    def fax(self, document: str, number: str) -> None:
        print(f"Faxing to {number}: {document}")
 
    def copy(self, document: str) -> str:
        return f"Office copy of: {document}"
 
# Functions accept only what they need
def print_doc(printer: IPrinter, doc: str) -> None:
    printer.print_document(doc)
 
def scan_and_copy(device: IScanner, doc: str) -> None:
    # In Python, we rely on duck typing or Protocol for intersection types
    scanned = device.scan()
    print(f"Scanned: {scanned}")
 
print_doc(HomePrinter(), "Report.pdf")
print_doc(OfficeMachine(), "Report.pdf")
// Java - ISP Applied
public interface IPrinter {
    void print(String document);
}
 
public interface IScanner {
    String scan();
}
 
public interface IFax {
    void fax(String document, String number);
}
 
public interface ICopier {
    String copy(String document);
}
 
// Home printer - only what it can do
public class HomePrinter implements IPrinter, IScanner, ICopier {
    @Override
    public void print(String document) {
        System.out.println("Printing: " + document);
    }
 
    @Override
    public String scan() {
        return "Scanned document";
    }
 
    @Override
    public String copy(String document) {
        return "Copy of: " + document;
    }
}
 
// Office machine - full capabilities
public class OfficeMachine implements IPrinter, IScanner, IFax, ICopier {
    @Override
    public void print(String document) {
        System.out.println("Printing: " + document);
    }
 
    @Override
    public String scan() {
        return "High-res scanned document";
    }
 
    @Override
    public void fax(String document, String number) {
        System.out.println("Faxing to " + number + ": " + document);
    }
 
    @Override
    public String copy(String document) {
        return "Office copy of: " + document;
    }
}

ISP Benefits

  • No dead code — Classes only implement methods they actually support
  • Compile-time safety — Can't accidentally call fax() on a HomePrinter if the function only accepts IPrinter
  • Easier testing — Mock only the interface you need, not 10 methods
  • Clearer intent — Function signatures communicate exactly what capabilities are required

D — Dependency Inversion Principle (DIP)

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

This is often the most impactful SOLID principle. Instead of a UserService directly creating a PostgresDatabase, it depends on an IDatabase interface. The concrete implementation is injected from the outside.

Violation: Tight Coupling

// TypeScript - DIP Violation
class MySQLDatabase {
  save(data: string): void {
    console.log(`Saving to MySQL: ${data}`);
  }
 
  query(sql: string): string[] {
    console.log(`Querying MySQL: ${sql}`);
    return [];
  }
}
 
class StripePayment {
  charge(amount: number): boolean {
    console.log(`Charging $${amount} via Stripe`);
    return true;
  }
}
 
class SmtpEmailer {
  send(to: string, subject: string, body: string): void {
    console.log(`SMTP sending to ${to}: ${subject}`);
  }
}
 
// High-level module depends directly on low-level modules
class OrderService {
  private db = new MySQLDatabase();        // Hardcoded dependency
  private payment = new StripePayment();   // Hardcoded dependency
  private emailer = new SmtpEmailer();     // Hardcoded dependency
 
  placeOrder(userId: string, amount: number): void {
    this.payment.charge(amount);
    this.db.save(`Order for ${userId}: $${amount}`);
    this.emailer.send(userId, "Order Confirmation", `Your order of $${amount} is confirmed`);
  }
}

Why is this bad? Want to switch from MySQL to PostgreSQL? Rewrite OrderService. Want to test without Stripe? Can't. Want to add SendGrid instead of SMTP? Modify OrderService. Every infrastructure change forces business logic changes.

Refactored: Depend on Abstractions

// TypeScript - DIP Applied
 
// Abstractions (interfaces)
interface IDatabase {
  save(data: string): void;
  query(sql: string): string[];
}
 
interface IPaymentGateway {
  charge(amount: number): boolean;
}
 
interface IEmailService {
  send(to: string, subject: string, body: string): void;
}
 
// Low-level implementations
class MySQLDatabase implements IDatabase {
  save(data: string): void {
    console.log(`Saving to MySQL: ${data}`);
  }
  query(sql: string): string[] {
    console.log(`Querying MySQL: ${sql}`);
    return [];
  }
}
 
class PostgresDatabase implements IDatabase {
  save(data: string): void {
    console.log(`Saving to PostgreSQL: ${data}`);
  }
  query(sql: string): string[] {
    console.log(`Querying PostgreSQL: ${sql}`);
    return [];
  }
}
 
class StripePayment implements IPaymentGateway {
  charge(amount: number): boolean {
    console.log(`Charging $${amount} via Stripe`);
    return true;
  }
}
 
class PayPalPayment implements IPaymentGateway {
  charge(amount: number): boolean {
    console.log(`Charging $${amount} via PayPal`);
    return true;
  }
}
 
class SmtpEmailer implements IEmailService {
  send(to: string, subject: string, body: string): void {
    console.log(`SMTP sending to ${to}: ${subject}`);
  }
}
 
class SendGridEmailer implements IEmailService {
  send(to: string, subject: string, body: string): void {
    console.log(`SendGrid sending to ${to}: ${subject}`);
  }
}
 
// High-level module depends on abstractions
class OrderService {
  constructor(
    private db: IDatabase,
    private payment: IPaymentGateway,
    private emailer: IEmailService
  ) {}
 
  placeOrder(userId: string, amount: number): void {
    this.payment.charge(amount);
    this.db.save(`Order for ${userId}: $${amount}`);
    this.emailer.send(userId, "Order Confirmation", `Your order of $${amount} is confirmed`);
  }
}
 
// Usage — swap implementations without changing OrderService
const orderService = new OrderService(
  new PostgresDatabase(),  // Switch DB? Change here, not in OrderService
  new StripePayment(),     // Switch payment? Change here
  new SendGridEmailer()    // Switch email? Change here
);
 
orderService.placeOrder("user123", 99.99);
 
// For testing — use mock implementations
class MockDatabase implements IDatabase {
  savedData: string[] = [];
  save(data: string): void { this.savedData.push(data); }
  query(sql: string): string[] { return []; }
}
 
class MockPayment implements IPaymentGateway {
  charged = false;
  charge(amount: number): boolean { this.charged = true; return true; }
}
 
class MockEmailer implements IEmailService {
  sentEmails: string[] = [];
  send(to: string, subject: string, body: string): void { this.sentEmails.push(to); }
}
 
// Easy to test!
const testService = new OrderService(
  new MockDatabase(),
  new MockPayment(),
  new MockEmailer()
);
# Python - DIP Applied
from abc import ABC, abstractmethod
 
# Abstractions
class IDatabase(ABC):
    @abstractmethod
    def save(self, data: str) -> None:
        pass
 
    @abstractmethod
    def query(self, sql: str) -> list[str]:
        pass
 
class IPaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount: float) -> bool:
        pass
 
class IEmailService(ABC):
    @abstractmethod
    def send(self, to: str, subject: str, body: str) -> None:
        pass
 
# Concrete implementations
class PostgresDatabase(IDatabase):
    def save(self, data: str) -> None:
        print(f"Saving to PostgreSQL: {data}")
 
    def query(self, sql: str) -> list[str]:
        print(f"Querying PostgreSQL: {sql}")
        return []
 
class StripePayment(IPaymentGateway):
    def charge(self, amount: float) -> bool:
        print(f"Charging ${amount} via Stripe")
        return True
 
class SendGridEmailer(IEmailService):
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"SendGrid sending to {to}: {subject}")
 
# High-level module depends on abstractions
class OrderService:
    def __init__(
        self,
        db: IDatabase,
        payment: IPaymentGateway,
        emailer: IEmailService
    ):
        self._db = db
        self._payment = payment
        self._emailer = emailer
 
    def place_order(self, user_id: str, amount: float) -> None:
        self._payment.charge(amount)
        self._db.save(f"Order for {user_id}: ${amount}")
        self._emailer.send(
            user_id,
            "Order Confirmation",
            f"Your order of ${amount} is confirmed"
        )
 
# Swap implementations freely
service = OrderService(
    db=PostgresDatabase(),
    payment=StripePayment(),
    emailer=SendGridEmailer()
)
service.place_order("user123", 99.99)
// Java - DIP Applied
public interface IDatabase {
    void save(String data);
    List<String> query(String sql);
}
 
public interface IPaymentGateway {
    boolean charge(double amount);
}
 
public interface IEmailService {
    void send(String to, String subject, String body);
}
 
// Implementations
public class PostgresDatabase implements IDatabase {
    @Override
    public void save(String data) {
        System.out.println("Saving to PostgreSQL: " + data);
    }
 
    @Override
    public List<String> query(String sql) {
        System.out.println("Querying PostgreSQL: " + sql);
        return List.of();
    }
}
 
public class StripePayment implements IPaymentGateway {
    @Override
    public boolean charge(double amount) {
        System.out.println("Charging $" + amount + " via Stripe");
        return true;
    }
}
 
// High-level module depends on abstractions
public class OrderService {
    private final IDatabase db;
    private final IPaymentGateway payment;
    private final IEmailService emailer;
 
    public OrderService(IDatabase db, IPaymentGateway payment, IEmailService emailer) {
        this.db = db;
        this.payment = payment;
        this.emailer = emailer;
    }
 
    public void placeOrder(String userId, double amount) {
        payment.charge(amount);
        db.save("Order for " + userId + ": $" + amount);
        emailer.send(userId, "Order Confirmation",
            "Your order of $" + amount + " is confirmed");
    }
}

DIP Enables

  • Testability — Inject mocks/fakes for unit testing
  • Flexibility — Swap implementations (MySQL → PostgreSQL) without touching business logic
  • Modularity — Teams can work on different implementations independently
  • Framework integration — Spring, Nest.js, and FastAPI all use DI containers built on this principle

Real-World Example: E-Commerce Order System

Let's see all five SOLID principles working together in a realistic scenario:

// TypeScript - All SOLID Principles Applied
 
// === Interfaces (ISP - small, focused) ===
 
interface IOrderValidator {
  validate(order: Order): ValidationResult;
}
 
interface IInventoryChecker {
  checkAvailability(productId: string, quantity: number): boolean;
  reserve(productId: string, quantity: number): void;
}
 
interface IPaymentProcessor {
  process(amount: number, method: PaymentMethod): PaymentResult;
}
 
interface IOrderRepository {
  save(order: Order): void;
  findById(id: string): Order | null;
}
 
interface INotificationSender {
  send(userId: string, message: string): void;
}
 
// === Data Types ===
 
interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  total: number;
  status: "pending" | "confirmed" | "shipped" | "delivered";
}
 
interface OrderItem {
  productId: string;
  quantity: number;
  price: number;
}
 
interface PaymentMethod {
  type: string;
}
 
interface PaymentResult {
  success: boolean;
  transactionId: string;
}
 
interface ValidationResult {
  valid: boolean;
  errors: string[];
}
 
// === Validators (OCP - add new validators without changing existing ones) ===
 
class MinimumAmountValidator implements IOrderValidator {
  constructor(private minimumAmount: number) {}
 
  validate(order: Order): ValidationResult {
    if (order.total < this.minimumAmount) {
      return { valid: false, errors: [`Minimum order amount is $${this.minimumAmount}`] };
    }
    return { valid: true, errors: [] };
  }
}
 
class ItemLimitValidator implements IOrderValidator {
  constructor(private maxItems: number) {}
 
  validate(order: Order): ValidationResult {
    if (order.items.length > this.maxItems) {
      return { valid: false, errors: [`Maximum ${this.maxItems} items per order`] };
    }
    return { valid: true, errors: [] };
  }
}
 
// New validator — no existing code modified (OCP)
class FraudCheckValidator implements IOrderValidator {
  validate(order: Order): ValidationResult {
    if (order.total > 10000) {
      return { valid: false, errors: ["Order exceeds fraud threshold — manual review required"] };
    }
    return { valid: true, errors: [] };
  }
}
 
// === Composite Validator (SRP - only validates) ===
 
class CompositeOrderValidator implements IOrderValidator {
  constructor(private validators: IOrderValidator[]) {}
 
  validate(order: Order): ValidationResult {
    const allErrors: string[] = [];
 
    for (const validator of this.validators) {
      const result = validator.validate(order);
      if (!result.valid) {
        allErrors.push(...result.errors);
      }
    }
 
    return {
      valid: allErrors.length === 0,
      errors: allErrors
    };
  }
}
 
// === Order Service (SRP - orchestration only, DIP - depends on abstractions) ===
 
class OrderService {
  constructor(
    private validator: IOrderValidator,
    private inventory: IInventoryChecker,
    private payment: IPaymentProcessor,
    private repository: IOrderRepository,
    private notification: INotificationSender
  ) {}
 
  placeOrder(order: Order, paymentMethod: PaymentMethod): PaymentResult | null {
    // Step 1: Validate
    const validation = this.validator.validate(order);
    if (!validation.valid) {
      console.log(`Validation failed: ${validation.errors.join(", ")}`);
      return null;
    }
 
    // Step 2: Check inventory
    for (const item of order.items) {
      if (!this.inventory.checkAvailability(item.productId, item.quantity)) {
        console.log(`Product ${item.productId} not available`);
        return null;
      }
    }
 
    // Step 3: Process payment
    const paymentResult = this.payment.process(order.total, paymentMethod);
    if (!paymentResult.success) {
      console.log("Payment failed");
      return null;
    }
 
    // Step 4: Reserve inventory
    for (const item of order.items) {
      this.inventory.reserve(item.productId, item.quantity);
    }
 
    // Step 5: Save order
    order.status = "confirmed";
    this.repository.save(order);
 
    // Step 6: Notify
    this.notification.send(order.userId, `Order ${order.id} confirmed!`);
 
    return paymentResult;
  }
}
 
// === Usage ===
 
// Compose with specific implementations (DIP)
const orderService = new OrderService(
  new CompositeOrderValidator([
    new MinimumAmountValidator(10),
    new ItemLimitValidator(50),
    new FraudCheckValidator()
  ]),
  // inventory, payment, repository, notification implementations...
  // would be injected here
  {} as IInventoryChecker,
  {} as IPaymentProcessor,
  {} as IOrderRepository,
  {} as INotificationSender
);

SOLID Principles in This Example

PrincipleWhere Applied
SRPEach class has one job: MinimumAmountValidator validates amounts, OrderService orchestrates, CompositeOrderValidator combines validators
OCPAdd FraudCheckValidator without modifying existing validators or OrderService
LSPAll IOrderValidator implementations are interchangeable — CompositeOrderValidator works with any mix
ISPSeparate interfaces for validation, inventory, payment, storage, notification — each client depends only on what it needs
DIPOrderService depends on interfaces, not on PostgresDB or StripePayment directly

How SOLID Principles Connect

The five principles aren't isolated—they reinforce each other:

  • SRP creates focused classes → makes them easier to extend (OCP)
  • OCP relies on polymorphism → which requires proper substitution (LSP)
  • ISP creates small interfaces → which are the abstractions DIP depends on
  • DIP enables swapping implementations → which works because of LSP

SOLID Quick Reference

PrincipleOne-LinerKey Question
S — Single ResponsibilityOne class, one job"Does this class have more than one reason to change?"
O — Open/ClosedAdd new code, don't change old code"Can I add this feature without modifying existing classes?"
L — Liskov SubstitutionSubclasses shouldn't surprise you"Would replacing this parent with a child break anything?"
I — Interface SegregationSmall interfaces, not fat ones"Are clients forced to implement methods they don't use?"
D — Dependency InversionDepend on abstractions"Does this high-level module know about implementation details?"

When NOT to Apply SOLID

SOLID principles are guidelines, not dogma. Over-applying them can lead to:

  • Over-engineering — Creating interfaces for classes that will never have a second implementation
  • Abstraction explosion — 50 tiny interfaces for a simple CRUD app
  • Analysis paralysis — Spending more time designing than building

Rules of thumb:

  1. Start simple — Add abstractions when you need a second implementation, not before
  2. Follow the pain — If changing one thing breaks another, that's a SOLID violation worth fixing
  3. Consider the scope — A 200-line script doesn't need DIP; a 200,000-line system does
  4. Refactor toward SOLID — It's easier to extract interfaces from working code than to predict the perfect abstraction upfront

Summary and Key Takeaways

The Five Principles

SRP — Each class should have only one reason to change
OCP — Extend behavior through new classes, not by modifying existing ones
LSP — Subtypes must honor parent type contracts
ISP — Many small interfaces are better than one large interface
DIP — Depend on abstractions (interfaces), not concrete implementations

SOLID Enables

  • Testable code — DIP lets you inject mocks
  • Extensible code — OCP lets you add features without breaking things
  • Readable code — SRP gives each class a clear purpose
  • Safe inheritance — LSP prevents subtle bugs
  • Clean contracts — ISP ensures classes only implement what they use

SOLID Is the Foundation for Design Patterns

Every design pattern you'll learn next—Singleton, Factory, Strategy, Observer—builds on SOLID principles. Understanding SOLID makes design patterns intuitive rather than magical.


Practice Exercises

  1. Refactor a Report Generator: You have a ReportGenerator class that generates reports, formats them as HTML/PDF/CSV, saves to disk, and emails the result. Apply SRP to separate concerns, then OCP to make formats extensible.

  2. Design a Payment System: Build a payment processing system with multiple gateways (Stripe, PayPal, Bank Transfer). Apply DIP so the checkout service doesn't know which gateway is used. Apply ISP to separate refundable vs non-refundable payment interfaces.

  3. Fix an Inheritance Hierarchy: You have Bird with a fly() method, and Penguin extends Bird. Apply LSP and ISP to fix this hierarchy so penguins don't "fly" and no code breaks.


What's Next?

With SOLID principles under your belt, you're ready to learn design patterns—proven solutions to recurring design problems. In the next post, we'll start with creational patterns:

  • Singleton Pattern — Ensuring a class has only one instance
  • When to use it and when to avoid it
  • Thread-safe implementations
  • Alternatives to Singleton

Continue your OOP journey: Singleton Pattern


Additional Resources

Previous Posts in This Series

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.