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 captionsListen 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
- SOLID names five principles collected by Robert C. Martin: single responsibility, open-closed, Liskov substitution, interface segregation and dependency inversion; each applies to classes, components and services.
- Single responsibility means one actor per module: code changed at the request of different groups of people should be separated, and it does not mean that a module may do only one thing.
- Liskov substitution requires behavioural compatibility: no stronger preconditions, no weaker postconditions and preserved invariants, which is exactly the test for a backward-compatible API version.
- Interface segregation argues for small role interfaces in code and client-specific APIs, focused events and small libraries between services.
- Dependency inversion makes details depend on abstractions owned by the policy side; who owns the interface matters more than the fact that one exists.
- REP, CCP and CRP decide what goes into a component and pull against each other; the right balance shifts from CCP towards REP and CRP as a system matures.
- Applied dogmatically, SOLID produces shallow, speculative abstractions; it works best as vocabulary for diagnosing designs that are causing real pain.
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: ...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])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 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.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 oursInverting a dependency across a component boundary
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
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.
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.
- 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).
- 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.
- 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.
- 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
| Principle | Class scale | Component or service scale |
|---|---|---|
| Single responsibility | One actor per class | Service or component aligned with one business capability and one owning team |
| Open-closed | New behaviour through new implementations of an interface | Events, plug-ins and additive API changes that let others extend without modifying you |
| Liskov substitution | Subclasses honour the base class contract | New API versions and replacement implementations honour the old contract: no new required inputs, no removed outputs |
| Interface segregation | Small role interfaces | Client-specific APIs (backend for frontend), focused event types, small shared libraries |
| Dependency inversion | Depend on abstractions you own | Domain 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.
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?”