Be Software Architecture Wise
Be Wise Apps

Leer en español · Lire en français · Auf Deutsch lesen

Section 3.5 of 3.8 · Intermediate · 26 min read

The SOLID principles applied to components

Video lesson

5 min · narrated, with captions
Listen only (audio)
Read the transcript

SOLID is the best-known set of design principles, and one of the most argued about. In this lesson you'll learn what each of the five really means, how each applies to components and services, and when to be careful with them.

Think of a well-run restaurant group. Each kitchen answers to one head chef, so two bosses never give it conflicting orders. New dishes are added to the menu without rebuilding the kitchen. A stand-in chef must cook every dish exactly as the regular chef would, or diners notice. Each supplier is given only the order form it needs, not the whole company handbook. And head office sets the standards, while suppliers adapt to them. SOLID is these five habits, written for software. Robert C. Martin gathered the principles, and Michael Feathers noticed their initials could be rearranged to spell SOLID.

S is the single responsibility principle, the most misunderstood of the five. It doesn't say a module should do only one thing. It says a module should be responsible to one actor: a group of people who want the system to change in the same way. Imagine one restaurant service holding menus, commission rules and search ranking. Restaurant owners, finance and the growth team each change it, for unrelated reasons. Split it along those actors. Note that this isn't a rule to make services small.

O is the open-closed principle, first stated by Bertrand Meyer: open for extension, closed for modification. When a kind of variation is expected, the next variant should be new code, not another branch in old code. An order service that publishes an order placed event is open to extension. The loyalty team can add a consumer that awards points without the order service changing at all. But add extension points only for change that actually arrives. L is the Liskov substitution principle, from Barbara Liskov. Code that works with a type must keep working with any subtype, without knowing which it got. The right method names aren't enough; the behaviour must be compatible. A substitute must not demand more, promise less, or break the invariants. That's exactly the test for a compatible API version: no new required field, no removed response field.

I is interface segregation: no client should be forced to depend on operations it doesn't use. A fat interface couples every client to every operation. Between services, that argues for client-specific APIs and focused event types. A company-wide common library is the same problem: a fix to its PDF code forces new releases of services that never make PDFs. D is dependency inversion. Business policy shouldn't depend on details such as databases. Both should depend on abstractions. And the often missed point is ownership, as this next diagram shows.

Before, the Orders component imports and calls a concrete PostgreSQL repository in the persistence component. So a database change ripples into the business rules. After, the Orders component owns an order repository interface, written in its own terms. The persistence component implements it, so the source code arrow now points towards the policy. If the interface had stayed in the persistence package, nothing would have been inverted.

Martin adds three principles for deciding which classes belong in one component. Reuse release equivalence: the unit of reuse is the unit of release, so a component's classes should make sense to release together. Common closure: gather the classes that change for the same reasons at the same times. It's single responsibility for components. Common reuse: classes used together belong together, because depending on a component means depending on all of it. They pull against each other, and each edge names the price of giving up the opposite corner. Early on, teams favour common closure. As others come to depend on their parts, the balance shifts towards reuse.

Let's apply them. An online shop's package, shop core, is used by five services. It holds pricing, tax, address validation, PDF invoices and a database helper, and had forty releases last quarter. Four different teams ask for its changes. Four actors means four independent reasons to change. The warehouse service uses only address validation, yet every one of those forty releases forced it to upgrade and retest. And the database helper imports the services' own models, so a stable, widely used package depends on volatile code. The arrow points the wrong way. Splitting it means a tax rate change now releases one package, and touches only the services that compute tax. The cost is more packages to version. Notice the principles didn't dictate the answer. They gave a vocabulary to explain why it hurt. Applied dogmatically, SOLID produces an interface for every class and extension points nobody uses. Use it to diagnose real pain.

The full section shows each principle in Python, and sets out the criticism in full. Next, in Section three point six, we meet connascence, a finer measure of coupling. See you there.

What you need to know

Five principles with a history

