You have inherited a six-hundred-line UserService class: it validates, saves, sends emails and writes the audit trail. Every change requires reading all of it, every test requires setting up five collaborators, and nobody is sure what breaks if you touch the wrong line. SOLID is a set of five design rules meant to avoid this situation, and the short answer is that they boil down to two questions to ask while reading code: "for how many distinct reasons could this class change?" and "what does it depend on, an abstraction or an implementation?". In this article each principle has a case with the wrong code, the correct code and an output that was actually run: the Java listings run on OpenJDK 21.0.13 without Spring, and the TypeScript one is compiled with tsc 5.9.3 in strict mode and run with Node 24. Prerequisite: knowing what an interface is; the Clean Architecture part is in the linked chapters.
The five principles, in the words of the person who formulated them
SOLID is an acronym coined by Robert C. Martin for five principles of class design. On his page The Principles of OOD he sums each up in one line, and it is worth keeping them in front of you before looking at the examples, because the versions in circulation are often diluted.
| Principle | Martin's formulation (paraphrased) |
|---|---|
| SRP, single responsibility | A class should have only one reason to change. |
| OCP, open/closed | You should be able to extend a class's behavior without modifying it. |
| LSP, Liskov substitution | Derived classes must be substitutable for their base classes. |
| ISP, interface segregation | Fine-grained interfaces that are client specific. |
| DIP, dependency inversion | Depend on abstractions, not on concretions. |
The five lines are not commandments to apply everywhere, they are criteria for judgment, and the judgment depends on what really changes in your system. A ten-line method that will never change gains nothing from an interface; a class that changes every sprint for different reasons does. Martin himself, in the post The Single Responsibility Principle (2014), acknowledges that the formula "one reason to change" immediately raises the question of what a reason is, and ties it to the concept of responsibility.
SRP: one class, one reason to change
Think of a user registration. The reasons for change are distinct: the rules on the email are decided by the product, persistence depends on the database, the text of the welcome email is changed by whoever handles communication, the audit format is requested by whoever handles compliance. If they live in a single method, every request from one of these groups risks breaking the others. In the listing the monolithic version (UserServiceGod) and the separated one (RegisterUser) coexist. In the second, the use case registers the user and announces an event; sending the email and the audit are two independent reactions, connected at wiring time.
import java.util.*;
import java.util.function.Consumer;
interface UserRepository { boolean existsByEmail(String email); void save(String email); }
interface EmailSender { void send(String to, String subject); }
class InMemoryUsers implements UserRepository {
private final Set<String> emails = new LinkedHashSet<>();
public boolean existsByEmail(String email) { return emails.contains(email); }
public void save(String email) { emails.add(email); }
}
// WRONG: validation, persistence, email, audit and notification in the same method.
class UserServiceGod {
private final UserRepository users; private final EmailSender mail; private final List<String> audit;
UserServiceGod(UserRepository users, EmailSender mail, List<String> audit) {
this.users = users; this.mail = mail; this.audit = audit;
}
void register(String email) {
if (!email.contains("@")) throw new IllegalArgumentException("Invalid email");
if (users.existsByEmail(email)) throw new IllegalStateException("User already exists");
users.save(email);
mail.send(email, "Welcome");
audit.add("User registered: " + email);
mail.send("admin@example.com", "New user " + email);
}
}
// RIGHT: the use case registers and announces; whoever reacts is another object.
record UserRegistered(String email) {}
class RegisterUser {
private final UserRepository users; private final Consumer<UserRegistered> publish;
RegisterUser(UserRepository users, Consumer<UserRegistered> publish) { this.users = users; this.publish = publish; }
void execute(String email) {
if (!email.contains("@")) throw new IllegalArgumentException("Invalid email");
if (users.existsByEmail(email)) throw new IllegalStateException("User already exists");
users.save(email);
publish.accept(new UserRegistered(email));
}
}
public class Srp {
public static void main(String[] args) {
// Test of the "pure" registration: no email, no audit to set up.
List<UserRegistered> events = new ArrayList<>();
new RegisterUser(new InMemoryUsers(), events::add).execute("a@example.com");
System.out.println("published events: " + events);
// Real wiring: two independent reactions to the same event.
List<String> sent = new ArrayList<>(), audit = new ArrayList<>();
List<Consumer<UserRegistered>> handlers = List.of(
e -> sent.add(e.email() + ": Welcome"), // email handler
e -> audit.add("User registered: " + e.email())); // audit handler
RegisterUser useCase = new RegisterUser(new InMemoryUsers(), e -> handlers.forEach(h -> h.accept(e)));
useCase.execute("b@example.com");
System.out.println("email: " + sent + " | audit: " + audit);
// The monolithic version cannot be tested without building mail and audit too.
List<String> mails = new ArrayList<>();
new UserServiceGod(new InMemoryUsers(), (to, s) -> mails.add(to + ": " + s), new ArrayList<>()).register("c@example.com");
System.out.println("monolith, emails sent: " + mails);
}
}
// published events: [UserRegistered[email=a@example.com]]
// email: [b@example.com: Welcome] | audit: [User registered: b@example.com]
// monolith, emails sent: [c@example.com: Welcome, admin@example.com: New user c@example.com]
The output holds three facts. The first test creates the use case by passing only a repository and a way to collect events: it did not have to set up either an email sender or an audit log, and it checks the only thing the use case must guarantee, namely that the event was published. The second shows two independent reactions to the same event: if tomorrow the audit moves to another system, one handler changes and the use case is not recompiled. The third shows the cost of the monolith: to test a registration you had to build the email sender too, and the email to the administrator goes out anyway.
The price is real: the event introduces an indirection, and whoever reads the use case no longer sees directly what happens after registration. With only two side effects, a single fifty-line class is often clearer; the event pays off when the reactions grow or belong to different people. In Spring code the two handlers would be methods annotated with @EventListener, and attention goes to transaction boundaries: by default listeners receive the event synchronously and, if there is a transaction, operate in its context (Spring Framework documentation). See Use Case Pattern in Spring Boot for the shape of the use case.
OCP: add without modifying
You have a PaymentProcessor with a chain of if statements on the payment method. Every new method forces you to reopen that file, and the regression hits the methods that were working. The solution is the Strategy pattern: a PaymentGateway interface with a supports() method that declares which method it handles, and one class per gateway. The use case receives the list of gateways and builds from it a map indexed by method. This is the point many examples skip: a map that is declared but never populated compiles and does not work; here the construction is visible in the constructor.
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
enum PaymentMethod { CREDIT_CARD, PAYPAL, BITCOIN }
record Payment(PaymentMethod method, long cents) {}
interface PaymentGateway {
PaymentMethod supports();
String process(Payment payment);
}
class StripeGateway implements PaymentGateway {
public PaymentMethod supports() { return PaymentMethod.CREDIT_CARD; }
public String process(Payment p) { return "stripe: " + p.cents(); }
}
class PayPalGateway implements PaymentGateway {
public PaymentMethod supports() { return PaymentMethod.PAYPAL; }
public String process(Payment p) { return "paypal: " + p.cents(); }
}
class BitcoinGateway implements PaymentGateway { // the "new requirement": one new class
public PaymentMethod supports() { return PaymentMethod.BITCOIN; }
public String process(Payment p) { return "bitcoin: " + p.cents(); }
}
// This class does NOT change when you add a gateway.
class ProcessPayment {
private final Map<PaymentMethod, PaymentGateway> gateways;
ProcessPayment(List<PaymentGateway> all) {
this.gateways = all.stream().collect(Collectors.toMap(PaymentGateway::supports, Function.identity()));
}
String execute(Payment payment) {
PaymentGateway g = gateways.get(payment.method());
if (g == null) throw new IllegalArgumentException("Unsupported method: " + payment.method());
return g.process(payment);
}
}
public class Ocp {
public static void main(String[] args) {
ProcessPayment before = new ProcessPayment(List.of(new StripeGateway(), new PayPalGateway()));
System.out.println(before.execute(new Payment(PaymentMethod.PAYPAL, 1990)));
try { before.execute(new Payment(PaymentMethod.BITCOIN, 1990)); }
catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
ProcessPayment after = new ProcessPayment(List.of(new StripeGateway(), new PayPalGateway(), new BitcoinGateway()));
System.out.println(after.execute(new Payment(PaymentMethod.BITCOIN, 1990)));
try { new ProcessPayment(List.of(new StripeGateway(), new StripeGateway())); }
catch (IllegalStateException e) { System.out.println("duplicate: " + e.getMessage().split(" \\(")[0]); }
}
}
// paypal: 1990
// Unsupported method: BITCOIN
// bitcoin: 1990
// duplicate: Duplicate key CREDIT_CARD
The program first builds the use case with two gateways and shows its limit: a Bitcoin payment is rejected with a clear message. Then it adds a third class, BitcoinGateway, and passes it to the same ProcessPayment class, which was not modified: the payment works. The last line tests the defect this pattern brings with it: two gateways that declare the same method cannot coexist, and Collectors.toMap flags it immediately with an exception instead of letting one of the two win silently. In Spring the List<PaymentGateway> is injected with all the beans that implement the interface, and a new gateway with @Component joins on its own.
The cost to know about: "closed to modification" holds for the extension point you planned, not for any change whatsoever. If tomorrow the payment must also produce a receipt, the interface changes and so do all the implementations with it. That is why it is better to introduce the abstraction after the second or third real case, and not at the first.
LSP: the subtype keeps the contract
The principle says that an object of the subtype must be usable wherever the base one is used, without surprises. The formal formulation is by Barbara Liskov and Jeannette Wing (A Behavioral Notion of Subtyping, ACM TOPLAS, 1994): it is not enough for the signatures to match, what counts is the promised behavior. Here the contract of DiscountPolicy.percentage() is "returns a number between 0 and 100 and does not throw". The LooseTieredPolicy class breaks it: it receives the tier as an unchecked string and, for values it does not know, throws inside the method. The client applyDiscount cannot defend itself, because it does not know that its DiscountPolicy is of that type.
// DiscountPolicy contract: percentage() returns a number between 0 and 100 and does not throw.
class DiscountPolicy {
percentage(): number { return 0; }
}
// WRONG: the tier comes from outside as a string and percentage() throws for unknown values.
class LooseTieredPolicy extends DiscountPolicy {
constructor(private readonly tier: string) { super(); }
override percentage(): number {
if (this.tier === 'gold') return 20;
if (this.tier === 'silver') return 10;
throw new Error(`Tier not configured: ${this.tier}`); // the base contract does not allow this
}
}
// RIGHT: the type and the constructor make an invalid state impossible.
type Tier = 'gold' | 'silver';
class TieredPolicy extends DiscountPolicy {
private constructor(readonly tier: Tier, private readonly pct: number) { super(); }
static fromUntrusted(tier: string): TieredPolicy { // the error happens here, at creation
if (tier === 'gold') return new TieredPolicy('gold', 20);
if (tier === 'silver') return new TieredPolicy('silver', 10);
throw new Error(`Invalid tier: ${tier}`);
}
override percentage(): number { return this.pct; } // never throws
}
function applyDiscount(policy: DiscountPolicy, price: number): number {
return price * (1 - policy.percentage() / 100);
}
const fromJson = (JSON.parse('{"tier":"platinum"}') as { tier: string }).tier;
try {
console.log(applyDiscount(new LooseTieredPolicy('gold'), 100));
console.log(applyDiscount(new LooseTieredPolicy(fromJson), 100));
} catch (e) { console.log('client: ' + (e as Error).message); }
console.log(applyDiscount(TieredPolicy.fromUntrusted('gold'), 100));
try { TieredPolicy.fromUntrusted(fromJson); } catch (e) { console.log('creation: ' + (e as Error).message); }
// 80
// client: Tier not configured: platinum
// 80
// creation: Invalid tier: platinum
The output shows that the same faulty input (a platinum tier arriving from JSON, where TypeScript cannot check the type at runtime) blows up the client in the first case, and is rejected at creation in the second. The correct version uses a private constructor and a static method that validates: a TieredPolicy does not exist in an invalid state, so percentage() has no reason to throw. This is the practical way to respect LSP: move errors from the moment of use to the moment of construction. A detail about the starting code: in strict mode, tsc already rejects an uninitialized tier property (error TS2564), so the classic defect shows up when data comes from outside, not from a forgotten field.
Java's standard library has a real case of this tension. The Collection specifications declare certain operations "optional", and the implementation may throw UnsupportedOperationException: List.of(1, 2, 3).add(4) compiles and throws at runtime, as I verified by running it. The contract declares it openly (Javadoc of Collection), but a client that receives a List does not know whether it can modify it. When you write the interface yourself, avoid optional operations: they are LSP sacrificed for convenience, and you pay for it at runtime.
ISP: small interfaces, for those who use them
A Worker interface with work(), eat() and manage() seems convenient, until you have to implement it for a robot. The only shortcut is to let the useless methods throw exceptions, which is exactly the LSP defect just seen. The listing shows the problem the way you meet it: the compiler accepts lunchBreak(robot), and the error arrives only when the code runs.
interface Worker {
void work();
void eat();
void manage();
}
class RobotWorker implements Worker {
public void work() { System.out.println("robot: running the task"); }
public void eat() { throw new UnsupportedOperationException("a robot does not eat"); }
public void manage() { throw new UnsupportedOperationException("a robot does not manage"); }
}
public class IspBad {
static void lunchBreak(Worker w) { w.eat(); } // the compiler accepts any Worker
public static void main(String[] args) {
Worker robot = new RobotWorker();
robot.work();
try { lunchBreak(robot); }
catch (UnsupportedOperationException e) { System.out.println("at runtime: " + e.getMessage()); }
}
}
// robot: running the task
// at runtime: a robot does not eat
The correct version splits the interface according to what the clients ask for: whoever assigns work wants a Workable, whoever manages breaks wants an Eatable. The robot implements only the first, the human all three. The advantage is not aesthetic: the error moves from runtime to compilation.
interface Workable { void work(); }
interface Eatable { void eat(); }
interface Manageable { void manage(); }
class RobotWorker implements Workable {
public void work() { System.out.println("robot: running the task"); }
}
class HumanWorker implements Workable, Eatable, Manageable {
public void work() { System.out.println("human: working"); }
public void eat() { System.out.println("human: lunch break"); }
public void manage() { System.out.println("human: coordinating"); }
}
public class IspGood {
static void assign(Workable w) { w.work(); }
static void lunchBreak(Eatable e) { e.eat(); }
public static void main(String[] args) {
assign(new RobotWorker());
assign(new HumanWorker());
lunchBreak(new HumanWorker());
// lunchBreak(new RobotWorker()); // does not compile: RobotWorker is not Eatable
}
}
// robot: running the task
// human: working
// human: lunch break
If you try to pass the robot to the method that expects something that eats, the code no longer compiles. This is the real javac message for that line:
// IspBroken.java
public class IspBroken {
public static void main(String[] args) {
IspGood.lunchBreak(new RobotWorker());
}
}
// IspBroken.java:3: error: incompatible types: RobotWorker cannot be converted to Eatable
// IspGood.lunchBreak(new RobotWorker());
// ^
// 1 error
A warning about the opposite risk: splitting into a hundred one-method interfaces is not ISP, it is fragmentation. The criterion is the client: group the methods that the same clients use together. In a Clean Architecture this principle underlies the choice of small, use-case-specific ports, instead of a single repository with thirty methods.
DIP: the detail depends on the rule
The FindUserTight use case builds by itself the concrete class that accesses the database. The result is twofold: it cannot be tested without a database, and changing database means modifying the use case. The inversion lies in the place where the interface is declared: UserRepository belongs to the high level (the application rule), while the concrete implementation sits below and depends on it, not the other way around. This is the inversion in the name: the implementation's dependency arrow points toward the abstraction, against the flow of calls.
import java.util.*;
record User(String email) {}
// Port: declared by the high level (application), not by the database.
interface UserRepository {
Optional<User> findByEmail(String email);
}
// WRONG: the use case builds the concrete class by itself.
class PostgresUserRepository implements UserRepository {
PostgresUserRepository() { throw new IllegalStateException("database connection unavailable"); }
public Optional<User> findByEmail(String email) { return Optional.empty(); }
}
class FindUserTight {
private final UserRepository repository = new PostgresUserRepository(); // concrete dependency
Optional<User> execute(String email) { return repository.findByEmail(email); }
}
// RIGHT: it receives the abstraction from the constructor.
class FindUser {
private final UserRepository repository;
FindUser(UserRepository repository) { this.repository = repository; }
String execute(String email) {
return repository.findByEmail(email).map(u -> "found " + u.email()).orElse("not found");
}
}
public class Dip {
public static void main(String[] args) {
try { new FindUserTight(); }
catch (IllegalStateException e) { System.out.println("cannot be built without a database: " + e.getMessage()); }
UserRepository fake = email -> email.equals("a@example.com") ? Optional.of(new User(email)) : Optional.empty();
FindUser useCase = new FindUser(fake);
System.out.println(useCase.execute("a@example.com"));
System.out.println(useCase.execute("x@example.com"));
}
}
// cannot be built without a database: database connection unavailable
// found a@example.com
// not found
The program shows the two paths. The tight version fails already in the constructor, because the concrete class (simulated here with an exception) requires a database that the test does not have. The correct version receives the abstraction from the constructor, and the test fills it with a one-line function, without mock libraries: the interface has a single method, so a lambda is enough. With Spring, constructor injection is already the normal way of working; the principle says where the interface must live, not just that it exists. For the adapter and mapping side, see JPA and Flyway in Clean Architecture.
In this site's backend the rule is applied at package level and can be checked in the code: the ArticleRepository port lives in domain.port and its implementation ArticleRepositoryAdapter in infrastructure.persistence.adapter; the former imports nothing from JPA (its Javadoc says so, and the file's import declarations confirm it). A Clean Architecture is, to a large extent, this principle applied at every boundary between layers.
How to use them together, and how to check them
The five principles reinforce one another. A class with a single responsibility (SRP) usually has a small interface (ISP); a small interface is easier for subtypes to honor (LSP); a system made of small parts behind interfaces can be extended by adding parts (OCP) and verified by replacing the parts with test doubles (DIP). To use them in a code review, five questions are enough.
- SRP: can you describe the class in one sentence without "and"? If the sentence has three "and"s, the reasons for change are three.
- OCP: to add the next case, do you have to reopen code that works, or can you write a new class?
- LSP: is there a subtype that throws where the base does not, or that ignores a parameter?
- ISP: are there methods that some implementations leave empty or that throw
UnsupportedOperationException? - DIP: is there a
newin the constructor of a concrete class that talks to the outside world?
The five questions are not a score: a "yes" to just one of them, at a point that changes often, is worth more than five "yes" answers in code that will never change. Start applying them where you already feel pain, that is, the files that every change touches and the tests that require more setup than the code they exercise. A total refactoring in one go rarely works and is almost never justified.
Frequently asked questions
Is SOLID overkill for a small script?
For a script you will run once, yes. The time spent respecting the principles pays off when the code changes: if it will not change, there is nothing to pay back. A realistic criterion: two principles, SRP and DIP, keep the cost low and testing easy even in a small service.
How do I introduce SOLID into legacy code?
In small steps, starting from the pain. Pick a point that changes often, isolate it behind an interface (DIP), write the tests you could not write before, and only then separate the responsibilities. Showing your colleagues a test that used to require a database and now runs in an instant is more convincing than a document of principles.
Does SOLID apply to functional programming too?
The underlying ideas do, the form changes. A pure function that receives its dependencies as parameters achieves DIP without interfaces; a small, focused function is SRP. The vocabulary changes, not the goal: small boundaries, explicit dependencies.
What you can do now
You can recognize the five typical violations in real code, you can say for each which concrete cost you pay (impossible tests, regressions, runtime errors, coupling to the database) and you know where the remedy has a price: the event of SRP, the list of OCP, the private constructor of LSP. The next step is to see the principles inside a whole architecture, in the domain layer and in the big picture of Clean Architecture in Spring Boot 3.4.
Sources
- Robert C. Martin - The Principles of OOD - the formulations of the five principles; consulted on 2026-11-12
- Robert C. Martin - The Single Responsibility Principle (2014) - the meaning of "reason to change"; consulted on 2026-11-12
- Spring Framework Reference - Standard and Custom Events - synchronous listeners and the publisher's transactional context; consulted on 2026-11-12
- Java SE 21 API - java.util.Collection - optional operations and
UnsupportedOperationException; consulted on 2026-11-12 - Barbara Liskov, Jeannette Wing, A Behavioral Notion of Subtyping, ACM Transactions on Programming Languages and Systems 16(6), 1994 (DOI 10.1145/197320.197383) - bibliographic reference for the notion of behavioral subtyping; bibliographic reference only: the text of the paper was not re-read for this work
- Runs performed: OpenJDK 21.0.13 (Java),
tsc5.9.3 and Node 24.11.0 (TypeScript), temporary folder outside the repository

Commenti
Caricamento commenti...
Sign in to comment