SOLID Principles — Middle¶
What? One worked refactor per letter on realistic domains — loan, refund, claim, reservation, warehouse, healthcare — and a combined cleanup of a class that breaks several letters at once. How? Each section shows a faulty starting class, names the smell, and walks through the smallest change that removes it without over-engineering. Read the diff, not just the principle.
1. Why one example per letter beats abstract definitions¶
Junior-level SOLID lists definitions. Middle-level SOLID is transformations: I had this class, the smell was X, I applied principle Y, the result is the diff below. Until you can perform the transformation on your own code, the letters are slogans.
Every section follows the same rhythm: a real domain class, the concrete pain, then the smallest refactor that buys back changeability. None of the refactors introduce a framework or a new dependency — SOLID is a sketching tool, the heavy lifting is structural.
2. SRP — breaking up a god OrderManager (loan domain)¶
Suppose a lending platform has a LoanManager. It started small. A year later:
public class LoanManager {
private final Connection db;
private final SmtpClient mail;
public void apply(LoanApplication a) {
if (a.amount().signum() <= 0) throw new IllegalArgumentException("amount");
if (a.term() < 6 || a.term() > 60) throw new IllegalArgumentException("term");
BigDecimal monthly = a.amount().multiply(new BigDecimal("0.015"));
BigDecimal total = monthly.multiply(BigDecimal.valueOf(a.term()));
try (PreparedStatement ps = db.prepareStatement(
"INSERT INTO loans (borrower, amount, term, total) VALUES (?, ?, ?, ?)")) {
ps.setLong(1, a.borrowerId());
ps.setBigDecimal(2, a.amount());
ps.setInt(3, a.term());
ps.setBigDecimal(4, total);
ps.executeUpdate();
} catch (SQLException e) { throw new RuntimeException(e); }
mail.send(a.borrowerEmail(), "Loan approved", "Total payable: " + total);
}
}
Four stakeholders edit this class: risk (validation), finance (interest formula), data (schema), and marketing (email copy). Four reasons to change.
Refactor into focused collaborators:
public record LoanApplication(long borrowerId, String borrowerEmail,
BigDecimal amount, int term) {}
public class LoanValidator {
public void validate(LoanApplication a) {
if (a.amount().signum() <= 0) throw new IllegalArgumentException("amount");
if (a.term() < 6 || a.term() > 60) throw new IllegalArgumentException("term");
}
}
public class InterestCalculator {
public BigDecimal totalPayable(LoanApplication a) {
return a.amount().multiply(new BigDecimal("0.015"))
.multiply(BigDecimal.valueOf(a.term()));
}
}
public interface LoanRepository { void save(LoanApplication a, BigDecimal total); }
public interface LoanNotifier { void approved(LoanApplication a, BigDecimal total); }
public class LoanService {
private final LoanValidator validator;
private final InterestCalculator interest;
private final LoanRepository repo;
private final LoanNotifier notifier;
/* constructor omitted */
public void apply(LoanApplication a) {
validator.validate(a);
BigDecimal total = interest.totalPayable(a);
repo.save(a, total);
notifier.approved(a, total);
}
}
apply is now a four-line story. Each collaborator has one stakeholder, and tests can mock any of them in isolation.
3. OCP — replacing a type-code switch with sealed types (refund domain)¶
A returns service handles refund channels by string code:
public class RefundProcessor {
public void refund(String channel, BigDecimal amount, String reference) {
switch (channel) {
case "CARD" -> reverseCardAuthorization(reference, amount);
case "WALLET" -> creditWallet(reference, amount);
case "BANK" -> issueBankTransfer(reference, amount);
case "STORE_CREDIT" -> addStoreCredit(reference, amount);
default -> throw new IllegalArgumentException("unknown: " + channel);
}
}
}
Every new channel forces edits to a class that already works for four. The risk of breaking CARD while adding CRYPTO is non-zero.
Modern Java fits this perfectly via sealed interfaces and pattern matching:
public sealed interface RefundChannel
permits CardChannel, WalletChannel, BankChannel, StoreCreditChannel {
void refund(BigDecimal amount, String reference);
}
public final class CardChannel implements RefundChannel { /* reverse auth */ }
public final class WalletChannel implements RefundChannel { /* credit wallet */ }
// ... bank, store-credit
public class RefundProcessor {
public void refund(RefundChannel channel, BigDecimal amount, String reference) {
channel.refund(amount, reference);
}
}
Adding CryptoChannel is one new file plus one line in permits. The compiler also refuses to forget a case in any pattern-matching switch over RefundChannel — OCP's payoff without the openness-of-classic-strategy where anyone could add a rogue implementation.
4. LSP — fixing a subclass that throws (claim/list domain)¶
An insurance claims module exposes a list of evidence files. To "protect" it, someone wrote:
public class FrozenEvidenceList<T> extends ArrayList<T> {
public FrozenEvidenceList(Collection<? extends T> src) { super(src); }
@Override public boolean add(T t) { throw new UnsupportedOperationException(); }
@Override public boolean remove(Object o) { throw new UnsupportedOperationException(); }
@Override public T set(int i, T e) { throw new UnsupportedOperationException(); }
}
void attachToClaim(Claim c, List<Evidence> evidence) {
evidence.add(new Evidence("audit-trail.pdf")); // boom for FrozenEvidenceList
c.assignEvidence(evidence);
}
FrozenEvidenceList is-a ArrayList by inheritance but isn't one by contract — it narrows the parent's behaviour in ways callers can't see. The fix is not to extend the mutable type at all:
public final class FrozenEvidenceList<T> implements Iterable<T> {
private final List<T> items;
public FrozenEvidenceList(Collection<? extends T> src) { this.items = List.copyOf(src); }
public T get(int i) { return items.get(i); }
public int size() { return items.size(); }
public Iterator<T> iterator() { return items.iterator(); }
public Stream<T> stream() { return items.stream(); }
}
The parameter type now tells the caller whether mutation is allowed. The general rule: a subtype may strengthen postconditions and weaken preconditions, never the reverse. Throwing where the parent didn't is strengthening preconditions in disguise — "you can only call me on instances that happen to be the base class".
5. ISP — splitting a fat BookingRepository (reservation domain)¶
A hotel system has one repository all clients depend on:
public interface BookingRepository {
Booking findById(long id);
List<Booking> findByGuest(long guestId);
List<Booking> findOverlapping(LocalDate from, LocalDate to);
void save(Booking b);
void cancel(long id);
void archiveOlderThan(LocalDate cutoff);
BookingReport monthlyReport(YearMonth ym);
void exportToWarehouse(LocalDate cutoff);
}
Three callers use it: a reception screen reads and saves, a nightly job archives and exports, a finance dashboard wants only the monthly report. Every caller pulls in methods it doesn't use, every test double mocks methods nobody cares about, and a change to exportToWarehouse recompiles the reception screen.
Split by the role each caller plays:
public interface BookingReader {
Booking findById(long id);
List<Booking> findByGuest(long guestId);
List<Booking> findOverlapping(LocalDate from, LocalDate to);
}
public interface BookingWriter {
void save(Booking b);
void cancel(long id);
}
public interface BookingArchive {
void archiveOlderThan(LocalDate cutoff);
void exportToWarehouse(LocalDate cutoff);
}
public interface BookingReports {
BookingReport monthlyReport(YearMonth ym);
}
The JDBC class implements all four; nobody is forced to. The reception screen depends on BookingReader + BookingWriter, the nightly job on BookingArchive, the dashboard on BookingReports. Notice the interfaces are grouped by role, not by method count — ISP doesn't require one method per interface; BookingReader has three, and a reader uses all three.
6. DIP — inverting a service that imports a Postgres driver (healthcare domain)¶
A patient-records service depends on a concrete driver:
import org.postgresql.ds.PGSimpleDataSource; // low-level detail leaking into the domain
public class PatientRecordService {
private final PGSimpleDataSource ds;
public PatientRecordService(PGSimpleDataSource ds) { this.ds = ds; }
public Optional<PatientRecord> findByNationalId(String nid) {
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(
"SELECT * FROM patient_records WHERE national_id = ?")) {
ps.setString(1, nid);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
}
} catch (SQLException e) { throw new RuntimeException(e); }
}
}
The domain knows Postgres, JDBC types, and a SQL dialect. Migrate to a document DB and the domain class rewrites.
Invert the arrow:
public interface PatientRecordRepository {
Optional<PatientRecord> findByNationalId(String nid);
void save(PatientRecord r);
}
public class PatientRecordService {
private final PatientRecordRepository repo;
public PatientRecordService(PatientRecordRepository repo) { this.repo = repo; }
public Optional<PatientRecord> lookup(String nid) { return repo.findByNationalId(nid); }
}
// In an adapter package, not in the domain package:
public class PostgresPatientRecordRepository implements PatientRecordRepository {
private final DataSource ds;
/* constructor + JDBC bodies */
}
The domain now owns the abstraction (PatientRecordRepository sits next to PatientRecordService); the Postgres class is a detail that depends on the domain. Tests substitute a Map<String, PatientRecord>-backed implementation. The Postgres import is confined to one adapter file.
7. Combined refactor — a warehouse class that breaks four letters¶
public class WarehouseManager {
private final OracleConnection oracle;
public WarehouseManager(OracleConnection o) { this.oracle = o; }
public void process(String kind, Object payload) {
if (kind.equals("RESTOCK")) {
Restock r = (Restock) payload;
oracle.exec("UPDATE stock SET qty = qty + " + r.qty()
+ " WHERE sku = '" + r.sku() + "'");
sendEmail("ops@warehouse", "restocked " + r.sku());
} else if (kind.equals("SHIP")) {
Shipment s = (Shipment) payload;
oracle.exec("UPDATE stock SET qty = qty - " + s.qty()
+ " WHERE sku = '" + s.sku() + "'");
}
}
}
Smells: SRP (validates, persists, notifies in one method), OCP (adding RETURN edits the if-chain), ISP (callers stuck with process(String, Object) can pass the wrong kind), DIP (concrete OracleConnection wired into the domain).
Cleanup in four moves:
// 1. Replace stringly typed dispatch with a sealed command (OCP):
public sealed interface WarehouseCommand permits Restock, Shipment, ReturnReceipt {}
public record Restock(String sku, int qty) implements WarehouseCommand {}
public record Shipment(String sku, int qty) implements WarehouseCommand {}
public record ReturnReceipt(String sku, int qty) implements WarehouseCommand {}
// 2. Invert storage (DIP) and split notifications (ISP + SRP):
public interface StockRepository { void adjust(String sku, int delta); }
public interface OpsNotifier { void restocked(String sku, int qty); }
// 3. One handler per command (SRP):
public class RestockHandler {
private final StockRepository stock;
private final OpsNotifier ops;
/* constructor omitted */
public void handle(Restock r) {
stock.adjust(r.sku(), +r.qty());
ops.restocked(r.sku(), r.qty());
}
}
// ShipmentHandler and ReturnHandler are analogous.
// 4. A thin dispatcher; exhaustiveness checked by javac:
public class WarehouseDispatcher {
private final RestockHandler restock;
private final ShipmentHandler ship;
private final ReturnHandler returns;
/* constructor omitted */
public void execute(WarehouseCommand cmd) {
switch (cmd) {
case Restock r -> restock.handle(r);
case Shipment s -> ship.handle(s);
case ReturnReceipt r -> returns.handle(r);
}
}
}
Each move corresponds to one letter; doing them together yields a class set where every future change has an obvious home.
8. SOLID with records, sealed types, and functional interfaces¶
Modern Java collapses ceremony around several letters:
| Feature | Letter it serves | Why |
|---|---|---|
record | SRP | Value carriers have one job: hold values. Final, immutable, equal-by-fields. |
sealed | OCP + LSP | You decide the closed set; exhaustiveness is compile-checked; no rogue subtype can break contracts. |
Function<T,R> etc. | ISP + DIP | One-method abstractions; pass behaviour without inventing an interface. |
record + interface | DIP | Implement an abstraction with a zero-boilerplate immutable adapter. |
A small example combining several:
public sealed interface DiscountRule permits PercentOff, FixedAmountOff, FreeShipping {
BigDecimal apply(BigDecimal subtotal);
}
public record PercentOff(BigDecimal rate) implements DiscountRule {
public BigDecimal apply(BigDecimal s) { return s.multiply(BigDecimal.ONE.subtract(rate)); }
}
public record FixedAmountOff(BigDecimal off) implements DiscountRule {
public BigDecimal apply(BigDecimal s) { return s.subtract(off).max(BigDecimal.ZERO); }
}
public record FreeShipping(BigDecimal save) implements DiscountRule {
public BigDecimal apply(BigDecimal s) { return s.subtract(save).max(BigDecimal.ZERO); }
}
OCP for free (new rule = new record), LSP for free (records are final), SRP for free (each rule is one calculation), no DIP concern because there is no infrastructure inside a rule.
9. Mistakes that come from over-applying SOLID¶
Over-segregation. Splitting a five-method repository into five interfaces because "ISP" produces five files and zero callers that benefit. The unit of segregation is a caller role, not a method.
Premature abstraction. Wrapping every collaborator in an interface before there is a second implementation. You pay the indirection tax (jump-to-definition lands on the interface, reading flow takes two clicks) and never collect the benefit. Introduce the interface when a second impl appears or a test needs a fake at a real boundary.
"I-prefixed everything." IOrderService, IRepository, ILogger is a smell, not a discipline. The interface is the abstraction; the implementation is the variation. Name the interface for the role (OrderService), the implementation for the variation (JpaOrderService, InMemoryOrderService).
Treating SRP as line count. A 400-line PricingEngine for a real legal regime is fine if it has one stakeholder. A 30-line class that prints and saves is not.
Inheriting just to reuse code. Every extends introduces LSP risk. If you only want code reuse, compose; inheritance is for is-a relationships where substitution must hold.
Wrapping inert types in DIP. Don't introduce IString, IUuid, ILocalDate. DIP is for interesting boundaries — I/O, time, randomness, network, persistence, external systems.
10. Quick rules¶
- If you can name two stakeholders who would edit the same class for different reasons, split (SRP).
- If adding the next variant means editing the dispatch instead of adding a class, polymorphism or sealed types are missing (OCP).
- If a subclass narrows behaviour (throws, returns differently, requires more), the inheritance is wrong, not the parent (LSP).
- If callers mock methods they don't call, the interface is too wide for them (ISP).
- If a domain file imports a driver, a client, or a vendor SDK, the arrow is pointing the wrong way (DIP).
- Refactor one letter at a time; don't try to score all five in a single PR.
- Records, sealed types, and functional interfaces remove a lot of historical SOLID ceremony — use them.
11. What's next¶
| Topic | File |
|---|---|
| Edge cases, anti-patterns, "SOLIDified to death" | senior.md |
| Driving SOLID across a team and a codebase | professional.md |
| JLS/JVMS hooks relevant to SOLID idioms | specification.md |
| Silent SOLID violations and their runtime symptoms | find-bug.md |
| JIT, dispatch, allocation: the cost of SOLID idioms | optimize.md |
| Hands-on refactors | tasks.md |
| Interview Q&A | interview.md |
Memorize this: every SOLID fix is a transformation — name the smell, point at the letter, make the smallest move that removes it, stop. Records replace value-carrier SRP boilerplate; sealed types replace OCP-switch boilerplate; constructor injection replaces DIP framework ceremony. The principles are sketching tools, not laws — apply the one that names the smell in front of you.