SOLID is a mnemonic for five design principles that Robert C. Martin gathered and refined in the late 1990s, most fully in his 2000 paper Design Principles and Design Patterns. Michael Feathers noticed that their initials could be rearranged into the word SOLID, and the acronym stuck. The principles were written for object-oriented classes, but Martin's later book Clean Architecture (2017) argues that each one also applies to components, and practitioners routinely apply them to services and APIs as well.

That is why they belong in an architecture course. Each principle is really a statement about where change should land and which way dependencies should point, the two concerns of the previous sections on coupling and cohesion and dependency management. This section takes each principle in turn, shows it in a few lines of Python, and then shows what it means when the "class" is a deployable component or a service owned by another team. It ends with the component-level cohesion principles and with the criticism SOLID attracts, which is substantial and worth knowing.

S: single responsibility means one actor

The single responsibility principle is the most quoted and most misunderstood of the five. It does not say that a module should do only one thing; that is a good rule for functions, but it is a different rule. Martin's own wording evolved from saying that a class should have only one reason to change to saying, in Clean Architecture, that a module should be responsible to one, and only one, actor. An actor is a group of people (users or stakeholders) who want the system to change in the same way. The principle is about people and the direction of future change.

The classic illustration is an Employee class with three methods: calculate_pay, specified by finance; report_hours, specified by operations; and save, specified by the database administrators. If the pay and the hours report share a helper for computing regular hours, a change finance asks for silently alters the report operations relies on. Nobody did anything careless; the code simply mixed the concerns of two actors, so a change requested by one could hurt the other.

from dataclasses import dataclass

@dataclass
class EmployeeRecord:          # shared data, no behaviour
    id: str
    hourly_rate: float
    hours: list[float]

class PayCalculator:           # actor: finance
    def pay(self, e: EmployeeRecord) -> float:
        regular = sum(min(h, 8) for h in e.hours)
        overtime = sum(max(h - 8, 0) for h in e.hours)
        return (regular + 1.5 * overtime) * e.hourly_rate

class HoursReporter:           # actor: operations
    def weekly_report(self, e: EmployeeRecord) -> str:
        return f"{e.id}: {sum(e.hours):.1f} hours"

class EmployeeRepository:      # actor: database administrators
    def save(self, e: EmployeeRecord) -> None: ...
Splitting by actor: each class changes only when its own stakeholders ask, and the shared data stays a plain record.

At component and service scale, the principle becomes a test for boundaries: whose requests change this component? In a food-delivery platform, a single "Restaurant" service that holds menus (changed at the request of restaurant owners), commission rules (changed by the finance team) and search ranking (changed by the growth team) will be modified for three unrelated reasons, by three sets of people, with three release rhythms. Every release risks the other two concerns, and the owning team becomes a queue. Splitting along actors, for example into a menu service, a commission component inside the finance domain and a ranking service, aligns each boundary with one source of change. This is also the idea behind aligning services with business capabilities and, in domain-driven design, with bounded contexts (covered in Chapter 5).

Notice that the principle does not say "make services small". A large service that serves a single actor well is fine; a tiny service that two departments keep changing in conflicting directions is not.

O: open for extension, closed for modification

The open-closed principle was first stated by Bertrand Meyer in Object-Oriented Software Construction (1988): a module should be open for extension (you can add new behaviour) but closed for modification (its existing, tested source does not need editing to do so). Meyer had inheritance in mind; Martin reinterpreted it in terms of interfaces and polymorphism. The practical form is: when a kind of variation is expected, give it an extension point, so that the next variant is new code rather than another branch in old code.

from typing import Protocol

class Carrier(Protocol):
    def quote(self, weight_kg: float) -> int: ...   # price in pence

class RoyalMail:
    def quote(self, weight_kg: float) -> int:
        return 350 + int(weight_kg * 120)

class Courier:
    def quote(self, weight_kg: float) -> int:
        return 600 + int(weight_kg * 80)

CARRIERS: dict[str, Carrier] = {"royal_mail": RoyalMail(), "courier": Courier()}

