Most enterprise Java systems start out clean and gradually get tangled: one OrderService class calculates prices, writes to the database, and sends email; adding a promotion type means reopening a method that already works; writing a unit test means standing up SMTP and a database. The five SOLID principles are the most common diagnosis and cure for those symptoms. This article walks through each SOLID principle with a small order system that actually runs. It is compiled with --release 17 (the Java 17 LTS language and API level), pastes real output, and says plainly when applying SOLID is overkill.

What SOLID is and where it comes from

Robert C. Martin (known as Uncle Bob) first collected these object-oriented design principles in the 2000 paper Design Principles and Design Patterns. Michael Feathers coined the acronym SOLID around 2004 to make them easier to remember. Two of the principles go back further: Open–Closed was stated by Bertrand Meyer in Object-Oriented Software Construction (1988), and Liskov Substitution comes from Barbara Liskov's keynote at OOPSLA 1987, formalized by Liskov and Jeannette Wing in 1994. You can check this history in Martin's original paper (archived copy of Principles and Patterns), the Liskov–Wing paper A behavioral notion of subtyping (ACM TOPLAS, 1994), and the overview pages for SOLID and the Liskov substitution principle.

LetterPrincipleShort statementSign of a violation
SSingle ResponsibilityA class should have only one reason to changeOne file is edited by several teams for unrelated requirements
OOpen–ClosedOpen for extension, closed for modificationAdding a feature means editing an old switch or if chain
LLiskov SubstitutionA subclass can be used anywhere its parent is used without breaking correct behaviorA subclass throws UnsupportedOperationException, or callers check instanceof
IInterface SegregationClients should not be forced to depend on methods they do not useA "fat" interface with many implementations that leave methods empty
DDependency InversionHigh-level modules depend on abstractions, not on concrete implementationsCalling new on an external service directly inside business logic

One important note up front: SOLID is a set of guiding principles, not theorems. SOLID helps you ask the right questions in code review. It is not a rule to apply mechanically to every class.

S in SOLID: Single Responsibility, one reason to change per class

The most common misunderstanding of the S in SOLID is "a class should do only one thing". The original statement is more precise: a class should have only one reason to change. A reason to change maps to a group of people making requests: accounting changes how money is rounded, the infrastructure team switches email providers, the data team changes the order table schema. If three groups edit the same file, they will step on each other.

The example below is the familiar "before" version: one checkout method that calculates the total, saves the order, and sends the email.

static class OrderServiceBefore {
    void checkout(String customerEmail, List<Line> lines) {
        BigDecimal total = BigDecimal.ZERO;
        for (Line l : lines) total = total.add(l.unitPrice().multiply(BigDecimal.valueOf(l.qty())));
        System.out.println("[SQL] INSERT INTO orders ... total=" + total);
        System.out.println("[SMTP] Sending mail to " + customerEmail + ": total " + total);
    }
}

After splitting, each responsibility is its own type, and OrderService is left with only the job of coordinating them:

