SOLID principles in Java are five design rules that keep classes easy to change: Single Responsibility (one reason to change), Open/Closed (extend without editing), Liskov Substitution (subtypes must keep the parent’s promises), Interface Segregation (small, focused interfaces) and Dependency Inversion (depend on abstractions, not concrete classes). Below, every principle is shown the same way: a design that breaks it, real output that proves the damage, then the fixed design.
| Principle | One-line interview answer | Smell that tells you it is broken |
|---|---|---|
| S — Single Responsibility | A class should have one reason to change. | One class mixes rules, formatting and storage. |
| O — Open/Closed | Open for extension, closed for modification. | A growing if/else or switch on a type string. |
| L — Liskov Substitution | Code using a parent must work with any child. | A subclass that overrides to throw, or to change the contract. |
| I — Interface Segregation | Do not force clients to depend on methods they do not use. | Empty or UnsupportedOperationException implementations. |
| D — Dependency Inversion | Depend on abstractions; let the caller supply the concrete class. | new ConcreteRepo() inside business logic. |
Everything here is one file, SolidDemo.java (download), and runs with java SolidDemo.java on JDK 21 or newer. The outputs below are copied from a real run on JDK 25.0.4.1.
Versions used here. JDK 25.0.4.1 (LTS); the code also compiles on JDK 21 (records, sealed interfaces). Principles themselves are language-independent; they were popularised by Robert C. Martin. Checked 22 October 2026.
S — Single Responsibility Principle
Interview one-liner: a class should have one, and only one, reason to change. “Reason” means a stakeholder or concern: the finance team changes tax rules, the UI team changes the format, the DBA changes storage.
Before: one class owns all three concerns.
static class InvoiceBefore {
double total(List<Double> lines) { return lines.stream().mapToDouble(d -> d).sum() * 1.18; }
String printable(List<Double> lines) { return "INVOICE total=" + total(lines); }
void saveToDb(List<Double> lines) { /* JDBC code lives here too */ }
}
After: each concern is its own type, so a new tax rate touches only the calculator.
record InvoiceCalculator(double taxRate) {
double total(List<Double> lines) { return lines.stream().mapToDouble(d -> d).sum() * (1 + taxRate); }
}
static class InvoiceFormatter {
String format(double total) { return String.format("INVOICE total=%.2f", total); }
}
interface InvoiceStore { void save(double total); }
[S] single responsibility
before: INVOICE total=177.0 <- tax rule, format and storage all change this class
after : INVOICE total=177.00 <- a new tax rate touches only InvoiceCalculator
Notice the “before” output even formats badly (177.0); fixing that in the old class would risk the tax and storage code that shares it. That coupling is the real cost. SRP does not mean “one method per class”; split by reason to change, not by size.
O — Open/Closed Principle
Interview one-liner: add new behaviour by adding code, not by editing tested code. The usual tool is polymorphism: an interface plus one class per variation. This is the Strategy pattern.
Before: every new customer type edits the method.
static double discountBefore(String type, double amount) {
if (type.equals("REGULAR")) return amount * 0.0;
else if (type.equals("PREMIUM")) return amount * 0.10;
else throw new IllegalArgumentException("unknown type " + type);
}
After: a new type is a new class. Existing classes are never edited.
interface DiscountPolicy { double discount(double amount); }
record RegularDiscount() implements DiscountPolicy { public double discount(double a) { return 0; } }
record PremiumDiscount() implements DiscountPolicy { public double discount(double a) { return a * 0.10; } }
record StudentDiscount() implements DiscountPolicy { public double discount(double a) { return a * 0.25; } } // added later
[O] open/closed
before: PREMIUM on 200 = 20.0
before: STUDENT fails -> unknown type STUDENT (must edit the method)
after : RegularDiscount on 200 = 0.0
after : PremiumDiscount on 200 = 20.0
after : StudentDiscount on 200 = 50.0
The “before” version cannot handle STUDENT without editing and retesting the method, and the failure only appears at runtime. If the set of types is fixed and you want the compiler to force every switch to handle a new case, a sealed interface is the better tool; see sealed classes and exhaustive switch.
L — Liskov Substitution Principle
Interview one-liner: anywhere you use a parent type, any subtype must work without surprising the caller. Inheritance is about behaviour, not about “is-a” in the real world.
Before: a square “is a” rectangle in geometry, but not in behaviour.
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 int stretch(Rectangle r) { r.setWidth(5); r.setHeight(4); return r.area(); }
After: drop the inheritance and model both as immutable shapes behind a sealed interface.
sealed interface Shape permits Rect, Sq { int area(); }
record Rect(int w, int h) implements Shape { public int area() { return w * h; } }
record Sq(int side) implements Shape { public int area() { return side * side; } }
[L] liskov substitution
Rectangle: width 5, height 4 -> area 20 (expected 20)
Square : width 5, height 4 -> area 16 (expected 20) <- caller's contract broken
after: immutable Rect(5,4).area()=20, Sq(4).area()=16
stretch is correct for a Rectangle, yet fails for a Square that was passed in good faith. Other common violations: a subclass that throws UnsupportedOperationException, tightens a precondition, or returns something the parent’s contract forbids.
I — Interface Segregation Principle
Interview one-liner: many small interfaces beat one fat one; a class should not be forced to implement methods it cannot honour.
Before: a robot must implement eat().
interface FatWorker { void work(); void eat(); }
static class Robot implements FatWorker {
public void work() { /* ok */ }
public void eat() { throw new UnsupportedOperationException("robots do not eat"); }
}
After: split by capability and implement only what applies.
interface Workable { void work(); }
interface Eatable { void eat(); }
static class Human implements Workable, Eatable { public void work() {} public void eat() {} }
static class Robot2 implements Workable { public void work() {} }
[I] interface segregation
before: Robot.eat() -> robots do not eat (forced to implement it)
after : 2 Workable implementations, Robot2 never mentions eating
Notice that ISP violations often end up as LSP violations: the forced eat() throws, so a caller holding a FatWorker can no longer trust it.
D — Dependency Inversion Principle
Interview one-liner: high-level code should depend on an abstraction that it owns, and low-level details should implement that abstraction. Dependency injection is the technique, dependency inversion is the principle.
Before: the service builds its own concrete repository, so it cannot run without the database.
static class OrderServiceBefore {
private final MySqlOrderRepo repo = new MySqlOrderRepo(); // hard-wired concrete class
String place(String o) { return repo.save(o); }
}
After: the service receives an interface; tests pass an in-memory implementation.
interface OrderRepo { String save(String order); }
static class OrderService {
private final OrderRepo repo;
OrderService(OrderRepo repo) { this.repo = repo; } // injected abstraction
String place(String o) { return repo.save(o); }
}
static class InMemoryOrderRepo implements OrderRepo {
final List<String> rows = new ArrayList<>();
public String save(String o) { rows.add(o); return "saved " + o + " (row " + rows.size() + ")"; }
}
[D] dependency inversion
before: can't test without the database -> no database in this demo
after : saved order-1 (row 1)
This is what Spring does for you: you declare the dependency as an interface in the constructor and the container supplies the implementation. Starter walkthrough: Spring Boot tutorial for beginners.
Going deeper. SOLID is the foundation for the GoF patterns. Strategy is Open/Closed in action, Factory Method and Observer lean on Dependency Inversion. Read the series: GoF design patterns in Java, Strategy, Factory Method, Observer and Singleton (which, note, often fights Dependency Inversion). Language basics: OOP concepts in Java.
Common interview questions
1. Explain SOLID in one minute.
One reason to change per class; extend by adding classes, not editing; subtypes must honour the parent’s contract; keep interfaces small; depend on abstractions. Then give one concrete example, such as the discount policy above, because a rehearsed list without an example sounds memorized.2. Why does Square extending Rectangle break Liskov?
The caller assumes width and height change independently.Square couples them, so stretch returns 16 instead of 20 (see the output above). The subtype changed observable behaviour the parent promised.
3. What is the difference between Dependency Inversion, Dependency Injection and IoC?
Inversion is the design rule (depend on abstractions). Injection is passing the dependency in from outside (constructor, setter). Inversion of Control is the broader idea that a framework calls your code and wires objects, which is what the Spring container does.4. How does the Open/Closed Principle relate to the Strategy pattern?
Strategy is the usual implementation: the varying rule hides behind an interface, and new rules are new classes. Spring makes it easy by injecting aList<DiscountPolicy> or a map of strategies.
5. Can you over-apply SOLID?
Yes. An interface with one implementation that will never change, or a class per method, adds indirection without benefit. Apply a principle when you feel the pain (a change that touches many places, a test that needs a database), not in advance.Common mistakes
- Treating SRP as “small classes”. It is about reasons to change, not line counts.
- Creating an interface for every class “for DIP”, even when there will only ever be one implementation.
- Using inheritance for code reuse where composition is right, then breaking Liskov.
- Confusing dependency injection (a technique) with dependency inversion (a design rule).
- Answering with definitions only. Always attach a before-and-after example and say what it cost.
Should you follow SOLID everywhere? Treat it as a set of diagnostic questions, not laws. In a 200-line script, one class is fine. Reach for the principles when requirements start to change in more than one direction.
No Comments yet!