def cheapest(weight_kg: float) -> tuple[str, int]:
    return min(((name, c.quote(weight_kg)) for name, c in CARRIERS.items()),
               key=lambda pair: pair[1])
Adding a carrier means registering a new class; the quoting code never changes.

At architecture scale the same idea appears as extension points that other teams can use without asking you to change your code. An order service that publishes an OrderPlaced event is open to extension: the loyalty team can add a new consumer that awards points, and the order service is not modified, retested or redeployed. A plug-in architecture (the microkernel style) makes the same promise for features. For APIs, the principle translates into additive evolution: new optional fields, new endpoints and new event types extend a contract without modifying what existing clients rely on.

The catch is prediction. An extension point is only valuable for the kinds of change that actually arrive. A registry of carriers is worth having if the shop adds carriers every few months; a generic "pricing rule engine" built because pricing might one day vary is speculative machinery. Professionals add the extension point the second or third time a variation appears, not before.

L: Liskov substitution and behavioural contracts

The Liskov substitution principle comes from Barbara Liskov's keynote Data Abstraction and Hierarchy at the OOPSLA conference in 1987, and was made precise by Liskov and Jeannette Wing in A Behavioral Notion of Subtyping (ACM Transactions on Programming Languages and Systems, 1994). Informally: if code works with objects of type T, it must keep working when given an object of any subtype S, without knowing which one it got. Having the right method names is not enough; the behaviour must be compatible.

The rules of behavioural subtyping connect directly to design by contract (Section 3.3). A subtype may not strengthen preconditions (demand more of the caller), may not weaken postconditions (promise less in return) and must preserve invariants (the facts that are always true of the type). Liskov and Wing added a history constraint: a subtype must not allow state changes the supertype forbids, which is why a mutable point cannot pose as an immutable one.

class Discount:
    """Postcondition: 0 <= result <= total."""
    def apply(self, total: int) -> int:
        return total

class PercentOff(Discount):
    def __init__(self, pct: int):
        self.pct = pct
    def apply(self, total: int) -> int:
        return total - total * self.pct // 100      # keeps the contract

class FixedVoucher(Discount):
    def __init__(self, pence: int):
        self.pence = pence
    def apply(self, total: int) -> int:
        return total - self.pence                    # can go below zero!

# FixedVoucher(500).apply(300) == -200: the checkout now issues a refund.
# Fix: return max(total - self.pence, 0), honouring the base postcondition.
The base contract says a discount never makes the total negative. A subtype that breaks it fails callers that trusted the contract.

The symptom of an LSP violation is type-checking in callers: if isinstance(d, FixedVoucher): .... Each such check means an abstraction is no longer trustworthy, and the knowledge of its exceptions spreads through the codebase.

At service scale, substitutability is the question behind every compatible API change. If version 2.3 of a payment API is meant to replace 2.2 without clients noticing, it is a behavioural subtype of 2.2 and the same rules apply. It must not strengthen preconditions: no new required request field, no tighter validation of existing fields, no lower rate limit. It must not weaken postconditions: no removed response fields, no slower guaranteed response time, no lost idempotency. It must preserve invariants: an amount is still in minor units, an order identifier still never changes. Adding a new value to an enumeration in a response is a subtle case: it widens what the client may receive, which breaks clients that assumed the old set was complete. The same reasoning applies when one implementation of a service replaces another behind a load balancer, or when you swap one payment provider adapter for another. Consumer-driven contract tests are, in effect, automated Liskov checks, and Hyrum's law (Section 3.3) is the reminder that clients may rely on behaviour you never promised.

I: interface segregation and client-specific APIs

The interface segregation principle says that no client should be forced to depend on operations it does not use. A fat interface couples every client to every operation: when one operation changes, all clients are affected, at least by having to recompile, retest or redeploy, even those that never call it. In compiled languages that coupling is literal (a change to a header or a shared JAR forces rebuilding its dependants); in Python it shows up as test doubles that must implement twenty methods to test code that calls two.

