A ticket says “also send an SMS when payment succeeds.” You open the class that handles orders, and it is nine hundred lines: validation, tax math, a payment call, an SMTP client, a SQL insert, and an audit log. The change is small. The blast radius is not.
That gap — small change, large blast radius — is the only thing SOLID is trying to close.
SOLID is five design principles that keep the cost of changing code roughly proportional to the size of the change. Nothing more mystical than that. They are not laws, not a checklist for code review, and not a reason to add an interface per class.
This post is the series glossary and index: what each letter means in one sentence, the shared lab domain that Parts 2–7 reuse, and the failure mode where SOLID turns into ceremony. Later posts link back here instead of re-lecturing all five.
Why “cheap to change” is the real goal
Design principles are easy to argue about in the abstract, so anchor them to something you can feel on a Tuesday afternoon:
- How many files do you touch to add one behavior?
- How many unrelated tests break when you do?
- Can you test the interesting logic without a database, a network, or a mail server?
- When a new variant appears (a second payment provider, a third notification channel), do you add code or edit code that already works?
Every SOLID principle is a different angle on those four questions. If a refactor does not improve at least one of them, it is decoration, not design.
Note: The letters came from Robert C. Martin’s writing in the early 2000s, gathered from earlier work by Bertrand Meyer (open-closed) and Barbara Liskov (substitution). The history matters less than the fact that all five predate microservices, DI frameworks, and records — they are about dependencies between units of code, at any scale.
The five principles, one sentence each
Read these once. Each has its own post; this is the definition you can quote in a design discussion.
S — Single Responsibility Principle (SRP)
A class should have one reason to change — one audience whose decisions force you to edit it.
The common misreading is “one method” or “do one thing.” A class that formats money, writes to the database, and emails the customer answers to three different groups: finance, DBAs, and marketing. Any of them can force an edit. That is three reasons to change. Details in Single Responsibility: One Reason to Change, Not One Method.
O — Open-Closed Principle (OCP)
You should be able to add behavior by adding code, not by editing code that already works.
The signal is a switch or if/else chain over a type code that grows every quarter. Every new case is an edit to a tested file. The fix is a seam — usually an interface with one implementation per case, or a sealed hierarchy with exhaustive matching. Details in Open-Closed: Add Behavior Without Rewriting the Core.
L — Liskov Substitution Principle (LSP)
Any subtype must be usable everywhere its supertype is expected, without callers changing how they behave.
This is the principle that breaks quietly. A subclass that throws UnsupportedOperationException, tightens what inputs it accepts, or returns null where the parent never did is a landmine for callers who only see the parent type. Details in Liskov Substitution: Subtypes That Don’t Surprise Callers.
I — Interface Segregation Principle (ISP)
No client should be forced to depend on methods it does not use.
Fat interfaces spread pain: add a method to a twelve-method interface and every implementation must change, including the five that will stub it out. Split by client need, not by “everything a payment thing can do.” Details in Interface Segregation: Stop Forcing Clients to Depend on What They Ignore.
D — Dependency Inversion Principle (DIP)
Policy should depend on abstractions, and the concrete details should be handed in — not created with new inside the policy.
A service that calls new StripeGateway() has welded itself to Stripe, to Stripe’s config, and to Stripe’s network calls in every test. Take the dependency as a constructor parameter and the wiring moves outward, where it belongs. Details in Dependency Inversion: Depend on Abstractions, Not on new.
Terms at a glance
| Letter | Principle | One-liner | Smell it fixes |
|---|---|---|---|
| S | Single Responsibility | One reason to change | God class; unrelated tests break together |
| O | Open-Closed | Extend by adding, not editing | Growing switch over a type code |
| L | Liskov Substitution | Subtypes keep the contract | UnsupportedOperationException; instanceof in callers |
| I | Interface Segregation | Small, client-shaped interfaces | Fat interface; stubs full of no-ops |
| D | Dependency Inversion | Inject details, depend on abstractions | new on infrastructure inside business logic |
Two related terms show up constantly in the later posts:
- Seam — a place where you can change behavior without editing the surrounding code. Interfaces, constructor parameters, and strategy objects are all seams.
- Coupling direction — which side knows about the other. SOLID rarely removes a dependency; it flips which way it points so the stable side does not know about the volatile side.
The lab domain: order, payment, notification
Every post in this series works the same tiny domain so you are never re-learning a scenario. Four names, used consistently from Part 2 onward:
| Type | Role |
|---|---|
Order | The data: id, customer email, line items, total |
PaymentGateway | Charges an order through some provider |
EmailNotifier | Tells the customer what happened |
OrderProcessor | The policy that coordinates the above |
Order is a plain data carrier — a good fit for a record:
public record Order(String id, String customerEmail, List<LineItem> items, BigDecimal total) {}
public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
If records are new to you, Java Records: Immutable Data Without the Boilerplate covers the shape.
The starting point: one god service
Here is OrderProcessor as it usually exists before anyone thinks about design. This is the code the whole series pulls apart:
public class OrderProcessor {
public void process(Order order) throws Exception {
// 1. validation
if (order.items().isEmpty()) {
throw new IllegalArgumentException("empty order");
}
if (order.total().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("bad total");
}
// 2. pricing rules, inline
BigDecimal tax = order.total().multiply(new BigDecimal("0.18"));
BigDecimal payable = order.total().add(tax);
// 3. payment — provider chosen by a string, gateway created here
String provider = System.getProperty("payment.provider", "stripe");
if ("stripe".equals(provider)) {
new StripeClient(System.getenv("STRIPE_KEY")).charge(order.id(), payable);
} else if ("razorpay".equals(provider)) {
new RazorpayClient(System.getenv("RAZORPAY_KEY")).pay(payable, order.id());
} else {
throw new IllegalStateException("unknown provider: " + provider);
}
// 4. persistence — raw SQL in the same method
try (Connection c = DriverManager.getConnection(System.getenv("DB_URL"))) {
PreparedStatement ps = c.prepareStatement(
"insert into orders(id, total, status) values (?, ?, 'PAID')");
ps.setString(1, order.id());
ps.setBigDecimal(2, payable);
ps.executeUpdate();
}
// 5. notification — SMTP wired in place
Session session = Session.getInstance(smtpProps());
MimeMessage msg = new MimeMessage(session);
msg.setRecipients(Message.RecipientType.TO, order.customerEmail());
msg.setSubject("Order " + order.id() + " confirmed");
msg.setText("You paid " + payable);
Transport.send(msg);
// 6. audit
Files.writeString(Path.of("audit.log"), order.id() + " PAID\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
}
Nothing here is exotic. It works. It also fails all four of the questions from earlier:
Add a payment provider -> edit the if/else, redeploy the whole method (OCP)
Add an SMS channel -> edit the same method again (SRP)
Test the tax rule -> needs a DB, an SMTP server, and a live key (DIP)
Change the tax rate -> touch the class that also does payments (SRP)
Read the comment numbers as a map of the series: six responsibilities in one method, each one a different post. Parts 2–6 take one principle at a time against this exact code, and SOLID in Practice: Apply the Five Without the Ceremony does the full refactor end to end.
The one seam to notice now
You do not need the finished design yet, but you should be able to spot the cheapest first cut. Everything the method does with a provider is really one verb:
public interface PaymentGateway {
PaymentResult charge(Order order, BigDecimal amount);
}
And everything it does with SMTP is another:
public interface EmailNotifier {
void orderConfirmed(Order order, BigDecimal amountCharged);
}
OrderProcessor then takes both as constructor parameters instead of building them. That single move is most of DIP, opens the door to OCP, and makes the tax rule unit-testable — but the interesting decisions (how many interfaces, where the transaction lives, what PaymentResult carries, whether a failed charge throws) are exactly what the later posts argue about. Do not extract every seam you can see on day one; extract the one that a real change is pushing on.
When SOLID is cargo-cult
SOLID goes wrong more often from over-application than from ignorance. The tell is indirection with no second implementation and no second reason to change.
Concrete anti-patterns to refuse:
- An interface per class.
OrderServiceImplbehindOrderService, forever alone. That is not a seam; it is two files to open instead of one. Add the interface when a second implementation or a test double actually needs it. - A class per method. Shredding a cohesive 60-line service into eight one-method classes named after verbs makes the flow unreadable. SRP is about reasons to change, not line count.
- Abstracting a variation that never varies. A
TaxStrategyinterface in a company that sells in one country adds a lookup and a config key to describe a constant. - Factories to reach injected objects. If your DI container needs a factory to produce a provider to locate a service, the dependency is not inverted; it is hidden.
- Rewriting working code for a letter. “This violates OCP” is not a defect report. A
switchwith three stable cases that has not changed in two years is fine. - Reviewing by acronym. Comments like “SRP violation” without naming which two audiences force edits are unactionable. Name the change that would be expensive.
The healthy trigger is a real change request. When a second provider, a second channel, or an untestable rule shows up, apply the principle that removes that pain — and stop there. Design that follows actual change pressure ages well; design that follows a checklist becomes the next legacy system.
Note: Framework code plays by different rules. A library published for other teams needs stable abstractions before the second caller exists, because the callers are outside your repository. Application code can wait for evidence; public API cannot.
This series
Each post stands alone for its own title, and all of them reuse the Order / PaymentGateway / EmailNotifier / OrderProcessor domain above.
Read in order for the running refactor, or jump to the letter that matches the pain you have today. Parts 2–7 assume the definitions on this page and link back here rather than repeating them.
Cheat sheet
S Single Responsibility one reason to change -> split by audience, not by line count
O Open-Closed add, don't edit -> seam where variants appear
L Liskov Substitution subtypes keep promises-> no surprise throws, no narrowed inputs
I Interface Segregation small interfaces -> split by client need
D Dependency Inversion inject the details -> no `new` on infrastructure in policy
Trigger to apply: a real change is expensive
Trigger to stop: indirection with one implementation and no pending variant
Scoreboard: files touched per change, tests broken, testability without I/O
Do:
- Apply a principle when a concrete change request is being blocked by the current shape.
- Name the audience or the variant when you claim a violation.
- Keep data types dumb (records) and put coordination in a thin policy class.
- Let the wiring live at the edge —
main, a config class, or your DI container. - Measure the win: fewer files touched, fewer unrelated test failures, no I/O in unit tests.
Don’t:
- Add an interface with exactly one implementation and no test double asking for it.
- Treat the five letters as a code-review checklist.
- Split a class until it is a pile of one-method objects.
- Abstract a variation your product does not have.
- Refactor working code because of an acronym rather than a cost.
Wrap-up
SOLID is five answers to one question: when this code has to change, how much of it do I have to touch? SRP separates reasons to change, OCP puts a seam where variants appear, LSP keeps subtypes honest with callers, ISP keeps interfaces client-shaped, and DIP pushes infrastructure out to the edge so the policy stays testable.
The OrderProcessor above is the whole series in one method — six responsibilities, one file, no seams. From here, take the letters one at a time against that code, and apply each one only where a real change is already pushing back. Start with the responsibilities, because splitting them makes every other principle easier to see.