Lesson 5 of 31
SOLID Principles in Java
SOLID is a set of five guidelines for keeping object-oriented designs open to change. They were written for class hierarchies, and Java is a class-based language, so they apply directly. The useful question for each principle is what it protects against and which Java feature supports it. This article takes the principles in order, shows a violation and a fix for each, and then lists what the compiler checks and what remains a design decision.
The running domain is a small order-processing system: orders with line items, discounts, notifications and persistence. It is the kind of domain a low-level design interview hands you. Money is stored as whole cents in a long, so every printed value is exact.
S: Single Responsibility Principle
A class should have one reason to change. If tax rules, storage format and receipt wording all live in one class, a change to any of them touches the same code and risks the others.
The violation: one Order that computes totals, formats a receipt and exports itself.
import java.util.ArrayList;
import java.util.List;
final class Order {
record Item(String name, long priceCents) {}
private final List<Item> items;
Order(List<Item> items) {
this.items = List.copyOf(items);
}
long totalCents() {
long total = 0;
for (Item item : items) {
total += item.priceCents();
}
return total;
}
// presentation concern
String receipt() {
var lines = new ArrayList<String>();
for (Item item : items) {
lines.add(item.name() + ": %d.%02d".formatted(item.priceCents() / 100, item.priceCents() % 100));
}
lines.add("TOTAL: %d.%02d".formatted(totalCents() / 100, totalCents() % 100));
return String.join("\n", lines);
}
// persistence concern
String toCsv() {
var lines = new ArrayList<String>();
for (Item item : items) {
lines.add(item.name() + "," + item.priceCents());
}
return String.join("\n", lines);
}
}
public class Main {
public static void main(String[] args) {
var order = new Order(List.of(new Order.Item("pen", 250), new Order.Item("book", 1299)));
System.out.println(order.receipt());
System.out.println(order.toCsv());
}
}pen: 2.50
book: 12.99
TOTAL: 15.49
pen,250
book,1299This works, but three stakeholders (finance, customer support, the data team) now edit one file. The fix separates the data from each concern. A record is a compact immutable class whose constructor, accessors, equals and toString the compiler generates, which makes small data types cheap to write.
import java.util.ArrayList;
import java.util.List;
record Money(long cents) {
Money {
if (cents < 0) {
throw new IllegalArgumentException("negative money: " + cents);
}
}
Money plus(Money other) {
return new Money(Math.addExact(cents, other.cents));
}
String display() {
return "%d.%02d".formatted(cents / 100, cents % 100);
}
}
record LineItem(String name, Money price) {}
// The entity only knows its own data and invariants.
record Order(List<LineItem> items) {
Order {
items = List.copyOf(items);
}
Money total() {
return items.stream().map(LineItem::price).reduce(new Money(0), Money::plus);
}
}
// Each concern gets its own small class.
final class ReceiptFormatter {
String render(Order order) {
var lines = new ArrayList<String>();
for (LineItem item : order.items()) {
lines.add(item.name() + ": " + item.price().display());
}
lines.add("TOTAL: " + order.total().display());
return String.join("\n", lines);
}
}
final class CsvExporter {
String export(Order order) {
var lines = new ArrayList<String>();
for (LineItem item : order.items()) {
lines.add(item.name() + "," + item.price().cents());
}
return String.join("\n", lines);
}
}
public class Main {
public static void main(String[] args) {
var order = new Order(List.of(
new LineItem("pen", new Money(250)),
new LineItem("book", new Money(1299))));
System.out.println(new ReceiptFormatter().render(order));
System.out.println(new CsvExporter().export(order));
}
}pen: 2.50
book: 12.99
TOTAL: 15.49
pen,250
book,1299A change to the receipt layout now touches only ReceiptFormatter. Do not over-apply this: a class with five methods that all serve one purpose is fine. Split when two methods change for different reasons.
O: Open/Closed Principle
Code should be open for extension and closed for modification: adding a new case should not mean editing tested logic. The classic violation is a growing switch on a type tag inside business logic.
enum DiscountKind { PERCENT, FIXED }
record Discount(DiscountKind kind, long value) {}
public class Main {
static long apply(Discount discount, long total) {
return switch (discount.kind()) {
case PERCENT -> total - total * discount.value() / 100;
case FIXED -> Math.max(0, total - discount.value());
// Adding "free shipping over a threshold" means editing this method,
// and every other switch on DiscountKind in the code base.
};
}
public static void main(String[] args) {
System.out.println(apply(new Discount(DiscountKind.PERCENT, 10), 2000));
System.out.println(apply(new Discount(DiscountKind.FIXED, 100), 2000));
}
}1800
1900The answer depends on whether the set of cases is open or closed, as explained in modeling domains in Java. If the business keeps inventing discounts, or plugins define them, use an interface so a new kind is a new class and the calling code never changes.
import java.util.List;
interface Discount {
long apply(long total);
String label();
}
record Percent(long percent) implements Discount {
public long apply(long total) {
return total - total * percent / 100;
}
public String label() {
return percent + "% off";
}
}
record Fixed(long cents) implements Discount {
public long apply(long total) {
return Math.max(0, total - cents);
}
public String label() {
return cents + " cents off";
}
}
// Added later, in another file, without touching checkout().
record FreeShippingThreshold(long threshold, long shipping) implements Discount {
public long apply(long total) {
return total >= threshold ? total : total + shipping;
}
public String label() {
return "shipping free over " + threshold;
}
}
public class Main {
static long checkout(long total, List<Discount> discounts) {
long current = total;
for (Discount discount : discounts) {
current = discount.apply(current);
}
return current;
}
public static void main(String[] args) {
List<Discount> discounts = List.of(new Percent(10), new Fixed(100), new FreeShippingThreshold(1500, 599));
for (Discount discount : discounts) {
System.out.println("- " + discount.label());
}
System.out.println("final: " + checkout(2000, discounts));
}
}- 10% off
- 100 cents off
- shipping free over 1500
final: 1700The rule of thumb: a closed set is a sealed interface with records and a switch that has no default. Adding a record then makes every such switch fail to compile until it handles the new case, which is the behavior wanted when the compiler should list every place that needs an update. An open set is a plain interface, where new variants arrive without edits. Choose deliberately, and do not use an interface for a set that will never grow.
L: Liskov Substitution Principle
Code that works with a base type must work with any subtype. A subtype that strengthens preconditions or breaks a promise violates the principle. The classic case is a Square that extends Rectangle and breaks the assumption that width and height change independently.
import java.util.List;
class Rectangle {
private int width;
private int height;
void setWidth(int width) {
this.width = width;
}
void setHeight(int height) {
this.height = height;
}
int area() {
return width * height;
}
}
class Square extends Rectangle {
@Override
void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
void setHeight(int height) {
super.setWidth(height);
super.setHeight(height);
}
}
// The fix: no setters, and each shape states only what it can promise.
sealed interface Shape {
int area();
}
record Rect(int width, int height) implements Shape {
public int area() {
return width * height;
}
}
record Sq(int side) implements Shape {
public int area() {
return side * side;
}
}
public class Main {
// Written against Rectangle: "width 5, height 4 gives area 20".
static int resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
return r.area();
}
public static void main(String[] args) {
System.out.println("rectangle: " + resize(new Rectangle()));
System.out.println("square: " + resize(new Square()));
for (Shape shape : List.of(new Rect(5, 4), new Sq(4))) {
System.out.println(shape + " area " + shape.area());
}
}
}rectangle: 20
square: 16
Rect[width=5, height=4] area 20
Sq[side=4] area 16The compiler accepts Square because it checks signatures and not promises. The fix removes the shared mutable contract. Immutable records have no setters to break, and Sq does not claim to be a Rect.
The same failure appears with interfaces: an implementation that satisfies the signature and breaks the documented contract. The fix is to put the contract and the allowed failure in the interface, so a limited backend has to say so.
import java.util.Optional;
import java.util.TreeMap;
interface Storage {
/**
* Contract: if put returns normally, get(key) returns the value.
* A value that cannot be stored is rejected with ValueTooLargeException.
*/
void put(String key, String value);
Optional<String> get(String key);
}
final class ValueTooLargeException extends RuntimeException {
ValueTooLargeException(int max) {
super("value longer than " + max);
}
}
final class MemoryStorage implements Storage {
private final TreeMap<String, String> data = new TreeMap<>();
public void put(String key, String value) {
data.put(key, value);
}
public Optional<String> get(String key) {
return Optional.ofNullable(data.get(key));
}
}
// Violates the contract: silently drops values longer than 5 characters.
final class TruncatingStorage implements Storage {
private final TreeMap<String, String> data = new TreeMap<>();
public void put(String key, String value) {
if (value.length() <= 5) {
data.put(key, value);
}
}
public Optional<String> get(String key) {
return Optional.ofNullable(data.get(key));
}
}
// Honors the contract: refuses loudly.
final class BoundedStorage implements Storage {
private final int max;
private final TreeMap<String, String> data = new TreeMap<>();
BoundedStorage(int max) {
this.max = max;
}
public void put(String key, String value) {
if (value.length() > max) {
throw new ValueTooLargeException(max);
}
data.put(key, value);
}
public Optional<String> get(String key) {
return Optional.ofNullable(data.get(key));
}
}
public class Main {
static boolean honorsContract(Storage storage) {
try {
storage.put("k", "a long value");
} catch (ValueTooLargeException e) {
return true; // a loud refusal is allowed by the contract
}
return storage.get("k").equals(Optional.of("a long value"));
}
public static void main(String[] args) {
System.out.println("memory: " + honorsContract(new MemoryStorage()));
System.out.println("truncating: " + honorsContract(new TruncatingStorage()));
System.out.println("bounded: " + honorsContract(new BoundedStorage(5)));
}
}memory: true
truncating: false
bounded: trueCallers of BoundedStorage now learn about the limit at the call, so no implementation can be substituted in a way that silently breaks them. The general technique is to state preconditions and failure modes where callers can see them (the interface's documentation, a sealed result, a documented exception) so that "behaves like the base" is checkable.
I: Interface Segregation Principle
Clients should not depend on methods they do not use. A wide interface forces every implementor to provide every method and every consumer to see every method.
interface Machine {
String print(String doc);
String scan();
String fax(String doc);
}
final class LegacyPrinter implements Machine {
public String print(String doc) {
return "printed " + doc;
}
public String scan() {
throw new UnsupportedOperationException("a printer cannot scan");
}
public String fax(String doc) {
throw new UnsupportedOperationException("a printer cannot fax");
}
}
public class Main {
public static void main(String[] args) {
Machine machine = new LegacyPrinter();
System.out.println(machine.print("report"));
try {
machine.scan();
} catch (UnsupportedOperationException e) {
System.out.println("failed at run time: " + e.getMessage());
}
}
}printed report
failed at run time: a printer cannot scanTwo methods throw at run time, and nothing in the type says so. The fix is several small interfaces. A class implements only what it can do, and a method asks for only what it needs. A generic method can require several capabilities with an intersection bound, T extends Printer & PageScanner.
interface Printer {
String print(String doc);
}
interface PageScanner {
String scan();
}
final class SimplePrinter implements Printer {
public String print(String doc) {
return "printed " + doc;
}
}
final class OfficeMachine implements Printer, PageScanner {
public String print(String doc) {
return "office printed " + doc;
}
public String scan() {
return "scanned page";
}
}
public class Main {
// Needs both capabilities, and says so in the bound.
static <T extends Printer & PageScanner> String copy(T device) {
return device.print(device.scan());
}
public static void main(String[] args) {
System.out.println(new SimplePrinter().print("report"));
System.out.println(copy(new OfficeMachine()));
}
}printed report
office printed scanned pagePassing a device that lacks a capability is rejected at compile time:
interface Printer {
String print(String doc);
}
interface PageScanner {
String scan();
}
final class SimplePrinter implements Printer {
public String print(String doc) {
return "printed " + doc;
}
}
public class Main {
static <T extends Printer & PageScanner> String copy(T device) {
return device.print(device.scan());
}
public static void main(String[] args) {
copy(new SimplePrinter()); // SimplePrinter does not implement PageScanner
}
}error: method copy in class Main cannot be applied to given types;The JDK follows this style: Readable, Appendable, Closeable and Comparable each declare one capability.
D: Dependency Inversion Principle
High-level policy should depend on abstractions, not on concrete low-level details. The violation is a service that constructs its own collaborators, which makes it impossible to swap or test.
final class SmtpMailer {
void send(String to, String body) {
System.out.println("SMTP -> " + to + ": " + body);
}
}
final class SignupService {
private final SmtpMailer mailer = new SmtpMailer(); // hard-wired concrete dependency
void register(String email) {
mailer.send(email, "welcome");
}
}
public class Main {
public static void main(String[] args) {
new SignupService().register("a@example.com");
}
}SMTP -> a@example.com: welcomeSignupService cannot run without SMTP. The fix is an interface owned by the high-level module, implemented by low-level classes, and passed in through the constructor (constructor injection). Because the interface has one method, a lambda can implement it, which makes a test double a single line. @FunctionalInterface asks the compiler to reject the interface if it ever gains a second abstract method.
import java.util.ArrayList;
import java.util.List;
@FunctionalInterface
interface Mailer {
void send(String to, String body);
}
final class SmtpMailer implements Mailer {
public void send(String to, String body) {
System.out.println("SMTP -> " + to + ": " + body);
}
}
final class ConsoleMailer implements Mailer {
public void send(String to, String body) {
System.out.println("[console] " + to + ": " + body);
}
}
final class SignupService {
private final Mailer mailer;
SignupService(Mailer mailer) {
this.mailer = mailer;
}
void register(String email) {
mailer.send(email, "welcome");
}
}
public class Main {
public static void main(String[] args) {
new SignupService(new SmtpMailer()).register("a@example.com");
new SignupService(new ConsoleMailer()).register("b@example.com");
var sent = new ArrayList<String>();
new SignupService((to, body) -> sent.add(to + " <- " + body)).register("c@example.com");
System.out.println(sent);
}
}SMTP -> a@example.com: welcome
[console] b@example.com: welcome
[c@example.com <- welcome]The arrow of dependency now points from the low-level mailers to the interface the service defines. No framework is involved: the object that builds the program (here main) chooses the implementations. The full treatment, including fakes and clocks, is in testability and dependency injection.
What the language already checks
| Principle | Java support | What remains your job |
|---|---|---|
| Single responsibility | Records, nested classes and packages make small types cheap | Deciding where the seams are |
| Open/closed | Interfaces allow extension by a new class; sealed types with switch give exhaustive handling when the set is closed |
Choosing interface or sealed type per requirement |
| Liskov substitution | @Override checks signatures; final classes and sealed hierarchies limit who can override |
Contracts that signatures do not express (documented behavior, ordering, thread safety) |
| Interface segregation | Small interfaces and intersection bounds are idiomatic | Not designing a wide interface that forces UnsupportedOperationException |
| Dependency inversion | Interfaces, lambdas and constructor injection | Deciding what deserves an interface |
Two observations follow. First, inheritance makes the Liskov violation possible in Java, so prefer composition and immutable records for data, and keep a class final unless it is designed for extension. Second, the compiler enforces signatures, not behavior. A documented contract, the position of the seams and the choice between an open and a closed set are design decisions.
Applying the principles without overdoing it
An interface for every class is a common failure. Introduce an abstraction only when a requirement varies along that seam (a second implementation, or a fake in tests), when a lambda or a method reference would not do, and when the set is open, since a closed set wants a sealed type. To draw the result, see class diagrams and relationships.
Interview points
- State each principle in one sentence and give the violation that the Java example shows.
- Open/closed: an interface for open sets, a sealed interface with a
switchfor closed sets. Name the trade-off. - Liskov: why
Square extends Rectanglefails with mutable setters, and how immutable records avoid it. - Interface segregation: an
UnsupportedOperationExceptionin an implementation is the sign of a wide interface. - Dependency inversion with constructor injection: the service depends on an interface it owns, and a lambda replaces the dependency in a test.
Summary
- SRP: split by reason to change. Entities hold data and invariants. Formatters, exporters and services are small separate classes.
- OCP: use an interface for open sets so new kinds are new classes. Use a sealed interface for closed sets so the compiler lists every
switchto update. - LSP: a subtype must keep the base type's promises. Avoid mutable shared contracts, and state failure modes in the interface.
- ISP: prefer several small interfaces and intersection bounds over one wide interface with
UnsupportedOperationExceptionbodies. - DIP: define the interface in the high-level module and inject implementations through the constructor.
- The compiler enforces signatures, not behavior. Documented contracts, seam placement and the open-versus-closed choice remain design judgment.
- Introduce an abstraction only when a requirement varies along that seam.
