The first article in this course ended on an uncomfortable demonstration: a subclass that satisfied the compiler in every way and still gave its caller a wrong answer. Inheritance had been used correctly by every rule the language enforces, and the design was still broken. That gap — between what javac checks and what a design promises — is what the five principles in this article are about.
They are usually presented as five definitions with five toy examples, which is why most people finish an article on SOLID able to recite the names and unable to spot a violation in their own code. So every principle here gets a before and an after that compile and run on OpenJDK 21.0.6, where the "before" is something a competent person would plausibly write. And every principle gets an honest note about where applying it stops being worth the cost, because all five of them can be over-applied into a codebase of forty one-method interfaces.
![]()
Every line of program output, every exception and every javac message quoted below was produced by compiling and running the code shown, unmodified, on OpenJDK 21.0.6 for arm64.
The five names, and the one thing they all measure
SOLID is an acronym Michael Feathers arranged from five principles Robert C. Martin had written up separately around the turn of the century. The names are awkward, but the acronym stuck, and it is the vocabulary every Java team already uses in code review.
| Letter | Principle | The question it asks |
|---|---|---|
| S | Single Responsibility | How many unrelated reasons does this class have to change? |
| O | Open/Closed | Can I add a case without editing the cases that already work? |
| L | Liskov Substitution | Can a caller hold the supertype and be right about what happens? |
| I | Interface Segregation | Is anything forced to implement a method it cannot support? |
| D | Dependency Inversion | Does the policy depend on the detail, or both on an abstraction? |
None of the five is about speed. Every one of them is about the cost of change, and that cost is countable: how many files a change opens, how many call sites break, how much has to exist before a piece of logic can be run at all. Where this article claims an improvement, it names a number you can count in the code.
Single Responsibility: one reason to change
A responsibility here is not "a thing the class does". It is a reason someone will edit this file. A class with three of them gets edited by three different people for three unrelated motives, and each of them can break the other two.
This report class computes a total, formats a CSV and writes it to disk. Nobody would call that unreasonable on sight.
import java.io.IOException;
import java.nio.file.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
record Sale(String product, int qty, long unitCents) {
long lineCents() { return qty * unitCents; }
}
class SalesReport {
private final List<Sale> sales;
private final Path out;
private final DateTimeFormatter stamp;
private final double taxRate;
SalesReport(List<Sale> sales, Path out, DateTimeFormatter stamp, double taxRate) {
this.sales = sales; this.out = out; this.stamp = stamp; this.taxRate = taxRate;
}
long generate() throws IOException {
long net = 0;
for (Sale s : sales) net += s.lineCents();
long gross = Math.round(net * (1 + taxRate));
StringBuilder csv = new StringBuilder("product,qty,cents\n");
for (Sale s : sales) csv.append(s.product()).append(',')
.append(s.qty()).append(',')
.append(s.lineCents()).append('\n');
csv.append("TOTAL,,").append(gross).append('\n');
csv.append("generated,").append(stamp.format(LocalDate.of(2026, 10, 6))).append(",\n");
Files.writeString(out, csv.toString());
return gross;
}
}
public class Before {
public static void main(String[] args) throws Exception {
List<Sale> sales = List.of(new Sale("keyboard", 2, 4500), new Sale("mouse", 3, 1900));
// All I want is the tax arithmetic.
SalesReport report = new SalesReport(
sales, Path.of("report.csv"), DateTimeFormatter.ISO_DATE, 0.10);
long gross = report.generate();
System.out.println("gross cents = " + gross);
System.out.println("report.csv on disk? = " + Files.exists(Path.of("report.csv")));
}
}
gross cents = 16170
report.csv on disk? = true
Look at the second line. Checking one arithmetic rule wrote a file to disk, because computing and storing are the same method. And to get that number at all I had to supply a Path and a DateTimeFormatter, neither of which has anything to do with tax. The constructor takes four arguments; exactly one of them is involved in the answer I wanted.
Splitting by reason to change, not by "one class per noun":
import java.io.IOException;
import java.nio.file.*;
import java.util.List;
record Sale(String product, int qty, long unitCents) {
long lineCents() { return qty * unitCents; }
}
/** Changes when a tax rule changes. Nothing else. */
final class SalesTotals {
private final double taxRate;
SalesTotals(double taxRate) { this.taxRate = taxRate; }
long grossCents(List<Sale> sales) {
long net = 0;
for (Sale s : sales) net += s.lineCents();
return Math.round(net * (1 + taxRate));
}
}
/** Changes when the file format changes. Nothing else. */
final class CsvRenderer {
String render(List<Sale> sales, long grossCents) {
StringBuilder csv = new StringBuilder("product,qty,cents\n");
for (Sale s : sales) csv.append(s.product()).append(',')
.append(s.qty()).append(',')
.append(s.lineCents()).append('\n');
return csv.append("TOTAL,,").append(grossCents).append('\n').toString();
}
}
/** Changes when reports move somewhere else. Nothing else. */
final class ReportStore {
void save(Path where, String body) throws IOException { Files.writeString(where, body); }
}
public class After {
public static void main(String[] args) throws IOException {
List<Sale> sales = List.of(new Sale("keyboard", 2, 4500), new Sale("mouse", 3, 1900));
long gross = new SalesTotals(0.10).grossCents(sales);
System.out.println("gross cents = " + gross);
System.out.println("report.csv on disk? = " + Files.exists(Path.of("report.csv")));
// Producing an actual report is now a separate decision.
new ReportStore().save(Path.of("report.csv"), new CsvRenderer().render(sales, gross));
System.out.println("after saving = " + Files.exists(Path.of("report.csv")));
}
}
gross cents = 16170
report.csv on disk? = false
after saving = true
Same 16170. Four countable differences: the constructor for the arithmetic went from four arguments to one; asking for the total wrote zero files instead of one; the three future changes now open three different files instead of colliding in one; and moving reports to object storage no longer recompiles anything that knows about tax.