static class PriceCalculator {
    BigDecimal total(List<Line> lines) {
        return lines.stream()
                .map(l -> l.unitPrice().multiply(BigDecimal.valueOf(l.qty())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}
interface OrderRepository { void save(BigDecimal total); }
interface Notifier { void orderPlaced(String to, BigDecimal total); }

static class OrderService {
    private final PriceCalculator calc;
    private final OrderRepository repo;
    private final Notifier notifier;
    OrderService(PriceCalculator calc, OrderRepository repo, Notifier notifier) {
        this.calc = calc; this.repo = repo; this.notifier = notifier;
    }
    void checkout(String email, List<Line> lines) {
        BigDecimal total = calc.total(lines);
        repo.save(total);
        notifier.orderPlaced(email, total);
    }
}

Running both versions on the same cart (two shirts at 199,000 VND and one hat at 89,000 VND) gives the same result. The behavior is unchanged; only the structure changed:

$ java -cp out Srp
--- Before the split ---
[SQL] INSERT INTO orders ... total=487000
[SMTP] Sending mail to khach@example.com: total 487000
--- After the split ---
[SQL] INSERT INTO orders ... total=487000
[SMTP] Sending mail to khach@example.com: total 487000

The immediate payoff of S in SOLID: testing PriceCalculator needs no mocks, and switching email providers touches exactly one class, the Notifier implementation. The trade-off: the number of types grows, and if you split too early you end up with many thin classes nobody needs. A practical rule: split only when you actually see two different reasons to change in your commit history.

O in SOLID: Open–Closed, add behavior with new code

This principle says that when a new requirement arrives, you should add new code instead of editing code that already works. The classic sign is a switch on customer tier:

static BigDecimal discountBefore(String tier, BigDecimal subtotal) {
    switch (tier) {
        case "VIP":   return subtotal.multiply(new BigDecimal("0.10"));
        case "STAFF": return subtotal.multiply(new BigDecimal("0.20"));
        default:      return BigDecimal.ZERO;
    }
}

Every new promotion forces you to open this method and risk breaking branches that are currently correct. The SOLID way is to turn the policy into an interface; adding a new policy then just means adding a new type:

interface DiscountPolicy { BigDecimal discount(BigDecimal subtotal); }
record Percent(String pct) implements DiscountPolicy {
    public BigDecimal discount(BigDecimal s) { return s.multiply(new BigDecimal(pct)).setScale(0, RoundingMode.HALF_UP); }
}
record NoDiscount() implements DiscountPolicy {
    public BigDecimal discount(BigDecimal s) { return BigDecimal.ZERO; }
}
// A policy added later without touching existing code: 15% off, capped at 50,000 VND
record FlatCapped(BigDecimal pct, BigDecimal cap) implements DiscountPolicy {
    public BigDecimal discount(BigDecimal s) { return s.multiply(pct).min(cap).setScale(0, RoundingMode.HALF_UP); }
}
static BigDecimal payable(BigDecimal subtotal, DiscountPolicy p) { return subtotal.subtract(p.discount(subtotal)); }

Results for an order of 487,000 VND:

$ java -cp out Ocp
switch VIP -> discount 48700.00
NoDiscount[] -> payable 487000
Percent[pct=0.10] -> payable 438300
FlatCapped[pct=0.15, cap=50000] -> payable 437000

The FlatCapped policy would discount 15% (73,050 VND) but is capped at 50,000 VND, so the customer pays 437,000 VND. The payable method has no idea this policy exists. This is OCP in SOLID at work, and it is also the essence of the Strategy pattern that later articles in this series come back to. Note that this approach only pays off if the axis of change is real: if the shop has exactly two discount levels and nobody expects more, a three-line switch is still easier to read than three types and an interface.

L in SOLID: Liskov Substitution, inheritance is a behavioral contract

The L in SOLID comes from the formal statement by Liskov and Wing: if a property holds for all objects of type T, it must also hold for all objects of subtype S. In contract terms: a subclass must not demand stricter preconditions, must not promise weaker postconditions, and must preserve the parent's invariants. That is why "a Square is a Rectangle" is true in math but wrong in code design when the setters are independent.

static class Rectangle {
    protected int w, h;
    void setWidth(int w) { this.w = w; }
    void setHeight(int h) { this.h = h; }
    int area() { return w * h; }
}
static class Square extends Rectangle {
    @Override void setWidth(int w) { this.w = w; this.h = w; }
    @Override void setHeight(int h) { this.w = h; this.h = h; }
}
static void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    System.out.println(r.getClass().getSimpleName() + ": expected 20, actual " + r.area());
}

The resize method is written correctly against the Rectangle contract (width and height are independent). Pass in a Square and the result is wrong:

$ java -cp out Lsp
Rectangle: expected 20, actual 20
Square: expected 20, actual 16
Shape: 20, 25
PaymentMethod pays 50000 -> 50000
VoucherPayment pays 50000 -> ERROR: Voucher only applies from 100,000 VND

The fix is to drop the inheritance relationship: use a Shape interface with only area() and two independent records (Rect(5, 4) gives 20, Sq(5) gives 25). The second example is closer to real business logic: VoucherPayment extends PaymentMethod but throws IllegalArgumentException when the amount is below 100,000 VND. That is a strengthened precondition: every place that accepts a PaymentMethod assumes any positive amount is valid, so the payment code blows up exactly when a customer picks a voucher. The way out is to move the condition into the type, for example an EligibleFor interface that is checked up front, instead of throwing a surprise error.

The JDK itself has gray areas: Collection.add is declared an "optional operation" in the Java 17 Javadoc, so an immutable list that throws UnsupportedOperationException still honors the published contract. The key point is that the contract must be written down; a Liskov violation happens when a subclass's behavior drifts from the contract callers are entitled to expect.

I in SOLID: Interface Segregation, do not force clients to depend on what they do not use

Suppose you define an "all-purpose" payment gateway with three methods: charge, refund, and createSubscription. Cash on delivery (COD) has no automatic refunds and no recurring payments, so the COD class is forced to implement two methods by throwing exceptions, a sign of violating both ISP and Liskov.

interface Chargeable { String charge(long amount); }
interface Refundable { String refund(String txId); }
interface Recurring { String createSubscription(String plan); }

static class CodGateway implements Chargeable {
    public String charge(long amount) { return "COD-" + amount; }
}
static class CardGateway implements Chargeable, Refundable, Recurring {
    public String charge(long amount) { return "CARD-" + amount; }
    public String refund(String txId) { return "REFUND-" + txId; }
    public String createSubscription(String plan) { return "SUB-" + plan; }
}
static void checkout(Chargeable g, long amount) { System.out.println("checkout: " + g.charge(amount)); }
static void giveRefund(Refundable g, String tx) { System.out.println("refund: " + g.refund(tx)); }
$ java -cp out Isp
fat interface -> COD has no automatic refunds
checkout: COD-250000
checkout: CARD-250000
refund: REFUND-TX1

The biggest benefit of ISP in SOLID is that errors move from run time to compile time. The line giveRefund(new CodGateway(), "TX1") does not compile because CodGateway is not Refundable, whereas with a fat interface you only find out when a real customer clicks refund. It also makes tests easier: a fake for giveRefund needs to implement just one method.

D in SOLID: Dependency Inversion, high-level logic knows nothing about infrastructure

The D in SOLID, Dependency Inversion, has two halves: high-level and low-level modules both depend on abstractions; and abstractions do not depend on details. In the example, CheckoutService (business logic) knows only the PaymentGateway interface. The concrete implementation (VNPay, Stripe, or a fake in tests) is a detail plugged in from outside.

interface PaymentGateway { boolean charge(String orderId, long amount); }

static class CheckoutService {
    private final PaymentGateway gateway;
    CheckoutService(PaymentGateway gateway) { this.gateway = gateway; }
    String checkout(String orderId, long amount) {
        return gateway.charge(orderId, amount) ? "PAID" : "FAILED";
    }
}
static class FakeGateway implements PaymentGateway {
    final List<String> calls = new ArrayList<>();
    boolean approve = true;
    public boolean charge(String orderId, long amount) { calls.add(orderId + ":" + amount); return approve; }
}
$ java -cp out Dip
PAID calls=[DH-1001:320000]
FAILED calls=[DH-1001:320000, DH-1002:90000]

Two runs, one where the gateway approves and one where it declines, and neither needs a network or a real account. The "wiring" of objects (new CheckoutService(fake)) lives at the application's startup point, usually called the composition root. Spring does exactly this: the Spring Framework 5.3 IoC container creates beans and injects dependencies through constructors. It is worth keeping the terms apart: Dependency Inversion is a design principle, while Dependency Injection is an implementation technique; you can inject dependencies and still depend directly on concrete classes, in which case nothing has been inverted.

How the SOLID principles support each other

The five principles do not stand alone. Splitting responsibilities (S) produces small pieces; narrow interfaces (I) give each piece a compact contract; the pieces plug into each other through abstractions (D); adding variants as new types (O) is only safe when the new types honor the contract (L). The reverse also holds: a class that violates Liskov is often the result of an interface that is too fat, and a switch that violates OCP is often a sign that an abstraction is missing.

Java's major libraries apply SOLID naturally. java.util.Comparator is a one-method interface that lets you extend sorting behavior without modifying Collections.sort (OCP, ISP). JDBC declares Connection and Statement as interfaces so business code is independent of the driver (DIP). The Spring Framework builds its entire bean model around injecting abstractions.

Adopting SOLID gradually in an existing project

In a running codebase, you should not "refactor to SOLID" in one go. A safer approach is to tie each SOLID principle to a concrete event. When fixing a bug means reading more than 500 lines in one class, split the responsibilities (S). When you have to add a third case branch, move that branch into an interface (O). When writing a test forces you to stand up a real database, inject an abstraction (D). This approach follows real needs, and every refactoring step is protected by tests. SOLID then becomes a tool for lowering the cost of change, not a goal in itself. When presenting to a new team, use concrete numbers (files touched per requirement, test run time) to show what SOLID actually improved.

When not to apply SOLID mechanically

SOLID helps most in long-lived code that many people edit, so weigh the cost of SOLID before applying it:

  • Short-lived scripts and prototypes: SOLID is not worth it yet; a one-off task does not need an interface for everything.
  • An axis of change that does not exist yet: creating an abstraction for something that has never changed is over-engineering. Wait for the second requirement before splitting (the "rule of three" for abstraction).
  • Extremely performance-sensitive code: abstraction has a small indirect-call cost; measure with JMH before concluding anything, and do not guess.
  • Small teams and small codebases: six thin classes for a 30-line feature make the code harder for newcomers to read.

Common mistakes

  1. Reading SRP in SOLID as "one class per method": this leads to dozens of thin classes with logic scattered and hard to follow. Measure by reason to change, not by line count.
  2. One-to-one interfaces for classes (FooService and FooServiceImpl) just to "have an interface": this gives no real dependency inversion unless there is a second implementation or a testing need.
  3. Inheriting to reuse code instead of to substitute types: this easily violates Liskov. Prefer composition.
  4. Confusing a DI container with DIP: an interface placed in the same package as the infrastructure implementation and imported back by the business module still inverts nothing; the interface should belong to the high-level module.
  5. Using double for money: the examples above use BigDecimal to avoid rounding errors; do not trade business correctness for a shorter example.

A SOLID checklist for code review

Use the following five questions as a short SOLID checklist in a pull request review:

  • For how many different kinds of requests has this class been edited in the past 6 months?
  • Adding a new variant (promotion type, payment gateway) requires changes in how many places?
  • Is there anywhere that checks instanceof or catches UnsupportedOperationException to "cover" for a subclass?
  • Is a client depending on a method it never calls?
  • Can the business tests run without a network, database, or SMTP?

All the code in this article (Srp, Ocp, Lsp, Isp, Dip) was compiled with javac --release 17 -d out *.java and run with java -cp out <Name> at the Java 17 API level, on a 16 GB RAM machine running macOS. The printed strings are deliberately plain ASCII to avoid terminal encoding problems. Once you have SOLID down, the next article in the series moves from principles to design patterns, starting with the most controversial creational pattern.

Next article

Read next: Singleton pattern in Java: lazy init, holder, enum and the Java Memory Model.