A class that saves a user to the database, sends them a welcome email, and formats a receipt is one change away from breaking two features it has nothing to do with. Touch the email template and you risk the database write. Touch the receipt layout and you risk the email. SOLID is five rules aimed at that kind of entanglement, each from a different angle, and they share one idea: a class should change for one reason, and the rest of the code should depend on what something promises to do rather than how it does it.

The principles come from Robert C. Martin’s 2000 paper “Design Principles and Design Patterns”. Michael Feathers later put them in the order that spells SOLID. None of the five is about syntax or a specific language. They are about where the seams in your code go, so a change in one place stays in that place.

What SOLID stands for, and why it’s five rules and not one

  • S, Single Responsibility: a class has one reason to change.
  • O, Open/Closed: add behavior by adding code, not by editing code that already works.
  • L, Liskov Substitution: a subclass has to behave like its parent everywhere the parent is used.
  • I, Interface Segregation: don’t make a class implement methods it has no use for.
  • D, Dependency Inversion: depend on an abstraction, not on a specific concrete class.

They overlap on purpose. Dependency Inversion is what makes Open/Closed work in practice: you can only add a new implementation without touching old code if the old code was written against an interface. Interface Segregation is Single Responsibility applied to interfaces instead of classes. Treat the five as views of one goal, not five boxes to tick.

Single Responsibility Principle: one reason to change

Here is a class with two reasons to change hiding in one body:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def save(self, db_connection) -> None:
        db_connection.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (self.name, self.email),
        )

    def send_welcome_email(self, smtp_client) -> None:
        smtp_client.send(
            to=self.email,
            subject="Welcome",
            body=f"Hi {self.name}, thanks for signing up.",
        )

A schema change and an email-template change now both touch User. Testing save() drags in a database connection (or a mock of one), and testing send_welcome_email() drags in an SMTP client, even though neither test cares about the other method. This is also where the tangling described in What Is Spaghetti Code and How to Refactor It starts: a class keeps absorbing unrelated jobs because it was already there.

Split the jobs out:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email


class UserRepository:
    def save(self, user: User, db_connection) -> None:
        db_connection.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (user.name, user.email),
        )


class WelcomeEmailer:
    def send(self, user: User, smtp_client) -> None:
        smtp_client.send(
            to=user.email,
            subject="Welcome",
            body=f"Hi {user.name}, thanks for signing up.",
        )

Each class now changes for its own reason, and each can be tested with a fake that covers one small job. Note that “one reason to change” is not “one method”. UserRepository could grow find_by_email and delete and still have a single responsibility, because every method serves the same job: persisting users.

Open/Closed Principle: add behavior without editing working code

A calculator that branches on a type tag:

1
2
3
4
5
6
7
class AreaCalculator:
    def area(self, shape: dict) -> float:
        if shape["type"] == "circle":
            return 3.14159 * shape["radius"] ** 2
        elif shape["type"] == "rectangle":
            return shape["width"] * shape["height"]
        raise ValueError(f"unknown shape: {shape['type']}")

Adding a triangle means editing area() again. Worse, every other function that branches on shape["type"] has to be found and edited the same way. Each new shape is a change to tested code, with a chance of breaking the circle and rectangle cases while you’re in there.

Define the contract once and add new shapes as new classes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from abc import ABC, abstractmethod

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


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius ** 2


class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


def total_area(shapes: list[Shape]) -> float:
    return sum(shape.area() for shape in shapes)

Adding Triangle is a new class that implements area(). total_area never changes, and neither do Circle and Rectangle. That is all “open for extension, closed for modification” means: new behavior arrives as new code, and the code that already has test coverage stays untouched.

Liskov Substitution Principle: a subclass must keep the parent’s promises

The classic failure is a Square built as a subclass of Rectangle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def set_width(self, width: float) -> None:
        self.width = width

    def set_height(self, height: float) -> None:
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class Square(Rectangle):
    def set_width(self, width: float) -> None:
        self.width = width
        self.height = width

    def set_height(self, height: float) -> None:
        self.width = height
        self.height = height
1
2
3
4
def resize_and_check(rect: Rectangle) -> None:
    rect.set_width(5)
    rect.set_height(4)
    assert rect.area() == 20

resize_and_check works for any Rectangle. Pass it a Square and the assertion fails: setting the height silently changed the width too, so the area is 16, not 20. Square type-checks as a Rectangle, so mypy has nothing to flag, yet it breaks the behavior every caller of Rectangle was written to expect. Keep that distinction. Liskov violations are rarely about method signatures, which tools already catch. They are about a subclass quietly changing what callers are allowed to assume.

