← Back to the section

The acronym SOLID stands for five principles of class design formulated by Robert Martin. They are often explained with examples about geometric shapes, and they can feel like theory for theory's sake. In practice the principles are answers to concrete pains: "why is this code so hard to change?", "why is one class edited by several teams at once?", "why does swapping a library break half the system?".

Let's go through each principle from scratch: what the problem is, how it shows up in code, and how to fix it.

SRP — single responsibility

A class should have one reason to change.

Imagine a service that has grown into a "junk drawer" over time:

class OrderService:
    def create_order(self, request: CreateOrderRequest) -> OrderDto: ...      # 120 lines
    def cancel_order(self, order_id: OrderId) -> OrderDto: ...               # 80 lines
    def search_orders(self, filter: OrderFilter) -> Page[OrderDto]: ...      # 60 lines
    def export_orders(self, filter: OrderFilter) -> bytes: ...               # 90 lines
    def recalculate_statistics(self) -> None: ...                            # 70 lines

When the order creation rule changes — we edit this class. When the export format changes — this class again. When the statistics algorithm changes — it again. The class has several unrelated reasons to change, and any edit risks touching the rest.

SRP says: a class should have one reason to change. In practice this means one class solves one task.

class CreateOrderHandler:

    def __init__(self, orders: OrderRepository, pricing: PricingPolicy) -> None:
        self._orders = orders
        self._pricing = pricing

    def handle(self, cmd: CreateOrderCommand) -> OrderDto:
        order = Order.create(cmd.customer_id, cmd.lines, self._pricing)
        self._orders.save(order)
        return OrderDto.from_order(order)

This class changes only when the order creation rule changes. Sending an email to the customer is a different responsibility, and it lives in a separate event handler.

A good sign of an SRP violation is when a class contains the word "and" in its spoken description: "it creates an order and sends an email and recalculates statistics".

OCP — open for extension, closed for modification

System behavior is extended by adding code, not by editing existing code.

A typical picture of a violation is a match or a chain of if that you have to add a branch to every time a new variant appears:

def discount(order: Order) -> Decimal:
    match order.customer.type:
        case CustomerType.VIP:
            return order.total * Decimal("0.10")
        case CustomerType.EMPLOYEE:
            return order.total * Decimal("0.20")
        case CustomerType.REGULAR:
            return Decimal("0")

A new customer type means editing this method. And all the other match statements over the same attribute elsewhere in the code.

The solution is to declare an extension point (in Python — a Protocol) and add new behavior with a new class, without touching the existing one:

class DiscountPolicy(Protocol):
    def supports(self, customer: Customer) -> bool: ...
    def discount(self, order: Order) -> Decimal: ...


class DiscountCalculator:

    def __init__(self, policies: list[DiscountPolicy]) -> None:
        self._policies = policies

    def discount(self, order: Order) -> Decimal:
        for policy in self._policies:
            if policy.supports(order.customer):
                return policy.discount(order)
        return Decimal("0")

A new discount type means a new class implementing DiscountPolicy. The existing code is not touched.

A caveat: OCP does not mean "create interfaces everywhere just in case". If there are knowingly only two variants and no new ones are expected — a match is more honest. The principle applies where extension is genuinely expected.

LSP — Liskov substitution principle

An implementation can be replaced by any other implementation of the same contract — and the calling code won't notice the difference.

A violation usually looks like inheritance for the sake of code reuse, where the subclass breaks the parent's expectations:

class CachedProductRepository(SqlAlchemyProductRepository):

    def __init__(self) -> None:
        super().__init__()
        self._cache: dict[ProductId, Product] = {}

    def find_by_id(self, product_id: ProductId) -> Product | None:
        if product_id not in self._cache:
            product = super().find_by_id(product_id)
            if product is not None:
                self._cache[product_id] = product
        return self._cache.get(product_id)

    def delete(self, product_id: ProductId) -> None:
        raise NotImplementedError("cache does not support deletion")

The class calls itself a repository, but delete throws an exception. Code that worked with the base class breaks with the "specialized" one — that is exactly the LSP violation: the subclass narrowed the contract.

The correct form is not inheritance but composition. The new class implements the same contract and honestly fulfills all of it:

class CachingProductRepository:

    def __init__(self, delegate: ProductRepository, cache: Cache) -> None:
        self._delegate = delegate
        self._cache = cache

    def find_by_id(self, product_id: ProductId) -> Product | None:
        return self._cache.get_or_load(product_id, lambda: self._delegate.find_by_id(product_id))

    def delete(self, product_id: ProductId) -> None:
        self._delegate.delete(product_id)   # deletion is performed
        self._cache.evict(product_id)       # and the cache is invalidated

Now CachingProductRepository can be substituted for any other ProductRepository without surprises.

A rule of thumb: if you see NotImplementedError in an overridden method — LSP is almost certainly violated.

ISP — interface segregation

A client should not depend on methods it does not use.

Interfaces have a tendency to grow: first there was save and find_by_id, then find_for_listing was added, then find_for_export, then archive_older_than:

class OrderStorage(Protocol):
    def save(self, order: Order) -> None: ...
    def find_by_id(self, order_id: OrderId) -> Order | None: ...
    def find_for_listing(self, filter: OrderFilter, page: Pagination) -> Page[OrderListRow]: ...
    def find_for_export(self, date_from: date, date_to: date) -> list[OrderExportRow]: ...
    def archive_older_than(self, cutoff: date) -> None: ...

A command handler uses two methods out of five but depends on all of them. Changing the signature of the export method forces it to be re-checked too. In tests you have to stub out all five methods, even though only two are needed.

The solution is to split by consumers, not by table:

# for commands — only what is needed
class OrderRepository(Protocol):
    def save(self, order: Order) -> None: ...
    def find_by_id(self, order_id: OrderId) -> Order | None: ...


# for reads and reports — separately
class OrderViewRepository(Protocol):
    def find_for_listing(self, filter: OrderFilter, page: Pagination) -> Page[OrderListRow]: ...
    def find_for_export(self, date_from: date, date_to: date) -> list[OrderExportRow]: ...

A single implementation can satisfy both protocols — that's fine. What matters is that each consumer depends only on the methods it actually uses.

DIP — dependency inversion

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

In plain words: business logic should not directly know about concrete tools (a database, a mail server, an external API). Otherwise, when you swap the tool, you have to touch the business logic.

A typical violation is a domain model that knows about a concrete notification transport:

class Order:
    def cancel(self, mail_sender: SmtpMailSender) -> None:
        self.status = Status.CANCELLED
        mail_sender.send(self.customer.email, "Order cancelled")

The domain model depends on SmtpMailSender — a concrete infrastructure detail. Want to switch to push notifications — you'll have to change Order. Want to test cancellation without a real SMTP server — it's hard.

Inversion: the domain declares what it needs (a Protocol), and the infrastructure decides how to implement it:

# the protocol is declared next to the domain and speaks the domain's language
class NotificationPort(Protocol):
    def order_cancelled(self, order: Order) -> None: ...


# the implementation lives in the infrastructure layer
class SmtpNotificationAdapter:

    def __init__(self, mail_client: SmtpClient) -> None:
        self._mail_client = mail_client

    def order_cancelled(self, order: Order) -> None:
        self._mail_client.send(build_message(order))

Order no longer knows anything about SMTP. In a test NotificationPort is easily replaced by a stub. Changing the transport means a new adapter, and the domain is not touched.

Note: the dependency goes from SmtpNotificationAdapter to NotificationPort (which lives next to the domain), not the other way around. The direction of the dependency is inverted relative to the direction of the call — hence the name "inversion".

In short

  • SRP: a class has one reason to change. If a class does "both this and that" — it's time to split it.
  • OCP: new behavior is added with a new class, not by editing the existing one. A Protocol as the extension point.
  • LSP: a subclass or implementation replaces the original without surprises. NotImplementedError in an overridden method is a red flag.
  • ISP: an interface contains only what a particular consumer needs. A big interface is cut into several narrow ones.
  • DIP: business logic depends on interfaces, not on concrete classes. The direction of dependencies is toward the domain, not away from it.

The principles work together: a class with a single responsibility (SRP) depends on a narrow interface (ISP) declared in the domain (DIP), whose implementations are interchangeable (LSP), and new behavior variants are added with new classes (OCP).

  • GoF patterns — concrete techniques that implement the ideas of SOLID in practice.
  • GRASP by example — principles of distributing responsibility between classes.
  • Hexagonal architecture — DIP and ISP taken all the way to the module structure.