In 2019 I wrote SOLID Java, a quick reminder of the five SOLID principles for Java developers who already knew them. Each principle had a BAD and a GOOD snippet, a list of code smells and a list of benefits.
This month I wanted a SOLID skill for
java-toolkit, my Claude
Code plugin for Java projects, and I asked Claude Code to start from that
post. Before reusing any snippet, it compiled each one with javac 21 and
25, stubbing only the types the post never defines (User, Engine,
Connection and a few others). Eight of the ten didn’t compile.
What didn’t compile
Two of the failures come from placeholder bodies: the BAD SRP
checkAccess has a comment where its return should be, and the BAD DIP
search calls an undefined query. The other six are real errors:
| Principle | Snippet | What javac says |
|---|---|---|
| SRP | GOOD UserSettingService |
cannot find symbol: securityService is never declared |
| OCP | GOOD ConsoleLog, FileLog |
log() in ConsoleLog cannot implement log() in Log, attempting to assign weaker access privileges; was public |
| LSP | BAD public boolean function add(...) |
';' expected: function is not a Java keyword |
| LSP | GOOD DataHashSet implements Set |
DataHashSet is not abstract and does not override abstract method clear() in Set |
| ISP | Driver, Mechanic |
cannot find symbol: the constructors drop car, then the methods call this.car |
| DIP | GOOD MySqlConnector implements Connector {} |
MySqlConnector is not abstract and does not override abstract method open() in Connector |
| DIP | GOOD Database |
cannot find symbol: connector and finder are never declared |
JDK 21 and 25 gave the same errors. The GOOD DataHashSet implements 2 of
the 13 abstract methods of Set. The empty classes in the DIP example
were meant as placeholders, but they don’t compile either. Only the BAD
OCP Logger and the GOOD ISP interfaces compile, and neither has a
method body with real logic.
What was wrong beyond the syntax
Some examples showed a different problem from the one in their heading, and some definitions were loose.
The LSP example is about inheritance, not substitution
With function removed, the BAD DataHashSet compiles and runs:
DataHashSet s = new DataHashSet();
s.addAll(List.of("x", "y", "z"));
// addCount=6 size=3
The comment in the post already said the count doubles. The reason is
that HashSet.addAll calls add, which the subclass also overrides. That
is the fragile base class problem from Effective Java (3rd edition, Item
18, “Favor composition over inheritance”). A caller holding this object as
a Set still gets correct set behavior, so nothing fails to substitute.
A real substitution failure is a subclass that breaks a contract its
callers rely on. equals in a class hierarchy is the classic case:
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");
p.equals(cp); // true
cp.equals(p); // false
ColorPoint extends Point adds a field and overrides equals, and
symmetry is gone. Collections that contain both kinds behave differently
depending on argument order. On current Java the fix is composition with
records, which are final:
record Point(int x, int y) {}
record ColorPoint(Point point, String color) {}
The counting set still deserves a fix, and composition is the answer.
Extending AbstractSet makes it short, because AbstractSet
inherits from AbstractCollection an addAll documented to call add
once per element:
final class CountingSet<E> extends AbstractSet<E> {
private final Set<E> delegate;
private int addCount;
CountingSet(Set<E> delegate) {
this.delegate = Objects.requireNonNull(delegate);
}
@Override
public boolean add(E e) {
addCount++;
return delegate.add(e);
}
@Override
public boolean contains(Object o) {
return delegate.contains(o);
}
@Override
public Iterator<E> iterator() {
return delegate.iterator();
}
@Override
public int size() {
return delegate.size();
}
int addCount() {
return addCount;
}
}
The same addAll of three elements now gives addCount=3 size=3.
Mislabeled smells and loose definitions
- SRP: the post said a class implementing feature 1 and feature 2 violates SRP. Robert C. Martin’s 2014 article ties a reason to change to people: changes should “originate from a single person, or rather, a single tightly coupled group of people”. Two features owned by one group can share a class.
- SRP: the GOOD version moved
checkAccessinto astaticmethod. A static call can’t be replaced or mocked, which contradicts the post’s own benefit, “can be now tested separately”. - OCP: one listed smell was a class that references another class directly instead of receiving it. That is a DIP smell.
- OCP: the
Loginterface has alog()method with no parameter, so no implementation can log a message. - LSP: the post called LSP “an extension of open/close principle”. LSP stands on its own (Liskov, 1987). OCP through subtyping depends on it.
- DIP: the BAD example reads
MSSQLConnectionand the GOOD one has aMySqlConnector, two different databases. TheConnectorandFinderinterfaces copy the low-level operations, andDatabaseonly forwards calls. In DIP the high-level module owns the interface and names it after what it needs.
What Java added since 2019
The post targeted Java 8. Several features since then change how some of the principles look in code.
| Feature | Final in | Effect on SOLID code |
|---|---|---|
| Records | 16 | Final value types, so no subclass can break equals (LSP) |
| Sealed classes | 17 | A closed set of variants the compiler knows (OCP, LSP) |
InstantSource |
17 | The JDK applying ISP to Clock |
Pattern matching for switch |
21 | Exhaustive switch over a sealed hierarchy (OCP) |
When new variants arrive often, an interface with implementations still
fits the open/closed principle. When the variants are fixed and new
operations arrive often, a sealed interface with an exhaustive switch
fits better:
sealed interface Shape permits Circle, Rect {}
record Circle(double radius) implements Shape {}
record Rect(double width, double height) implements Shape {}
static double area(Shape shape) {
return switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Rect(double w, double h) -> w * h;
};
}
There is no default branch. When a new record joins the permits list,
javac rejects every switch that misses it.
InstantSource is a small JDK example of interface segregation. Code that
only needs the current instant can take an InstantSource instead of a
Clock, which also carries a time zone. Tests pass a fixed source.
When not to apply them
The 2019 post listed smells and benefits but never said when to stop. Applied everywhere, SOLID leaves an interface beside every class and a class for every method. The skill has rules for that:
- SRP: a long class with one owner is cohesive. Splitting it scatters each change across files.
- OCP: one implementation and no second in sight needs no interface. Inside one code base, extracting it when the second variant arrives is cheap. Published libraries are the exception, because their callers can’t edit the code.
- LSP: make classes
finalunless they are designed for extension. - ISP: split interfaces by client group, not by method.
- DIP: depending on
String, collections orjava.timevalues is fine. Invert at boundaries: persistence, network, clock, file system.
The java-solid skill
java-solid shipped in java-toolkit
v0.1.5.
It explains a principle, writes code that follows it, or reviews a code
base:
/java-toolkit:java-solid review src/main/java
A review starts from the files that changed most in the last year of
git log, because that is where SRP and OCP problems cost the most. Then
it runs a few rg searches for candidate sites: catch-all *Manager and
*Util classes, instanceof chains, overrides that throw
UnsupportedOperationException, and policy code calling new on
repositories and clients or reading Instant.now() and System.getenv.
A hit is a candidate, not a finding. Each reported finding names the
principle, the evidence (who changes the class, which edit repeats, which
client breaks) and a fix that compiles on the project’s Java release. When
a fix costs more than it saves, the report says to leave the code as it
is.
Each Java example in the skill carries a marker with its minimum JDK, such as
<!-- compile: jdk=21 fails-below -->. The repository’s test script
compiles each marked snippet with that JDK. With fails-below it also
checks that the previous release rejects the snippet: the sealed Shape
must compile with --release 21 and fail with --release 20.
To try it, add the marketplace and install the plugin:
/plugin marketplace add filippobuletto/claude-plugins
/plugin install java-toolkit@filippobuletto
The original post stays online as it was, with a note pointing here. If the skill gives you a wrong or useless finding, open an issue on java-toolkit.
Leave a comment