Don’t fix this with a special case for squares inside resize_and_check. Fix it by not making Square a subclass of Rectangle at all. A square does not have two independently resizable dimensions, so it cannot honor a Rectangle’s contract. Both can implement the Shape interface from the Open/Closed example, with no inheritance between them.

Interface Segregation Principle: don’t force unused methods on a class

An interface that bundles unrelated capabilities:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document) -> None: ...

    @abstractmethod
    def scan(self, document) -> None: ...

    @abstractmethod
    def fax(self, document) -> None: ...


class OldInkjetPrinter(Printer):
    def print(self, document) -> None:
        ...  # actually works

    def scan(self, document) -> None:
        raise NotImplementedError("this printer cannot scan")

    def fax(self, document) -> None:
        raise NotImplementedError("this printer cannot fax")

Every caller holding a Printer now has to know, outside the type system, which methods are safe on which instance. The interface promises three capabilities and delivers one, plus two landmines.

Split the interface by capability instead of by device:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Printable(ABC):
    @abstractmethod
    def print(self, document) -> None: ...

class Scannable(ABC):
    @abstractmethod
    def scan(self, document) -> None: ...

class Faxable(ABC):
    @abstractmethod
    def fax(self, document) -> None: ...


class OldInkjetPrinter(Printable):
    def print(self, document) -> None: ...


class AllInOnePrinter(Printable, Scannable, Faxable):
    def print(self, document) -> None: ...
    def scan(self, document) -> None: ...
    def fax(self, document) -> None: ...

A function that only needs to print takes a Printable, and there is no .scan() to call by mistake on a device that never had one. The contract comes from what the object implements, not from a comment warning you which methods will raise.

Dependency Inversion Principle: depend on abstractions, not concrete classes

A service built directly on one database library:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class SQLiteUserRepository:
    def save(self, user) -> None:
        ...  # sqlite3-specific code


class UserService:
    def __init__(self):
        self.repository = SQLiteUserRepository()

    def register(self, user) -> None:
        self.repository.save(user)

UserService is welded to SQLite. Moving to Postgres, or handing it a fake repository in a test, means editing UserService itself instead of swapping a dependency.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from abc import ABC, abstractmethod

class UserRepository(ABC):
    @abstractmethod
    def save(self, user) -> None: ...


class SQLiteUserRepository(UserRepository):
    def save(self, user) -> None:
        ...  # sqlite3-specific code


class UserService:
    def __init__(self, repository: UserRepository):
        self.repository = repository

    def register(self, user) -> None:
        self.repository.save(user)

UserService now depends on the UserRepository abstraction. Building a SQLiteUserRepository(), or a FakeUserRepository() for a test, and passing it in is a separate step, and that step is dependency injection. People use the two terms interchangeably, but they are different things: dependency inversion is the design rule (depend on abstractions), and dependency injection is the technique that supplies the concrete object. What Is Dependency Injection (With Examples) covers the injection side, including what a DI container adds once a class needs more than one or two dependencies wired in.

When SOLID becomes over-engineering

Splitting a twenty-line script into five interfaces buys you nothing if there is exactly one implementation and no plan for a second. Interface Segregation and Dependency Inversion both add a layer of indirection. That layer pays off when you actually swap implementations, such as production versus test or SQLite versus Postgres, or when you need to isolate a class behind a test double as described in Unit vs Integration vs End-to-End Tests. If nothing is ever swapped, the abstraction is pure overhead.

More abstractions don’t mean better design. A constructor that takes eight injected interfaces is not more SOLID than one that takes three. It means the class behind it still has too many responsibilities, which sends you back to the first letter.

SOLID is also object-oriented vocabulary. Plenty of good code has no classes at all. The habits underneath carry over anyway: small functions with one job, and dependencies passed in rather than reached for globally. Those matter in a functional style even when none of the five letters applies literally.

How to start applying SOLID to an existing codebase

Don’t retrofit a whole codebase against a checklist. Apply Single Responsibility first, and only where a class already hurts to change: a file that causes merge conflicts every sprint, or a test that needs three unrelated mocks before it can run. Pull a dependency behind an abstraction only when a second implementation or a test fake is a current need, not a hypothetical one. Most Open/Closed and Liskov problems start life as a Single Responsibility problem that grew a subclass, so the other three principles tend to follow once those two habits stick.