SOLID as decisions

Each principle as the problem it prevents and the cost it adds, with the refactor that shows it.

8 min read📐 Low-Level Design

SOLID is usually taught as five rules, and rules get applied. That is how a codebase ends up with an interface per class and a factory per interface, each defended with an acronym. Every principle here is a decision: it prevents one specific kind of pain, it costs something concrete, and the way to use it is to recognise the pain and check that the cost is worth paying. The last lesson in this course makes that argument for patterns; this one makes it for the principles they came from.

Each section shows the problem, the refactor, and the bill.

Single responsibility: one reason to change

The rule is not "a class does one thing". It is "a class has one reason to change" — one group of people, or one kind of requirement, whose decisions land in this file. The tell is a class that gets edited for unrelated reasons:

OrderService.java, as foundjava
class OrderService {
    Order place(Cart cart) { ... }               // pricing rules — product
    void sendConfirmation(Order o) { ... }       // email template — marketing
    String toCsvRow(Order o) { ... }             // export format — finance
    void saveAudit(Order o) { ... }              // retention policy — compliance
}

Four teams ask for changes to one file. Every change risks the other three; every merge conflicts; every test sets up email and CSV to test pricing. The refactor is to split by who asks: OrderPlacement, OrderNotifier, OrderExporter, OrderAudit. Nothing about the domain changed; the boundaries now follow the reasons the code moves.

The cost: four files where there was one, and a reader who wants "what happens when an order is placed" now follows references instead of scrolling. That is worth paying when the reasons genuinely differ. It is not worth paying to split a 60-line class whose every method changes together — that is one responsibility already, and splitting it is the "wrong answer" lesson in miniature.

Open/closed: add behaviour by adding code

A module should be open for extension and closed for modification: new behaviour arrives as new code, not as an edit to code that works. The shape it prevents is the growing conditional:

java
double discount(Order o) {
    if (o.customer().isStudent()) return 0.10;
    else if (o.total() > 5_000) return 0.05;
    else if (o.coupon() != null) return o.coupon().rate();   // added last sprint
    else if (o.isFirstOrder()) return 0.15;                  // added this sprint, broke the coupon case
    return 0;
}

Every new discount edits this method, re-tests every branch, and re-argues the order of the branches. The refactor is strategy, from the patterns lesson: a DiscountRule interface, one class per rule, and a list Spring injects. A new discount is a new class; discount() never changes again.

The cost: the behaviour is now spread over several classes and a list whose order matters, and finding "which rule fired" is a debugger question rather than a line number. Pay it when the third case arrives. With two cases, the if is the honest design, and it is closed to modification only in the sense that nobody wants to modify it.

Liskov substitution: a subtype keeps the contract

Anywhere a Base is expected, any subtype must work without the caller knowing. The textbook example is a Square extends Rectangle whose setWidth also changes the height; the backend version is more common and less obvious:

java
class ReadOnlyOrderRepository extends JpaOrderRepository {
    @Override
    public Order save(Order o) { throw new UnsupportedOperationException(); }
}

It compiles, it is a JpaOrderRepository, and every service that takes one and calls save now fails at run time, in production, in the one code path the tests did not cover. The JDK does this too — Collections.unmodifiableList returns a List whose add throws — and it is a documented, deliberate violation that has caught every Java developer at least once. That is how expensive the principle is to break: the type system promised something the object does not deliver.

The contract has three parts, and an override may not tighten the first or loosen the other two: it must not require more (a stronger precondition — "only non-empty lists"), must not deliver less (a weaker postcondition — "may return null now"), and must not throw new things the base did not. When you find yourself wanting to, the relationship is not inheritance; it is a separate interface (ReadOnlyOrders with findById and no save), which is the next principle.

The cost: sometimes a class that looks like a subtype has to become a sibling, with some duplication, because the contract cannot be kept. That duplication is cheaper than a save that throws.

Interface segregation: no client depends on methods it does not use

A "fat" interface forces every implementation to implement everything and every client to depend on everything:

java
interface OrderRepository {
    Order findById(long id);  List<Order> findByCustomer(long c);  Order save(Order o);
    void delete(long id);     void archiveOlderThan(Instant t);   Stream<Order> exportAll();
}

The read-only reporting service takes an OrderRepository and can, by type, delete every order. The in-memory test double implements six methods to test one. The refactor is to split by client: OrderReader for the services that read, OrderWriter for the one that writes, OrderArchive for the batch job. One class can still implement all three; each caller depends on the slice it uses.

Spring Data is this principle as a library: Repository (nothing) → CrudRepository → PagingAndSortingRepository → JpaRepository, and the documentation tells you to extend the smallest one you need, exactly so that a repository exposed to a controller does not carry deleteAll().

The cost: more interfaces, and a class that implements three of them. Pay it when clients genuinely differ. A single service with a single caller does not need its interface split; it may not need an interface at all.

Dependency inversion: the arrow points at the abstraction

High-level policy should not depend on low-level detail; both depend on an abstraction, and the abstraction is owned by the policy. The IoC lesson showed the mechanism — constructor injection of an interface. This principle is about the direction of the import:

plaintext
before:   OrderService ──imports──▶ StripeClient        (policy depends on a vendor)
after:    OrderService ──imports──▶ PaymentGateway ◀──implements── StripeAdapter

PaymentGateway lives in the order package, next to the code that needs it, and is written in the order's vocabulary (charge(Money), not createPaymentIntent(Map)). Stripe is a detail that implements it from the outside. Swap the vendor, and the order package does not change; test the order package, and no network is involved. Scaled up, this is hexagonal architecture — ports in the domain, adapters at the edge — which the microservices course draws in full.

The cost: an interface with, today, one implementation, which the wrong-answer lesson says to be suspicious of. The resolution is where: invert at the boundaries you will replace or must test without — payment, email, the clock, the database — and not between two classes in the same package that have never had a second implementation and never will.

Clean, onion, hexagonal: one rule with three names

Three named architectures grew out of the dependency-inversion principle, and the argument about which is right misses that they share their one rule: dependencies point inward, from the frameworks and the database and the HTTP layer towards the domain, and never the other way. Hexagonal (Cockburn) draws the domain in the middle with ports (interfaces it owns) and adapters (implementations at the edge). Onion (Palermo) draws the same thing as rings — domain model, domain services, application services, infrastructure — with the rule that a ring may depend only on rings inside it. Clean Architecture (Martin) names the rings entities, use cases, interface adapters and frameworks, and calls the rule the Dependency Rule.

The Java shape they all reduce to: a package for the domain that imports nothing from Spring, JPA or Jackson; a package of application services that orchestrate it and define the ports; and adapter packages — web, persistence, messaging — that implement the ports and import the frameworks. The build lesson's multi-module boundary is what makes the rule enforced rather than aspirational, and this lesson's warning stands: invert at the boundaries you will test around or replace, and do not build four rings for a service whose domain is a table.

SOLID as a bill

PrinciplePreventsCostsPay when
Single responsibilityone file edited for four reasonsmore files, more referencesthe reasons to change genuinely differ
Open/closedthe growing conditionalbehaviour spread across classesthe third case arrives
Liskov substitutiona subtype that throws where the base workeda sibling type, some duplicationalways — breaking it is a run-time failure
Interface segregationclients that can do what they should notmore interfacescallers need different slices
Dependency inversionpolicy importing vendorsinterfaces with one implementationat a boundary you will replace or test around

Read the last column as the design decision each principle is. An interviewer who asks "what is SOLID" wants the five names; one who asks "where would you not apply it" wants this table.

Progress is saved on this device and to your account when signed in.