8 min read

SOLID Principles: Five Rules for Code That Survives Change

A practical guide to the SOLID principles of object-oriented design — what each one means, why it matters, and how to apply it with clear before-and-after code examples.

solidsoftware-designobject-oriented-programmingclean-codearchitecturebest-practices
SOLID Principles: Five Rules for Code That Survives Change

SOLID Principles: Five Rules for Code That Survives Change

Most code is easy to write and hard to change. The first version of a class works fine; the trouble starts six months later when a new requirement arrives and touching one method breaks three others. SOLID is a set of five design principles, popularized by Robert C. Martin, that aim at exactly that problem: keeping code flexible, understandable, and safe to modify as it grows.

The acronym stands for:

Letter Principle One-line summary
S Single Responsibility A class should have one reason to change
O Open/Closed Open for extension, closed for modification
L Liskov Substitution Subtypes must be usable wherever their base type is
I Interface Segregation Don't force clients to depend on methods they don't use
D Dependency Inversion Depend on abstractions, not on concrete implementations

Let's look at each one with examples. The code is in Python for readability, but the ideas apply to any language with classes and interfaces.


S — Single Responsibility Principle

A class should have only one reason to change.

"Responsibility" here means a reason to change, not "does one thing." A class that handles both business rules and database persistence has two masters: the domain expert and the DBA. When either changes their mind, the class changes.

Before

class Report:
    def __init__(self, data):
        self.data = data

    def calculate_totals(self):
        return sum(item.amount for item in self.data)

    def format_as_html(self):
        return f"<h1>Total: {self.calculate_totals()}</h1>"

    def save_to_database(self, connection):
        connection.execute("INSERT INTO reports ...", self.data)

This class changes when the calculation logic changes, when the HTML layout changes, and when the database schema changes.

After

class Report:
    def __init__(self, data):
        self.data = data

    def calculate_totals(self):
        return sum(item.amount for item in self.data)


class HtmlReportFormatter:
    def format(self, report: Report) -> str:
        return f"<h1>Total: {report.calculate_totals()}</h1>"


class ReportRepository:
    def __init__(self, connection):
        self.connection = connection

    def save(self, report: Report):
        self.connection.execute("INSERT INTO reports ...", report.data)

Each class now has a single axis of change. A side benefit: each piece is far easier to test in isolation.

Smell to watch for: class names containing "And" or "Manager", or methods that need wildly different imports.


O — Open/Closed Principle

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

You should be able to add new behavior without editing existing, tested code. In practice this usually means replacing if/elif chains on a type with polymorphism.

Before

class DiscountCalculator:
    def calculate(self, customer_type, amount):
        if customer_type == "regular":
            return amount * 0.05
        elif customer_type == "premium":
            return amount * 0.10
        elif customer_type == "vip":
            return amount * 0.20
        return 0

Every new customer tier means reopening this class and adding another branch — and re-testing all the old ones.

After

from abc import ABC, abstractmethod

class DiscountPolicy(ABC):
    @abstractmethod
    def apply(self, amount: float) -> float: ...


class RegularDiscount(DiscountPolicy):
    def apply(self, amount): return amount * 0.05

class PremiumDiscount(DiscountPolicy):
    def apply(self, amount): return amount * 0.10

class VipDiscount(DiscountPolicy):
    def apply(self, amount): return amount * 0.20


class DiscountCalculator:
    def calculate(self, policy: DiscountPolicy, amount: float) -> float:
        return policy.apply(amount)

Adding a "student" tier is now a new class, not an edit to DiscountCalculator.

Caveat: don't abstract prematurely. Two branches in an if are fine. Reach for this pattern when you see the third or fourth variation appearing.


L — Liskov Substitution Principle

If S is a subtype of T, objects of type T may be replaced with objects of type S without breaking the program.

Named after Barbara Liskov, this principle is about behavioral compatibility. A subclass must honor the contract of its parent — not just the method signatures, but the expectations callers have about what those methods do.

Before — the classic violation

class Rectangle:
    def __init__(self, width, height):
        self._width, self._height = width, height

    def set_width(self, w):  self._width = w
    def set_height(self, h): self._height = h
    def area(self):          return self._width * self._height


class Square(Rectangle):
    def set_width(self, w):
        self._width = self._height = w

    def set_height(self, h):
        self._width = self._height = h

Mathematically a square is a rectangle. But in code:

def stretch(rect: Rectangle):
    rect.set_width(5)
    rect.set_height(10)
    assert rect.area() == 50   # fails for Square — area is 100

Square silently changes the meaning of set_width, so any code written against Rectangle can break.