from typing import Protocol

class OrderReader(Protocol):
    def get(self, order_id: str) -> dict: ...
    def list_for_customer(self, customer_id: str) -> list[dict]: ...

class OrderWriter(Protocol):
    def save(self, order: dict) -> None: ...
    def cancel(self, order_id: str) -> None: ...

def monthly_statement(orders: OrderReader, customer_id: str) -> int:
    return sum(o["total"] for o in orders.list_for_customer(customer_id))

# The reporting job cannot cancel an order, and its tests
# need a fake with two read methods, not the whole repository.
Each client depends only on the role it needs; one class can still implement both roles.

At service scale, interface segregation is the argument for client-specific APIs. A single general-purpose API that serves the mobile app, the web shop, the warehouse system and partner integrations tends to grow into a lowest-common-denominator design: every client receives fields it ignores, and every change has to be negotiated with all of them. The backend for frontend pattern (named by Phil Calçado at SoundCloud and described by Sam Newman) gives each kind of client its own thin API layer, owned by the team that builds that client, which composes calls to the underlying services. GraphQL tackles the same problem differently, by letting each client select exactly the fields it needs. For events, it suggests publishing several focused event types (OrderPlaced, OrderShipped) rather than one OrderChanged event with every field, which forces every consumer to parse and filter everything.

The architectural version also applies to shared libraries. A company-wide common library containing logging, money handling, PDF generation and HTTP clients couples every service to every part of it: a security fix to the PDF code forces a new release of services that never generate PDFs. Splitting it into small libraries that clients adopt independently is interface segregation at the level of packages, and anticipates the common reuse principle below.

D: dependency inversion and who owns the interface

The dependency inversion principle has two parts: high-level modules (business policy) should not depend on low-level modules (details such as databases, message brokers and web frameworks); both should depend on abstractions. And abstractions should not depend on details. The word "inversion" refers to the direction of the source-code dependency: in a naive design, policy calls detail and therefore imports it; after inversion, the detail imports an interface that the policy defines, so the source-code arrow points the opposite way to the flow of control.

The crucial and often missed point is ownership. The interface belongs to the high-level component, expressed in its terms (OrderRepository.save(order), not PostgresClient.execute(sql)). If the interface lives in the persistence package, you have added an abstraction but not inverted anything: policy still depends on the persistence package.

# orders/ports.py  (high-level policy owns this)
from typing import Protocol
class PaymentPort(Protocol):
    def charge(self, order_id: str, pence: int) -> str: ...  # returns payment id

# orders/service.py
class Checkout:
    def __init__(self, payments: PaymentPort):
        self.payments = payments
    def pay(self, order_id: str, pence: int) -> str:
        return self.payments.charge(order_id, pence)

# infrastructure/stripe_adapter.py  (detail depends on policy)
from orders.ports import PaymentPort
class StripeAdapter(PaymentPort):
    def charge(self, order_id: str, pence: int) -> str:
        ...  # call the provider's API, map its errors to ours
The orders package defines the port it needs; infrastructure implements it and is wired in at start-up.

Inverting a dependency across a component boundary

Dependency inversion between an Orders component and a Persistence component, before and afterBefore: policy depends on detailOrders componentOrderServiceimports and callsthe concrete classPersistence componentPostgresOrderRepositoryDatabase change ripples into OrdersAfter: detail depends on policyOrders component (owns the interface)OrderService«interface»OrderRepositoryimplementsPersistence componentPostgresOrderRepositoryArrow now points towards policy
Moving the interface into the Orders component reverses the source-code dependency, so persistence can change without touching business policy.

Dependency inversion is not the same as dependency injection, although they travel together. Injection is a technique for supplying a module with its collaborators from outside (the constructor argument payments above); inversion is a decision about which way source-code dependencies point. You can inject a concrete class without inverting anything.