The trap is reading "single responsibility" as "small". It is not a line count. A parser with two thousand lines and one reason to change is fine; a forty-line class that both validates and sends email is not.
Open/Closed: adding a case without editing the old ones
Open for extension, closed for modification. The version that actually helps: adding a case should not require editing the cases that already work.
Here is where that bites. Three methods, three switches, one set of cases.
class ShippingCalculator {
long costCents(String method, int grams) {
switch (method) {
case "STANDARD": return 500 + grams / 10;
case "EXPRESS": return 1200 + grams / 5;
case "PICKUP": return 0;
default: throw new IllegalArgumentException("unknown method: " + method);
}
}
int estimatedDays(String method, boolean international) {
switch (method) {
case "STANDARD": return international ? 12 : 4;
case "EXPRESS": return international ? 4 : 1;
case "PICKUP": return 0;
default: throw new IllegalArgumentException("unknown method: " + method);
}
}
boolean isAvailable(String method, int grams, boolean international) {
switch (method) {
case "STANDARD": return grams <= 30_000;
case "EXPRESS": return grams <= 5_000;
case "PICKUP": return !international;
default: throw new IllegalArgumentException("unknown method: " + method);
}
}
}
public class Before {
public static void main(String[] args) {
ShippingCalculator c = new ShippingCalculator();
for (String m : new String[] { "STANDARD", "EXPRESS", "PICKUP" }) {
System.out.printf("%-9s %6d cents %2d days available=%b%n",
m, c.costCents(m, 800), c.estimatedDays(m, false), c.isAvailable(m, 800, false));
}
System.out.println(c.costCents("DRONE", 800));
}
}
STANDARD 580 cents 4 days available=true
EXPRESS 1360 cents 1 days available=true
PICKUP 0 cents 0 days available=true
Exception in thread "main" java.lang.IllegalArgumentException: unknown method: DRONE
at ShippingCalculator.costCents(Before.java:7)
at Before.main(Before.java:37)
Adding drone delivery means three edits inside this one file — I applied them and counted the diff, three added lines in three different methods. The expensive part is not the typing. It is that nothing checks you did all three: I added DRONE to costCents only, and the file compiled without a single complaint, then threw IllegalArgumentException: unknown method: DRONE at runtime from estimatedDays, the switch I skipped. The compiler cannot help, because a String switch has no idea what the full set is.
Turn each case into a type and the three switches disappear:
import java.util.List;
record Parcel(int grams, boolean international) {}
interface ShippingMethod {
String name();
long costCents(Parcel p);
int estimatedDays(Parcel p);
boolean isAvailable(Parcel p);
}
final class Standard implements ShippingMethod {
public String name() { return "STANDARD"; }
public long costCents(Parcel p) { return 500 + p.grams() / 10; }
public int estimatedDays(Parcel p) { return p.international() ? 12 : 4; }
public boolean isAvailable(Parcel p) { return p.grams() <= 30_000; }
}
final class Express implements ShippingMethod {
public String name() { return "EXPRESS"; }
public long costCents(Parcel p) { return 1200 + p.grams() / 5; }
public int estimatedDays(Parcel p) { return p.international() ? 4 : 1; }
public boolean isAvailable(Parcel p) { return p.grams() <= 5_000; }
}
final class Pickup implements ShippingMethod {
public String name() { return "PICKUP"; }
public long costCents(Parcel p) { return 0; }
public int estimatedDays(Parcel p) { return 0; }
public boolean isAvailable(Parcel p) { return !p.international(); }
}
// Added months later. Nothing above this line was touched.
final class Drone implements ShippingMethod {
public String name() { return "DRONE"; }
public long costCents(Parcel p) { return 2500; }
public int estimatedDays(Parcel p) { return 1; }
public boolean isAvailable(Parcel p) { return !p.international() && p.grams() <= 2_000; }
}
public class After {
static void quote(List<ShippingMethod> methods, Parcel p) {
for (ShippingMethod m : methods) {
if (!m.isAvailable(p)) continue;
System.out.printf("%-9s %6d cents %2d days%n",
m.name(), m.costCents(p), m.estimatedDays(p));
}
}
public static void main(String[] args) {
// The one line that has to know the new class exists.
List<ShippingMethod> methods =
List.of(new Standard(), new Express(), new Pickup(), new Drone());
quote(methods, new Parcel(800, false));
System.out.println("---");
quote(methods, new Parcel(9_000, true));
}
}
STANDARD 580 cents 4 days
EXPRESS 1360 cents 1 days
PICKUP 0 cents 0 days
DRONE 2500 cents 1 days
---
STANDARD 1400 cents 12 days
Drone is a new file. Zero lines changed in Standard, Express, Pickup or ShippingMethod, and — the part the toy examples skip — you cannot forget a method, because leaving one out is Drone is not abstract and does not override abstract method isAvailable(Parcel) in ShippingMethod. The compiler now enforces what the three switches only hoped for.

