A Practical, Code‑Dense Tour of the SOLID Principles in Java
By someone who has spent too many Friday nights undoing “harmless” feature requests.
Table of Contents
- Introduction: Why SOLID Still Matters
- S — Single‑Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
- SOLID Sanity Checklist
- Final Thoughts: Future‑You Deserves Better
Introduction: Why SOLID Still Matters
“But we’re microservices now, SOLID is for 2000‑era JavaBeans!”
Cute. The truth is: each microservice can still rot into a tiny monolith—hard to test, risky to change, impossible to extend. SOLID is your mold‑resistant paint.
Below is a full‑fat walkthrough. Every code snippet is production‑ish and comes with its matching anti‑pattern so you can recognise the smell before your code review does.
S — Single‑Responsibility Principle
“A class should have one, and only one, reason to change.”
🚫 How We Break It
class InvoiceService { // three jobs jammed together
Money calculateTotal(List<Item> items) { /* pricing rules */ }
void printPdf(Invoice inv) { /* rendering */ }
void email(Invoice inv, String to){ /* SMTP stuff */ }
}
✅ How We Fix It
class InvoiceCalculator { /* math only */ }
class InvoicePdfRenderer { /* produce PDF bytes */ }
class InvoiceMailer { /* fire off the email */ }
Now pricing tweaks don’t recompile the PDF engine, and SMTP outages stop spamming the accounting team’s pull requests.
O — Open/Closed Principle
“Open for extension, closed for modification.”
If adding a feature means editing old code, you’re doing it wrong.
3.1 Strategy vs. Giant switch
🚫 One‑Way Ticket to switch
Hell
double discount(Cart cart) {
switch (cart.type()) {
case REGULAR: return 0;
case MEMBER: return cart.total() * 0.05;
case VIP: return cart.total() * 0.10;
default: throw new IllegalStateException();
}
}
✅ Strategy Pattern
interface DiscountPolicy { double apply(Cart cart); }
class NoDiscount implements DiscountPolicy { public double apply(Cart c){return 0;} }
class MemberDiscount implements DiscountPolicy { public double apply(Cart c){return c.total()*0.05;} }
class VipDiscount implements DiscountPolicy { public double apply(Cart c){return c.total()*0.10;} }
A new policy (BlackFridayDiscount
) is a new class. Core code sleeps peacefully.
3.2 Plug‑in Files with ServiceLoader
Scenario: Exporting data to “whatever format product dreams up next”.
public interface Exporter {
String format(); // "csv", "json", "parquet", ...
void write(Data d, Path p);
}
Loader
ServiceLoader<Exporter> loader = ServiceLoader.load(Exporter.class);
Map<String, Exporter> exporters =
StreamSupport.stream(loader.spliterator(), false)
.collect(Collectors.toMap(Exporter::format, e -> e));
exporters.get(requestedFormat).write(data, path);
Publish a JAR containing ParquetExporter
, list it in
META-INF/services/com.acme.Exporter
, bounce the JVM—zero edits.
3.3 Taxes Without Tears (Chain of Responsibility)
interface TaxRule {
boolean applies(Bill b);
double apply(Bill b);
}
class IndiaVat implements TaxRule { ... }
class LuxurySurcharge implements TaxRule { ... }
class TaxCalculator {
private final List<TaxRule> rules;
TaxCalculator(List<TaxRule> rules){ this.rules = rules; }
double compute(Bill b){
return rules.stream()
.filter(r -> r.applies(b))
.mapToDouble(r -> r.apply(b))
.sum();
}
}
Next fiscal loophole = one class in the list. Finance sleeps, you sleep.
3.4 Decorators for Logging, Encryption & Friends
interface DataStore { void save(byte[] bytes); }
class DiskStore implements DataStore { ... }
class EncryptingStore implements DataStore {
private final DataStore inner;
EncryptingStore(DataStore i){ this.inner = i; }
public void save(byte[] b){ inner.save(Aes.encrypt(b)); }
}
class CompressingStore implements DataStore { ... }
// Compose as needed:
DataStore store = new CompressingStore(
new EncryptingStore(new DiskStore()));
Add caching, metrics, throttling—keep stacking wrappers.
3.5 Feature Flags with Functional Interfaces
@FunctionalInterface
interface Greeting { String msg(String name); }
@Component
class Greeter {
private final Greeting g;
Greeter(Greeting g){ this.g = g; }
String greet(String n){ return g.msg(n); }
}
// default bean
@Bean Greeting defaultGreeting() {
return n -> "Hello " + n;
}
// Christmas bean (activated via profile/flag)
@Bean @Profile("xmas") Greeting xmasGreeting() {
return n -> "🎄 Merry Christmas, " + n + "!";
}
Flip the profile → new behaviour. No code edits.
L — Liskov Substitution Principle
“Subtypes must honour the parent’s contract—no hidden disclaimers.”
🚫 Broken Promise
class FileReader {
String read(Path p) { /* … */ }
}
class NetworkFileReader extends FileReader {
@Override
String read(Path p) { throw new UnsupportedOperationException("remote only"); }
}
Works until someone innocently passes NetworkFileReader
where FileReader
was expected.
✅ Honest Abstraction
interface Reader { String read() throws IOException; }
class LocalFileReader implements Reader { ... }
class RemoteUrlReader implements Reader { ... }
Both obey the same rules; clients remain blissfully ignorant.
I — Interface Segregation Principle
“Many small, purpose‑built interfaces beat one kitchen‑sink.”
🚫 One Ring to Bore Them All
interface DataStore {
void save(Object o);
Object fetch(String id);
void flush();
void compact();
MigrationReport migrate(); // only admin tools use this
}
✅ Slice It Up
interface Saver { void save(Object o); }
interface Fetcher { Object fetch(String id); }
interface AdminOps extends Saver, Fetcher {
void flush();
void compact();
MigrationReport migrate();
}
Everyday services depend only on Saver
+ Fetcher
, not on arcane admin lore.
D — Dependency Inversion Principle
“High‑level policy must not depend on low‑level plumbing.”
🚫 Hard‑Wired DAO
class OrderService {
private final JdbcOrderDao dao = new JdbcOrderDao(); // welded‑in
void place(Order o) { dao.persist(o); }
}
✅ Inject an Abstraction
interface OrderRepository { void save(Order o); }
class JdbcOrderRepository implements OrderRepository { ... }
class NoSqlOrderRepository implements OrderRepository { ... }
class OrderService {
private final OrderRepository repo; // constructor injection
OrderService(OrderRepository repo){ this.repo = repo; }
void place(Order o){ repo.save(o); }
}
Unit tests swap in an in‑memory stub; prod swaps in a shiny, replicated DB driver.
SOLID Sanity Checklist
Principle | Ask Yourself… | Quick Smell |
---|---|---|
SRP | “Will this class change for more than one reason?” | 200‑line class with 6 verbs in its name |
OCP | “Can I add a feature without editing core logic?” | God‑switch blocks / if pyramids |
LSP | “Could a subclass break the parent’s guarantees?” |
UnsupportedOperationException in overrides |
ISP | “Are my consumers forced to implement stuff they never use?” | Interfaces ending in “Manager” or “Service” with 10+ methods |
DIP | “Am I new ‑ing concrete classes deep inside business code?” |
Tests that need a real database just to compile |
Print it, tape it next to your monitor, thank me later.
Final Thoughts: Future‑You Deserves Better
SOLID isn’t ivory‑tower dogma; it’s a survival kit.
- SRP stops the avalanche rebuild.
- OCP lets you ship Friday features without Saturday hotfixes.
- LSP kills polymorphic land‑mines.
- ISP keeps your mocks tiny and your APIs honest.
- DIP makes swapping databases or message buses a wiring exercise, not a rewrite.
Write code like you’ll be on‑call at 2 a.m.—because spoiler: you will be.
Keep it SOLID, and those 2 a.m. pages might just stay someone else’s problem.