At architecture scale, dependency inversion is the organising rule of hexagonal, onion and clean architectures: the domain sits at the centre and defines ports; adapters for databases, queues and user interfaces sit outside and depend inwards. Between services the same question is asked as "who owns the contract?". If a central billing service defines a BillingEvent schema and every product team must adapt to it, billing is the stable policy and products depend on it, which is often right. If billing instead reads each product's internal tables, the dependency points from stable to volatile, and every product schema change breaks billing. Section 3.4 showed how to quantify this with the stable dependencies principle.

The component cohesion principles: REP, CCP and CRP

SOLID tells you how to shape classes and their dependencies. Martin pairs it with three principles for deciding which classes belong together in one component, where a component is a unit of release and deployment such as a JAR, a Python package or a .NET assembly.

The reuse/release equivalence principle (REP) says the unit of reuse is the unit of release: if others are to reuse a component, it needs version numbers, release notes and a coherent theme, so the classes in it should make sense to release together. The common closure principle (CCP) is the single responsibility principle for components: gather into one component the classes that change for the same reasons at the same times, so that a typical change touches one component and only that component needs releasing. The common reuse principle (CRP) is the interface segregation principle for components: classes that are used together belong together, and classes that are not should not be packaged together, because depending on a component means depending on all of it.

The three pull against each other. REP and CCP are inclusive (they make components bigger); CRP is exclusive (it makes them smaller). Martin draws them as a triangle in which each edge names the price of abandoning the principle at the opposite corner.

The tension triangle

The tension triangle between the reuse/release equivalence, common closure and common reuse principlesREPReuse/release equivalenceCCPCommon closureCRPCommon reuseToo many unneededreleasesToo manycomponents changeHard to reuseEach edge names the costof giving up the principleat the opposite corner
Concentrating on any two cohesion principles incurs the cost written on the edge between them; architects choose a position, and the position moves as the project matures.

Martin's observation is that a project's position in the triangle should move over time. Early on, developability matters most, so teams favour CCP: group by change, accept that components are awkward to reuse. As a system matures and other teams start depending on its parts, the balance shifts towards REP and CRP. The same tension appears between services. Merging two services that always change together (CCP) reduces coordinated releases but makes the result less reusable; splitting a service so consumers depend only on what they use (CRP) increases the number of deployables that a single business change touches.

Worked example

Worked example: reviewing a shared component in an online shop

An online shop has a package shop-core used by five services. It contains pricing rules, tax calculation, address validation, PDF invoice rendering and a database helper. Over the last quarter it had 40 releases. Apply the principles.

Actors (SRP, CCP)Pricing is changed by the commercial team, tax by finance when rates change, PDF layout by marketing, the database helper by the platform team. Four actors means four independent reasons to change, so the package is not commonly closed.
Users (ISP, CRP)The warehouse service uses only address validation, yet every one of the 40 releases forced it to upgrade and retest. Under CRP, roughly 40 releases minus the few that touched addresses were unneeded for it.
Releases (REP)There is no coherent theme to release notes, so consumers cannot judge whether a release matters to them.
Dependencies (DIP, SDP)The database helper imports the services' ORM models, so a stable, widely used package depends on volatile service code: the arrow points the wrong way.
Proposed splitpricing (owned by the commercial domain), tax (finance), address, invoice-pdf, and the database helper moved into each service or into a platform library with no domain imports.
Expected effectEach service depends only on the packages it uses. A tax-rate change releases one package and touches only the services that compute tax.

The principles did not dictate the answer; they gave a vocabulary to explain why the current package hurts and what each option costs.

The split increases the number of packages to version and publish: the triangle's "too many components change" edge. That is acceptable here because the changes are mostly independent.

Criticism and limits

SOLID is widely taught and widely criticised, and both reactions have merit. The most serious criticisms are these.

The principles are vague enough to justify almost anything. Dan North has made this case in conference talks and in his 2022 essay introducing CUPID (composable, Unix philosophy, predictable, idiomatic, domain-based). He argues that "one reason to change" is arbitrary, since nearly any code can be said to change for many reasons, and that applying it eagerly creates artificial seams between code that actually changes together, such as a report's content and its format. He proposes properties to aim for rather than rules to obey. Martin answered this line of criticism in a 2020 post on SOLID's relevance, arguing that the principles remain valid because the underlying problems of managing dependencies have not changed, and that telling people to "just keep it simple" without guidance produces a mess.

