SOLID in Practice — A Decision Aid, Not a Religion¶
SOLID is a set of five heuristics that Robert C. Martin popularized in the early 2000s. When you use them as questions to ask about your design, they sharpen your thinking. When you use them as rules to obey, you produce over-engineered code that nobody wants to maintain. This file walks each principle with real Java examples, then shows the trap that most engineers fall into.
The Five, at a Glance¶
Letter |
Name |
One-line reading |
Modern Java lever |
|---|---|---|---|
S |
Single Responsibility |
A class has one reason to change |
Records, small services |
O |
Open/Closed |
Open for extension, closed for modification |
Strategy + sealed types |
L |
Liskov Substitution |
Subtypes must honor the contract of their supertype |
Sealed hierarchies, records |
I |
Interface Segregation |
Many small interfaces beat one fat one |
Functional interfaces |
D |
Dependency Inversion |
Depend on abstractions, not concretions |
Spring DI, constructor injection |
Read them as forces to balance, not commandments to obey.
S — Single Responsibility (and the “One Reason to Change” Trap)¶
The canonical statement: “A class should have only one reason to change.” Sounds noble. In practice, engineers hear “one thing” and shatter a coherent class into seven anemic classes that all change together anyway.
The trap¶
// "Refactored" for SRP — but every one of these classes changes
// together every time a business rule changes.
class OrderValidator { boolean validate(Order o) { ... } }
class OrderPriceCalculator { BigDecimal price(Order o) { ... } }
class OrderTaxCalculator { BigDecimal tax(Order o) { ... } }
class OrderDiscountCalculator { BigDecimal discount(Order o) { ... } }
class OrderTotalCalculator { BigDecimal total(Order o) { ... } }
You have not achieved separation. You have achieved fragmentation. Read every method on the same day of the same sprint and you know this was one class pretending to be five.
The honest version¶
final class OrderPricer {
BigDecimal price(Order order) {
var subtotal = subtotal(order);
var discount = discount(order, subtotal);
var tax = tax(order, subtotal.subtract(discount));
return subtotal.subtract(discount).add(tax);
}
// private helpers stay here, cohesive with the public method
}
One reason to change here is “how we price orders.” When the tax rule changes, this class changes. When the discount rule changes, this class changes. That is one reason. The “reason” is a stakeholder, not a method name.
⚠️ What most people get wrong. They read “one responsibility” as “one method” or “one noun.” The correct reading is “one actor (person or subsystem) that has authority to demand a change.” If Finance owns tax rules and Marketing owns discount rules, then splitting by those actors is real SRP. Splitting by verbs is not.
O — Open/Closed (Strategy Made Modern)¶
Open for extension, closed for modification. You should be able to add new behavior without editing existing code. In modern Java this almost always means an interface plus implementations — often lambdas.
The switch-statement smell¶
BigDecimal shippingCost(Order order, String carrier) {
return switch (carrier) {
case "FEDEX" -> order.weight().multiply(new BigDecimal("2.50"));
case "UPS" -> order.weight().multiply(new BigDecimal("2.30"));
case "USPS" -> new BigDecimal("5.00");
default -> throw new IllegalArgumentException(carrier);
};
}
Every new carrier means editing this method. Every edit risks breaking existing carriers. This is exactly what OCP warns against.
The open version¶
interface ShippingCalculator {
BigDecimal cost(Order order);
}
final class FedExCalculator implements ShippingCalculator { ... }
final class UpsCalculator implements ShippingCalculator { ... }
final class UspsCalculator implements ShippingCalculator { ... }
// Wiring lives in one place (Spring, factory, or a Map)
Map<String, ShippingCalculator> registry = Map.of(
"FEDEX", new FedExCalculator(),
"UPS", new UpsCalculator(),
"USPS", new UspsCalculator()
);
Adding DHL is a new class plus one registry entry. Zero edits to existing carriers. This is OCP working.
The nuance¶
If your hierarchy is closed (you truly know every case), a sealed interface plus pattern-matching switch is actually better than open extension — it forces exhaustive handling at the compiler level. OCP does not always win. Choose based on whether the set of variants is open (add new carriers forever) or closed (there are exactly three payment states).
L — Liskov Substitution (The Rectangle/Square Trap)¶
Objects of a subtype must be substitutable for objects of their supertype without breaking the program. The famous violation:
class Rectangle {
protected int width, height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
class Square extends Rectangle {
@Override public void setWidth(int w) { super.setWidth(w); super.setHeight(w); }
@Override public void setHeight(int h) { super.setWidth(h); super.setHeight(h); }
}
Now this test breaks:
void test(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // FAILS if r is actually a Square (returns 16)
}
Square is-a Rectangle mathematically, but Square is-not-a mutable Rectangle behaviorally. The lesson is not “don’t use inheritance” — it is inheritance is about behavioral substitutability, not taxonomy.
Modern fix¶
Model both as immutable records — the problem evaporates because there are no setters:
sealed interface Shape permits Rectangle, Square, Circle {
double area();
}
record Rectangle(double width, double height) implements Shape {
public double area() { return width * height; }
}
record Square(double side) implements Shape {
public double area() { return side * side; }
}
record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
}
⚠️ What most people get wrong. They think LSP is about
extendskeyword usage. LSP is about contracts. Even without inheritance, ifList.add()on your custom List throws for valid inputs, you have violated Liskov againstCollection. This is whyCollections.unmodifiableList().add()throws — it deliberately breaks LSP, and the Javadoc warns you.
I — Interface Segregation (Fat Interfaces in Legacy Java)¶
Clients should not be forced to depend on methods they do not use. You see this violated most in legacy service classes:
// A "God" service that every consumer depends on entirely
interface UserService {
User findById(long id);
User findByEmail(String email);
void create(User u);
void update(User u);
void delete(long id);
void sendPasswordResetEmail(long id);
void exportToCsv(OutputStream out);
void auditLog(long id, String action);
}
A read-only report generator only needs findById. But to mock this interface in a test it must stub eight methods. Compile-time coupling to unused methods is real coupling.
The segregated version¶
interface UserReader { User findById(long id); User findByEmail(String email); }
interface UserWriter { void create(User u); void update(User u); void delete(long id); }
interface UserEmailer { void sendPasswordResetEmail(long id); }
interface UserExporter { void exportToCsv(OutputStream out); }
// One implementation can still implement all of them
class DefaultUserService implements UserReader, UserWriter, UserEmailer, UserExporter { ... }
Consumers depend on the smallest interface they need. The concrete class is unchanged. Tests are easier.
Functional interfaces (Function, Predicate, Consumer) are ISP taken to its natural extreme — one method per interface.
D — Dependency Inversion (What Spring Does For You)¶
High-level modules should not depend on low-level modules. Both should depend on abstractions. This is the principle that Spring/Guice/CDI made almost automatic.
The concrete version (fragile)¶
class OrderService {
private final PostgresOrderRepository repo = new PostgresOrderRepository();
private final SmtpEmailer emailer = new SmtpEmailer();
// Testing this requires a real database and SMTP server
}
OrderService (high-level policy) now depends on PostgresOrderRepository (low-level detail). Switch databases → rewrite OrderService. Test in isolation → impossible.
The inverted version¶
class OrderService {
private final OrderRepository repo; // interface
private final Emailer emailer; // interface
OrderService(OrderRepository repo, Emailer emailer) { // constructor injection
this.repo = repo;
this.emailer = emailer;
}
}
OrderService depends on abstractions. Spring wires the concrete PostgresOrderRepository in production and you inject a FakeOrderRepository in tests. Both OrderService (high) and PostgresOrderRepository (low) now depend on OrderRepository (abstraction). That is the inversion.
⚠️ What most people get wrong. They confuse DIP with DI (dependency injection). DIP is a design principle — depend on abstractions. DI is a technique to achieve it (constructor injection, setter injection, Spring). You can practice DIP without any framework. You can use Spring and still violate DIP (e.g.,
@Autowireda concrete class instead of an interface).
The Bigger Picture — SOLID as Diagnostic¶
Do not open a new file and ask “Am I obeying SOLID?” Open a file and ask:
When this changes, what else changes? (SRP diagnostic — should be one clear answer)
To add a new variant, what do I have to touch? (OCP diagnostic — ideally one new class)
Can I hand a subclass to code expecting the parent and nothing surprises? (LSP diagnostic)
Do my consumers see methods they never call? (ISP diagnostic)
Does my policy code know about database libraries? (DIP diagnostic)
If the answers are ugly, reach for SOLID as a lens. If the answers are clean, do not “SOLID-ify” for its own sake.
What to Practice This Week¶
Take one class in your existing codebase over 300 lines. List the actors who could ask for a change. If more than two, split — but split by actor, not by verb.
Find a
switchon aStringorenumtype. Try replacing with a strategy interface. Ask honestly: is the extension actually likely, or isswitchfine?Find an interface with more than 5 methods. Sketch three consumer classes. Do they all use all methods? If not, segregate.
Read Bloch’s Effective Java Item 20 (“Prefer interfaces to abstract classes”) and Item 64 (“Refer to objects by their interfaces”). Both are DIP in practical form.
Return to README.md · Previous: 01_oop_first_principles.md · Next: 03_gof_patterns_alive_and_dead.md