Be precise about what was actually bought, though. Something still has to name Drone, and here it is the List.of(...) in main — one edited line in one existing file. Open for extension has never meant "this file is never edited again"; it means adding a case is not what edits it. Any article that shows you a plugin registry and claims zero edits has moved the edit somewhere else, usually into a configuration file that no compiler checks at all.
When a sealed interface and an exhaustive switch is the better answer
The polymorphic version is the right default when the set of cases is genuinely open — shipping methods, payment providers, export formats, anything a future requirement can add to. When the set is closed, a sealed interface plus a pattern-matching switch is a legitimate and often better alternative, and pretending otherwise is how you end up with an AbstractCreditCardPaymentHandlerImpl for three cases that will never grow.
sealed interface Payment permits Card, BankTransfer, StoreCredit {}
record Card(String last4) implements Payment {}
record BankTransfer(String iban) implements Payment {}
record StoreCredit(long cents) implements Payment {}
public class Sealed {
static long feeCents(Payment p, long amountCents) {
return switch (p) {
case Card c -> Math.round(amountCents * 0.029) + 30;
case BankTransfer b -> 25;
case StoreCredit s -> 0;
};
}
public static void main(String[] args) {
System.out.println("card = " + feeCents(new Card("4242"), 10_000));
System.out.println("banktransfer = " + feeCents(new BankTransfer("DE89"), 10_000));
System.out.println("storecredit = " + feeCents(new StoreCredit(5_000), 10_000));
}
}
It prints 320, 25 and 0. Note there is no default branch: the switch is exhaustive because permits tells javac the complete list. That is the trade. Adding a case now does edit this file — and the compiler makes sure you edit every file that needed it:
sealed interface Payment permits Card, BankTransfer, StoreCredit, Crypto {}
record Crypto(String chain) implements Payment {}
Sealed.java:10: error: the switch expression does not cover all possible input values
return switch (p) {
^
1 error
That error points at the switch expression, and you get one for every unhandled switch in the program. Compare that with the String version, which compiled silently and failed in production. The rule of thumb: an open set of cases wants an interface with several implementations; a closed set wants a sealed hierarchy and exhaustive switches. Fee calculation over the payment methods your company supports is closed. Shipping methods are not.
Liskov Substitution: the subclass that compiles and returns the wrong answer
Barbara Liskov's formulation is about subtypes being usable wherever the supertype is expected. In practice it means the subclass must keep every promise the superclass made — including the promises written in the Javadoc that the compiler never reads.
The first article in this course showed one of these violations without giving it a name. Here is a different one, and a more common one, because it comes from an optimisation. EventLog documents that append returns an index you can read back at. RotatingEventLog bounds memory by dropping the oldest entry.
import java.util.ArrayList;
import java.util.List;
class EventLog {
protected final List<String> entries = new ArrayList<>();
/** Appends an entry and returns the index it can be read back at. */
int append(String entry) {
entries.add(entry);
return entries.size() - 1;
}
String get(int index) { return entries.get(index); }
}
/** Keeps memory bounded by dropping the oldest entry. Compiles without a warning. */
class RotatingEventLog extends EventLog {
private final int max;
RotatingEventLog(int max) { this.max = max; }
@Override
int append(String entry) {
entries.add(entry);
if (entries.size() > max) entries.remove(0);
return entries.size() - 1;
}
}
public class Before {
/** Written against EventLog, months before RotatingEventLog existed. */
static void audit(EventLog log) {
int begin = log.append("BEGIN");
for (int i = 0; i < 4; i++) log.append("step " + i);
int end = log.append("END");
System.out.println(log.getClass().getSimpleName());
System.out.println(" get(begin) = " + log.get(begin));
System.out.println(" get(end) = " + log.get(end));
System.out.println(" correct? = " + log.get(begin).equals("BEGIN"));
}
public static void main(String[] args) {
audit(new EventLog());
audit(new RotatingEventLog(3));
}
}
EventLog
get(begin) = BEGIN
get(end) = END
correct? = true
RotatingEventLog
get(begin) = step 2
get(end) = END
correct? = false
audit was never changed and never recompiled against anything new. Given the subclass it returns the wrong entry — not an exception, not a crash, just a different string where BEGIN used to be. javac -Xlint:all prints nothing at all for this program: the override has the right name, the right parameters, the right return type and an @Override annotation. Every rule the language enforces is satisfied. The promise that append returns a stable index is not a rule the language can enforce.
That is the shape of almost every Liskov violation worth catching: not a subclass that fails to compile, but a subclass that quietly narrows what the supertype promised. Strengthening a precondition (rejecting inputs the parent accepted), weakening a postcondition (returning less than the parent guaranteed) and throwing exceptions the parent never documented are the three usual forms.
A contract both types can keep
@Override is not the fix, and neither is adding a runtime check. The fix is to notice that the base contract was never true of rotation, and write one that both types can keep: append returns an id that is never reused, and reading returns that entry or nothing.
import java.util.LinkedHashMap;
import java.util.Optional;
/**
* A contract both implementations can actually keep:
* append returns an id that is never reused,
* read returns that entry or nothing.
*/
interface EventLog {
long append(String entry);
Optional<String> read(long id);
}
final class ArrayEventLog implements EventLog {
private final LinkedHashMap<Long, String> byId = new LinkedHashMap<>();
private long next = 0;
public long append(String entry) {
long id = next++;
byId.put(id, entry);
return id;
}
public Optional<String> read(long id) { return Optional.ofNullable(byId.get(id)); }
}
final class RotatingEventLog implements EventLog {
private final LinkedHashMap<Long, String> byId = new LinkedHashMap<>();
private final int max;
private long next = 0;
RotatingEventLog(int max) { this.max = max; }
public long append(String entry) {
long id = next++;
byId.put(id, entry);
if (byId.size() > max) byId.remove(byId.keySet().iterator().next());
return id;
}
public Optional<String> read(long id) { return Optional.ofNullable(byId.get(id)); }
}
public class After {
static void audit(EventLog log) {
long begin = log.append("BEGIN");
for (int i = 0; i < 4; i++) log.append("step " + i);
long end = log.append("END");
System.out.println(log.getClass().getSimpleName());
System.out.println(" read(begin) = " + log.read(begin).orElse("<gone>"));
System.out.println(" read(end) = " + log.read(end).orElse("<gone>"));
System.out.println(" wrong data? = "
+ log.read(begin).filter(s -> !s.equals("BEGIN")).isPresent());
}
public static void main(String[] args) {
audit(new ArrayEventLog());
audit(new RotatingEventLog(3));
}
}
ArrayEventLog
read(begin) = BEGIN
read(end) = END
wrong data? = false
RotatingEventLog
read(begin) = <gone>
read(end) = END
wrong data? = false
The rotating log still drops old entries — that was the point of it — but it now says so in a way the caller can see, and wrong data? is false for both. The weaker contract is the honest one. Note what changed structurally: extends became implements, and neither implementation inherits anything from the other. When two types share an interface but not a promise, they were never parent and child.
Interface Segregation: the method nobody can implement
A fat interface pushes work onto implementations that cannot do it. The visible symptom is a method body that exists only to throw.
Storage looks like a perfectly sensible abstraction until something read-only has to implement it — bundled assets, a mounted read-only volume, a signed artifact.
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.util.HashMap;
import java.util.Map;
interface Storage {
String read(String key);
void write(String key, String value);
void delete(String key);
}
final class MemoryStorage implements Storage {
private final Map<String, String> map = new HashMap<>();
public String read(String key) { return map.get(key); }
public void write(String key, String value) { map.put(key, value); }
public void delete(String key) { map.remove(key); }
}
/** Assets shipped inside the build. Two of the three methods have no meaning here. */
final class BundledStorage implements Storage {
private final Path root;
BundledStorage(Path root) { this.root = root; }
public String read(String key) {
try { return Files.readString(root.resolve(key)); }
catch (IOException e) { throw new UncheckedIOException(e); }
}
public void write(String key, String value) {
throw new UnsupportedOperationException("BundledStorage is read-only");
}
public void delete(String key) {
throw new UnsupportedOperationException("BundledStorage is read-only");
}
}
public class Before {
/** Copies a key into cold storage, then removes the original. */
static void archive(Storage from, String key, Storage cold) {
cold.write(key, from.read(key));
from.delete(key);
}
public static void main(String[] args) {
Storage cold = new MemoryStorage();
Storage bundled = new BundledStorage(Path.of("assets"));
System.out.println("read = " + bundled.read("app.properties").lines().findFirst().get());
archive(bundled, "app.properties", cold);
}
}
read = app.name=vnntools
Exception in thread "main" java.lang.UnsupportedOperationException: BundledStorage is read-only
at BundledStorage.delete(Before.java:36)
at Before.archive(Before.java:44)
at Before.main(Before.java:52)
Read the failure carefully, because it is worse than "it threw". The copy into cold storage succeeded before the delete blew up, so the operation is half done: the data now exists in two places and the program is on the floor. A method signature promised something the object could not deliver, and the price was paid at runtime, in the middle of a two-step operation.
The payoff is a compile error instead of a runtime one
Split the interface along the line that implementations actually fall on. Reading and writing are separate capabilities, so they are separate interfaces, with the writable one extending the readable one because everything that writes can also read.
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.util.HashMap;
import java.util.Map;
interface ReadableStorage {
String read(String key);
}
interface WritableStorage extends ReadableStorage {
void write(String key, String value);
void delete(String key);
}
final class MemoryStorage implements WritableStorage {
private final Map<String, String> map = new HashMap<>();
public String read(String key) { return map.get(key); }
public void write(String key, String value) { map.put(key, value); }
public void delete(String key) { map.remove(key); }
}
/** Implements only what it can honour. Nothing throws for being unimplementable. */
final class BundledStorage implements ReadableStorage {
private final Path root;
BundledStorage(Path root) { this.root = root; }
public String read(String key) {
try { return Files.readString(root.resolve(key)); }
catch (IOException e) { throw new UncheckedIOException(e); }
}
}
public class After {
static void archive(WritableStorage from, String key, WritableStorage cold) {
cold.write(key, from.read(key));
from.delete(key);
}
/** Only reads, so it accepts anything that can read. */
static String firstLine(ReadableStorage s, String key) {
return s.read(key).lines().findFirst().orElse("");
}
public static void main(String[] args) {
WritableStorage hot = new MemoryStorage();
WritableStorage cold = new MemoryStorage();
BundledStorage bundled = new BundledStorage(Path.of("assets"));
hot.write("session.txt", "user=42");
archive(hot, "session.txt", cold);
System.out.println("cold = " + cold.read("session.txt"));
System.out.println("hot = " + hot.read("session.txt"));
System.out.println("asset = " + firstLine(bundled, "app.properties"));
}
}
cold = user=42
hot = null
asset = app.name=vnntools
And now the mistake that cost a half-finished archive is not expressible:
Bad.java:7: error: incompatible types: BundledStorage cannot be converted to WritableStorage
After.archive(bundled, "app.properties", cold);
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error
That is the whole return on interface segregation, stated as a number: one class of runtime failure removed entirely, and UnsupportedOperationException bodies down from two to zero. firstLine also became more useful without being touched — it takes ReadableStorage, so every store in the program can be passed to it.
Now the honest part. The JDK does not do this, on purpose. java.util.Collection documents add as an "optional operation", and List.of("a", "b").add("c") throws UnsupportedOperationException from ImmutableCollections.uoe — exactly the smell described above, shipped in the standard library by people who knew what they were doing. Splitting Collection into readable and mutable halves would have doubled the interface count across the entire library and forced every method signature in every codebase to choose. They took the runtime failure to keep one type. When your interface has one awkward implementation out of twelve, they made the right call; when half your implementations are throwing, you have a different problem.
Dependency Inversion: the class you cannot run without a file
Two claims, and the second one is the one people miss. High-level policy should not depend on low-level detail; and both should depend on an abstraction. The abstraction belongs to the policy, not to the detail.
This class reads its own configuration file. It is an extremely common shape.
import java.io.IOException;
import java.nio.file.*;
import java.util.Properties;
/** Reads its own configuration. The dependency on the filesystem is hidden inside. */
class FeatureFlags {
private final Properties props = new Properties();
FeatureFlags() throws IOException {
try (var in = Files.newInputStream(Path.of("flags.properties"))) {
props.load(in);
}
}
boolean enabled(String flag) {
return Boolean.parseBoolean(props.getProperty(flag, "false"));
}
}
public class Before {
public static void main(String[] args) throws IOException {
// The one thing worth checking: an unknown flag is off.
FeatureFlags flags = new FeatureFlags();
System.out.println("dark-mode = " + flags.enabled("dark-mode"));
System.out.println("no-such = " + flags.enabled("no-such"));
}
}
Run it in a directory without flags.properties and you never reach the logic at all:
Exception in thread "main" java.nio.file.NoSuchFileException: flags.properties
at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
The real trace continues through eight frames of JDK internals before reaching at FeatureFlags.<init>(Before.java:10). There is no way to check what enabled("no-such") returns without a file existing on disk at a path this class chose for itself. Every test of this class is a filesystem test, and the class is unusable in any deployment that keeps configuration somewhere else.
What counts as an abstraction here
Not "a Properties object". The abstraction is the small interface describing what FeatureFlags needs — one method — declared next to FeatureFlags because it belongs to the policy, and implemented by whatever knows about files.
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.util.Map;
import java.util.Properties;
/** What FeatureFlags needs, stated by FeatureFlags — not by the filesystem. */
interface FlagSource {
String get(String flag);
}
final class FileFlagSource implements FlagSource {
private final Properties props = new Properties();
FileFlagSource(Path path) {
try (var in = Files.newInputStream(path)) { props.load(in); }
catch (IOException e) { throw new UncheckedIOException(e); }
}
public String get(String flag) { return props.getProperty(flag); }
}
final class MapFlagSource implements FlagSource {
private final Map<String, String> map;
MapFlagSource(Map<String, String> map) { this.map = map; }
public String get(String flag) { return map.get(flag); }
}
final class FeatureFlags {
private final FlagSource source;
FeatureFlags(FlagSource source) { this.source = source; }
boolean enabled(String flag) {
String v = source.get(flag);
return v != null && Boolean.parseBoolean(v);
}
}
public class After {
public static void main(String[] args) {
// The same check, with no file anywhere.
FeatureFlags flags = new FeatureFlags(new MapFlagSource(Map.of("dark-mode", "true")));
System.out.println("dark-mode = " + flags.enabled("dark-mode"));
System.out.println("no-such = " + flags.enabled("no-such"));
System.out.println("file on disk? = " + Files.exists(Path.of("flags.properties")));
// Only the wiring at startup knows a file is involved.
Path p = Path.of("flags.properties");
if (Files.exists(p)) {
System.out.println("from the file = "
+ new FeatureFlags(new FileFlagSource(p)).enabled("dark-mode"));
}
}
}
dark-mode = true
no-such = false
file on disk? = false
FeatureFlags now compiles without java.nio.file in scope at all. The check that needed a file, a directory and a cleanup step is three lines and touches nothing. Run the same program with flags.properties present and the last branch prints from the file = true, so the file-backed path is still exercised — it just is not the only path.

The arrow is the whole idea. Before, FeatureFlags pointed down at Files.newInputStream and through it at a specific path on disk. After, FeatureFlags points at FlagSource, and FileFlagSource points up at the same interface. The low-level detail now depends on the high-level policy's vocabulary, which is what "inversion" names.
Where these principles stop paying
Every one of these can be applied until it does damage, and a codebase over-engineered in the name of SOLID is harder to change than the one it replaced. Four types where there was one method, and the answer is identical:
// Every principle applied, to a calculation that has one rule and always will.
interface DiscountRate { double rate(); }
final class TenPercent implements DiscountRate { public double rate() { return 0.10; } }
interface TotalCalculator { long total(long netCents, DiscountRate d); }
final class DefaultTotalCalculator implements TotalCalculator {
public long total(long netCents, DiscountRate d) {
return Math.round(netCents * (1 - d.rate()));
}
}
public class TooFar {
// What all of that replaced.
static long total(long netCents) { return Math.round(netCents * 0.90); }
public static void main(String[] args) {
TotalCalculator calc = new DefaultTotalCalculator();
System.out.println("four types = " + calc.total(16_170, new TenPercent()));
System.out.println("one method = " + total(16_170));
}
}
four types = 14553
one method = 14553
Same number, four extra .class files, and a reader who now has to open three files to find one multiplication. Where I would deliberately not apply each one:
| Principle | Skip it when |
|---|---|
| Single Responsibility | The class is a record or DTO with no behaviour, or the "two responsibilities" always change together for the same reason. |
| Open/Closed | The set of cases is genuinely closed. A sealed hierarchy with exhaustive switches gives you compiler-checked completeness that polymorphism cannot. |
| Liskov Substitution | Never — this one has no upside to violating. But it is a reason to prefer composition over inheritance, not a reason to add interfaces. |
| Interface Segregation | One awkward implementation out of many, where splitting would double the type count for everyone. This is the call the JDK made for Collection. |
| Dependency Inversion | The dependency is pure and deterministic. Injecting a StringBuilder, an ArrayList or Math.round buys nothing and costs a constructor argument. |
⚠️ The single most reliable signal is whether the change you are anticipating has ever happened. "We might support another database one day" has produced more useless abstraction layers than any other sentence in software. Invert the dependencies you have actually needed to replace: the clock, the filesystem, the network, randomness, anything that talks to a system you do not control.
The order to apply them in also matters. Dependency Inversion and Single Responsibility pay off almost immediately and on almost every codebase. Open/Closed pays off the second time a case is added, and not before. Interface Segregation only pays when you have implementations that genuinely differ in capability. Liskov is not a refactoring at all — it is a rule you check every time you write extends.
FAQ
What does SOLID stand for in Java?
Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation and Dependency Inversion. They are five separate object-oriented design principles collected under one acronym, not a framework or a checklist to tick. Nothing in the Java language enforces any of them: they are properties of a design that javac will happily compile either way.
Is SOLID still relevant with modern Java?
The problems are, and some of the answers have changed. Records removed a lot of the classes that used to violate Single Responsibility by accident, sealed interfaces plus pattern matching gave Open/Closed a compiler-checked alternative to polymorphism for closed sets of cases, and lambdas made one-method interfaces cheap enough that Interface Segregation costs less than it used to. What has not changed is the underlying question: how much does the next change cost?
What is the difference between Liskov Substitution and simple overriding?
Overriding is a language mechanism, checked by the compiler: same name, compatible parameters, compatible return type. Substitution is a design property that no compiler checks. RotatingEventLog in this article overrides correctly by every rule javac knows, has an @Override annotation, produces no warning under -Xlint:all, and still hands its caller the wrong entry, because the superclass documented a promise the subclass could not keep.
How do I know if a class violates Single Responsibility?
Ask who would ask for it to change. If the answers are different people with different motives — the finance team for tax rules, the data team for the file format, the platform team for where files are stored — that is three responsibilities in one file. Line count is a poor proxy: a two-thousand-line parser with one reason to change is fine, and a forty-line class that validates and sends email is not.
Does Dependency Inversion mean I need a dependency injection framework?
No. Every example in this article inverts its dependencies with a constructor parameter and nothing else. A framework automates the wiring once you have hundreds of objects to wire; it does not do the inverting, and adding one to a small program buys configuration complexity in exchange for nothing.
Can applying SOLID make code worse?
Yes, easily, and the failure mode is recognisable: one-method interfaces with a single implementation named after it, wrapper classes around objects that are only ever constructed one way, and abstraction layers built for a second database that never arrives. Each of those was a principle applied to a change that never came. The test is whether the flexibility you are adding matches a change you have actually had to make.
Which SOLID principle should I learn first?
Dependency Inversion, because it is the one whose payoff you feel immediately: a class that takes an interface instead of opening a file can be run and checked in isolation, which changes how you work the same day you apply it. Single Responsibility next, for the same reason. Open/Closed and Interface Segregation pay off on the second and third change, so they are easier to appreciate once you have lived with a codebase for a while.
Conclusion
The five principles are five answers to one question: what does the next change cost? Single Responsibility counts how many unrelated reasons open the same file. Open/Closed asks whether a new case edits the cases that already work. Liskov asks whether a caller holding the supertype is still right about what happens. Interface Segregation asks whether any implementation is being made to promise something it cannot do. Dependency Inversion asks whether the arrow points at a detail or at an abstraction the policy owns.
None of them is a rule to follow unconditionally. Every example here paid for itself because a specific change was likely — a new shipping method, a new report format, configuration moving out of a file. Applied to code where that change never comes, the same techniques produce four types where one method would have done, and the identical answer at the end of it. The judgement is not "did I apply SOLID"; it is "which change am I buying insurance against, and has it ever happened".
Next in this series: common design patterns in Java — Singleton, Factory, Builder, Observer and Strategy. Several of them are these principles worked out into a named, reusable shape, which is why the interface-with-several-implementations you wrote twice in this article will look familiar when it arrives with a name attached.