Dogmatic application produces over-abstraction. An interface for every class, a factory for every interface and an extension point for every conceivable variation lead to code where following one behaviour means opening ten files. John Ousterhout's distinction between deep and shallow modules (Section 3.3) is a useful counterweight: an abstraction should hide a lot behind a small interface, and many SOLID-inspired classes are shallow. Speculative extension points violate YAGNI (Section 3.8) and carry real cost.

The principles were written for one paradigm. In languages with first-class functions and structural typing, many SOLID patterns collapse into passing a function or relying on duck typing. The underlying ideas (separate by actor, depend on stable contracts, keep interfaces small) still hold; the ceremony often does not.

The balanced professional view is to treat SOLID as diagnostic vocabulary: when a design hurts, the principles help explain why and suggest a direction. They are not a checklist that every class must satisfy, and "violates OCP" is not by itself an argument; "every new carrier has required editing this function and caused two incidents" is.

Decision guide

Decision guide: should I add an abstraction here?

Use this when a principle seems to call for a new interface, extension point or split.

  1. Has this kind of variation actually happened at least twice, or is it certain (for example, a contract with a second provider is signed)?
    YesAn extension point is probably justified; continue.
    NoKeep the concrete code, keep it easy to refactor, and wait for real evidence (YAGNI).
  2. Are the parts you want to separate changed by different actors or on different schedules?
    YesSeparate them (SRP, CCP); the boundary will reduce coordination.
    NoKeep them together; splitting would add seams that every change must cross.
  3. Does a high-level policy currently import a volatile detail such as a database, vendor SDK or framework?
    YesDefine an interface owned by the policy side and implement it in the detail (DIP).
    NoNo inversion is needed; a direct dependency on a stable module is fine.
  4. Will the new interface hide significant complexity behind a few operations?
    YesIt is a deep module; worth having.
    NoIt is a shallow pass-through; reconsider, or merge it with its caller.

SOLID at three scales

PrincipleClass scaleComponent or service scale
Single responsibilityOne actor per classService or component aligned with one business capability and one owning team
Open-closedNew behaviour through new implementations of an interfaceEvents, plug-ins and additive API changes that let others extend without modifying you
Liskov substitutionSubclasses honour the base class contractNew API versions and replacement implementations honour the old contract: no new required inputs, no removed outputs
Interface segregationSmall role interfacesClient-specific APIs (backend for frontend), focused event types, small shared libraries
Dependency inversionDepend on abstractions you ownDomain defines ports; adapters and infrastructure depend inwards; stable owners define contracts

Check your understanding

A class formats an invoice and also calculates its tax. Does that automatically violate the single responsibility principle?

Not automatically. It does if the formatting and the tax rules are changed by different actors (say marketing and finance), because then changes requested by one can break the other; if one group owns both, the class may be fine.

Why does adding a new required field to an API request break substitutability?

It strengthens the precondition: callers that met the old contract now fail, so the new version can no longer stand in for the old one.

What is the difference between dependency inversion and dependency injection?

Inversion is about the direction of source-code dependencies, with the policy side owning the abstraction; injection is a technique for supplying collaborators from outside. You can inject a concrete class without inverting anything.

A team groups classes strictly by what changes together, and other teams complain the components are impossible to reuse. Which edge of the tension triangle are they on?

The CCP-CRP edge, labelled hard to reuse: they have neglected the reuse/release equivalence principle.

Next · 3.6ConnascenceMeilir Page-Jones's connascence names the hidden agreements between parts of a system, ranks them by strength and judges them by locality and degree, from positional parameters to timing between services.Continue →

Sources and further reading

Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.

Ask: “Our shared 'common' library is used by twelve microservices and every release forces them all to upgrade. How should we split it, and which principles should guide the split?”