After

Don't force an inheritance relationship that doesn't hold behaviorally. Model them as siblings under a shared abstraction:

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width, self.height = width, height
    def area(self): return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side
    def area(self): return self.side ** 2

Warning signs of an LSP violation: a subclass that throws NotImplementedError, overrides a method to do nothing, or requires callers to check isinstance before using it.


I — Interface Segregation Principle

Clients should not be forced to depend on interfaces they do not use.

Large, "do-everything" interfaces force implementers to write stub methods and force clients to know about capabilities they'll never call. Prefer several small, focused interfaces over one fat one.

Before

class Worker(ABC):
    @abstractmethod
    def work(self): ...
    @abstractmethod
    def eat(self): ...
    @abstractmethod
    def sleep(self): ...


class Robot(Worker):
    def work(self):  print("Assembling parts")
    def eat(self):   raise NotImplementedError  # robots don't eat
    def sleep(self): raise NotImplementedError  # or sleep

Robot is forced to implement methods that make no sense for it — which is also an LSP violation waiting to happen.

After

class Workable(ABC):
    @abstractmethod
    def work(self): ...

class Eatable(ABC):
    @abstractmethod
    def eat(self): ...

class Sleepable(ABC):
    @abstractmethod
    def sleep(self): ...


class Human(Workable, Eatable, Sleepable):
    def work(self):  print("Writing code")
    def eat(self):   print("Lunch break")
    def sleep(self): print("Zzz")

class Robot(Workable):
    def work(self): print("Assembling parts")

Now a scheduler that only cares about Workable doesn't need to know or care whether the worker eats.

Rule of thumb: design interfaces from the client's point of view, not the implementer's.


D — Dependency Inversion Principle

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

Business logic (high-level) shouldn't be welded to infrastructure (low-level) like a specific database, email provider, or file system. Introduce an abstraction that the high-level code owns, and let the low-level code implement it.

Before

class MySqlDatabase:
    def save(self, order): ...


class OrderService:
    def __init__(self):
        self.db = MySqlDatabase()   # hard-wired dependency

    def place_order(self, order):
        # ...validation, pricing...
        self.db.save(order)

OrderService cannot be tested without a running MySQL instance, and switching to Postgres means editing the service.

After

class OrderRepository(ABC):
    @abstractmethod
    def save(self, order): ...


class MySqlOrderRepository(OrderRepository):
    def save(self, order): ...

class InMemoryOrderRepository(OrderRepository):
    def __init__(self): self.orders = []
    def save(self, order): self.orders.append(order)


class OrderService:
    def __init__(self, repository: OrderRepository):
        self.repository = repository   # injected abstraction

    def place_order(self, order):
        # ...validation, pricing...
        self.repository.save(order)

The dependency arrow now points toward the abstraction. Production uses MySqlOrderRepository; tests use InMemoryOrderRepository; OrderService never changes.

Note: Dependency Inversion is the principle; Dependency Injection is one common technique for achieving it.


How the Principles Work Together

The five principles reinforce each other:

  • SRP produces small, focused classes.
  • ISP produces small, focused interfaces for those classes to implement.
  • LSP ensures implementations of those interfaces are genuinely interchangeable.
  • DIP makes high-level code depend on those interfaces instead of concrete classes.
  • OCP is the payoff: because everything depends on stable abstractions, you extend the system by adding new implementations rather than editing old ones.

When Not to Apply SOLID

SOLID is a tool for managing change, and it has a cost: more classes, more indirection, more files to open. In a 200-line script, a throwaway prototype, or code that will never be modified again, that cost buys nothing.

Apply the principles when:

  • You've seen the same code change for two or more unrelated reasons.
  • You're adding the third variation of something.
  • Testing requires standing up real infrastructure.
  • A new team member can't understand a class without reading the whole codebase.

Skip them (for now) when the code is small, stable, and understood by everyone who touches it. You can always refactor toward SOLID later — that's the whole point of the principles.

Summary

Principle Ask yourself
Single Responsibility How many different people or teams would ask me to change this class?
Open/Closed Can I add a new case without editing existing code?
Liskov Substitution Can I swap in any subclass and have callers still work correctly?
Interface Segregation Does every implementer actually need every method here?
Dependency Inversion Is my business logic importing infrastructure?

SOLID won't make your code perfect, but it gives you a shared vocabulary for spotting brittle design and a set of proven moves for fixing it.


Further reading: Robert C. Martin, "Agile Software Development: Principles, Patterns, and Practices" (2002), and Barbara Liskov's 1987 keynote "Data Abstraction and Hierarchy."