SOLID Design Principles: The Five Rules That Make an Object-Oriented Design Interview-Ready
The SOLID design principles explained with code: single responsibility, open/closed, Liskov substitution, interface segregation, dependency inversion, plus DRY, KISS and YAGNI for low-level design.
You’ve been handed a machine-coding round: "design a parking lot," "build a rate limiter," forty-five minutes, go. You write a class that works — it compiles, the demo prints the right thing — and the interviewer nods, then asks: "okay, now add a second pricing tier." You open your one big class, hunt for the right if, and start surgically editing a method three other things depend on. That flinch you just felt — I don’t want to touch this — is the exact thing SOLID exists to remove.
SOLID is five design principles that turn a machine-coding answer from "it works" into "it’s well-designed." They’re not academic. Each one attacks a specific smell that makes code painful to change, and every interviewer running an LLD round is quietly grading you on whether you reach for them without being asked. By the end of this article you and I will walk all five — the smell that motivates each, the tiny fix, and the one-line reason it pays off — then assemble a small report exporter that obeys every one of them at once.
Let’s start in a kitchen, not an IDE
Picture the difference between cooking dinner in a cramped studio and watching a real restaurant line during a rush. In your studio you do everything at one counter — chop, sear, plate, wash — and the moment you want to change the menu, the whole counter is in your way. On a restaurant line the work is split into stations: prep washes and chops, the cook heats and seasons, plating garnishes and hands the dish to the pass. Nobody’s job overlaps.
Three things make that line hum, and they are SOLID in disguise:
- Each station has one job. When a dish comes back wrong, you know exactly which station to talk to. (That’s Single Responsibility.)
- Stations connect through a standard pass, not a tangle of private hand-offs. Plating doesn’t care who cooked, only that a finished plate arrives. (That’s the abstraction the last three principles lean on.)
- Appliances are swappable. The grill dies mid-service, you slot in a fryer through the same connection, and prep and plating never notice. (That’s Open/Closed and Dependency Inversion.)
A god class is the studio counter: everything within arm’s reach, and everything in the way of everything else. Good object-oriented design is the restaurant line — small stations, standard connectors, swappable appliances. Every principle below is one rule for keeping the line clean.
Here’s the mental shift that makes SOLID click: none of these five rules is about making code run faster or use less memory. They’re all about one thing — letting you change one part without disturbing the others. If a design change forces you to open five files and pray, some SOLID principle is being violated; the letter that’s broken tells you which knife slipped.
Where you already meet this
You’ve used well-SOLID code all day without naming it. Every time you plug a new payment provider into an app through a PaymentGateway interface, that’s Open/Closed — the checkout code didn’t change, a new class appeared. Every database driver you drop onto the classpath so your code talks to Postgres instead of MySQL without a rewrite is Dependency Inversion doing its quiet work. Spring’s whole dependency-injection model — you declare what you need as an interface and the framework hands you a concrete one — is Dependency Inversion turned into a product.
The one worth slowing down on is the humble logging facade. Your code calls logger.info(...) against an interface, and behind it sits Logback, or Log4j, or a no-op stub in tests — swapped by a config line, never a code change. That’s three principles at once: a slim logging interface nobody’s forced to over-implement (Interface Segregation), any backend substitutable for any other (Liskov), and your business code depending on the abstraction while the concrete logger points up at it (Dependency Inversion). SOLID isn’t theory you’ll apply someday; it’s the shape of every library you already trust.
What the five actually are
Before we build, here’s the whole map on one page — the five letters, their full names, and the one-line reminder for each.
Robert Martin bundled these five under the acronym SOLID, but the ideas are older and independent — you can obey some and break others. We’ll take them in order, because each is a self-contained rule with its own smell and its own fix. For every one: I’ll show you the bad snippet first (the smell), then the good one (the fix), then the single sentence that says why it pays off. Watch the same story repeat five times — a change was hard, here’s the seam that makes it easy.
S — Single Responsibility: one reason to change
The Single Responsibility Principle says a class should have one reason to change — one job, one axis of change. The classic smell is the god class that computes, formats, and persists, all in one place.
Here’s the smell. This Invoice knows its business math, knows HTML, and knows the database — three completely different concerns welded together.
public class Invoice {
private final List<Row> rows;
public int total() { // business rule
return rows.stream().mapToInt(Row::amount).sum();
}
public String toHtml() { // presentation
return "<table>…</table>";
}
public void saveToDatabase(Connection db) { // persistence
// JDBC insert…
}
}Why does that hurt? Three different teams touch this one class for three unrelated reasons. A tweak to the HTML risks breaking the SQL; a change to the database schema drags in the person who owns the pricing math. The class has three reasons to change, so every change is a game of "what else did I just break?"
The fix is to give each concern its own class. The data-and-rules stay in Invoice; rendering moves to an InvoiceRenderer; persistence moves to an InvoiceRepository.
public record Invoice(List<Row> rows) {
public int total() { // the only reason Invoice changes
return rows.stream().mapToInt(Row::amount).sum();
}
}
public final class InvoiceRenderer {
public String toHtml(Invoice invoice) { /* presentation only */ return "…"; }
}
public final class InvoiceRepository {
public void save(Invoice invoice) { /* persistence only */ }
}Why it pays off: when the HTML needs to change, you open exactly one file, and nobody who owns the pricing math or the database has to review your diff.
"One responsibility" doesn’t mean "one method" — that’s the trap that shatters a class into a hundred anaemic fragments. The real test is Martin’s phrasing: gather the people who would ask for a change. If the billing team, the design team, and the DBA team would all send requests to the same class, it has three responsibilities. One class, one audience.
O — Open/Closed: extend by adding, not editing
The Open/Closed Principle says software should be open for extension but closed for modification — you should be able to add new behaviour without opening up and editing code that already works. The smell is the growing switch that you crack open every time a new case appears.
public int priceFor(Order order) {
switch (order.customerType()) {
case REGULAR: return order.amount();
case PREMIUM: return (int) (order.amount() * 0.90);
// every new tier means editing this method — and re-testing all of it
default: return order.amount();
}
}Every new customer tier forces you back into priceFor, editing a method that already ships and re-testing the branches you didn’t touch. The class is not closed — it reopens on every requirement.
The fix is to make each behaviour its own class behind a shared interface — a strategy — and let the calculator hold one. Adding a tier now means adding a class, and the calculator never changes.
public interface DiscountPolicy {
int apply(int amount);
}
public final class RegularDiscount implements DiscountPolicy {
public int apply(int amount) { return amount; }
}
public final class PremiumDiscount implements DiscountPolicy {
public int apply(int amount) { return (int) (amount * 0.90); }
}
// A new GoldDiscount is a NEW file — PriceCalculator stays sealed.
public final class PriceCalculator {
public int priceFor(int amount, DiscountPolicy policy) {
return policy.apply(amount);
}
}Why it pays off: new behaviour arrives as a new file the compiler checks in isolation, and the code that already works — and already passed its tests — is never reopened, so it can’t regress.
If that shape looks familiar, it should: Open/Closed is where the classic design patterns come from. Strategy, Decorator, Observer, and the Factory that hands you the right strategy are all just disciplined ways of achieving "open for extension, closed for modification." The pattern is the how; Open/Closed is the why.
L — Liskov Substitution: subtypes must not surprise
The Liskov Substitution Principle says a subtype must be usable anywhere its supertype is expected, without surprising the caller. If code works with a Rectangle, handing it a Square subclass must not quietly break it. The textbook smell is exactly that pair.
public class Rectangle {
protected int width, height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override public void setWidth(int w) { this.width = w; this.height = w; }
@Override public void setHeight(int h) { this.width = h; this.height = h; }
}It compiles, it looks reasonable — "a square is a rectangle," right? But now watch a caller that only knows it holds a Rectangle:
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
// caller reasonably expects 5 * 4 = 20…
r.area(); // returns 16 — Square silently overwrote the widthThe caller did nothing wrong. It relied on a promise Rectangle makes — width and height move independently — and Square broke that promise while wearing Rectangle’s type. That’s a Liskov violation: the "is-a" of everyday language isn’t the "is-substitutable-for" that inheritance demands.
The fix is to stop forcing a false inheritance. Make Shape an interface with the one thing both genuinely share — an area() — and let each type own its own behaviour with no mutable, conflicting setters.
public interface Shape {
int area();
}
public record Rectangle(int width, int height) implements Shape {
public int area() { return width * height; }
}
public record Square(int side) implements Shape {
public int area() { return side * side; }
}Why it pays off: any code written against Shape works with every shape you’ll ever add, because no subtype can secretly weaken a promise the caller is counting on.
The tell for a lurking Liskov violation is an override that removes ability
— a method that throws UnsupportedOperationException, tightens what inputs
it accepts, or silently ignores a call the parent honoured. A Penguin extends Bird whose fly() throws is the same bug as Square: the subtype can’t
stand in for the supertype, so the "is-a" was a lie. Prefer composition over
that inheritance.
I — Interface Segregation: no client forced to depend on what it doesn’t use
The Interface Segregation Principle says no client should be forced to depend on methods it doesn’t use. The smell is the fat interface — one big contract that lumps unrelated capabilities together, so implementers get stuck stubbing out methods that make no sense for them.
public interface Machine {
void print(Document d);
void scan(Document d);
void fax(Document d);
}
public final class SimplePrinter implements Machine {
public void print(Document d) { /* the only thing it can do */ }
public void scan(Document d) { throw new UnsupportedOperationException(); }
public void fax(Document d) { throw new UnsupportedOperationException(); }
}A plain printer can’t scan or fax, but the fat Machine interface forces it to implement all three — so it fills the extras with exceptions. Now every caller holding a Machine thinks it can scan, and finds out at runtime that it can’t. (Notice that’s also a Liskov violation — the two principles often catch the same smell from different angles.)
The fix is to split the fat interface into small role interfaces, one capability each. A class then implements only the roles it can actually fulfil.
public interface Printer { void print(Document d); }
public interface Scanner { void scan(Document d); }
public interface Fax { void fax(Document d); }
public final class SimplePrinter implements Printer {
public void print(Document d) { /* nothing to stub, nothing to throw */ }
}
// A real all-in-one just implements all three:
public final class OfficeMachine implements Printer, Scanner, Fax { /* … */ }Why it pays off: a client that only needs to print depends on Printer alone, so it can never call — or even see — a method that would blow up, and no class is ever forced to stub behaviour it doesn’t have.
D — Dependency Inversion: point the arrow at the abstraction
The Dependency Inversion Principle says high-level code should depend on an abstraction, not on a low-level detail — and the detail should depend on that same abstraction. The smell is the important, high-level class reaching down and new-ing up a concrete, low-level one.
public final class OrderService {
private final MySqlRepository repo = new MySqlRepository(); // a concrete detail
public void place(Order order) {
repo.save(order); // can’t test without a real MySQL; can’t swap the store
}
}OrderService is the valuable part — it holds the business logic. Yet it’s chained to MySQL: you can’t unit-test it without a database, and you can’t move to Postgres or an in-memory store without editing the class. The dependency arrow points the wrong way — from the thing that matters toward a swappable detail.
The fix is to introduce an interface the high-level code owns, depend on that, and have the concrete store implement it. The dependency is injected in, not built inside. Now both OrderService and MySqlRepository point at the same abstraction — the detail’s arrow has been inverted to aim upward.
public interface OrderRepository {
void save(Order order);
}
public final class MySqlRepository implements OrderRepository {
public void save(Order order) { /* JDBC insert */ }
}
public final class OrderService {
private final OrderRepository repo; // an abstraction
public OrderService(OrderRepository repo) { // injected, not new-ed
this.repo = repo;
}
public void place(Order order) { repo.save(order); }
}Why it pays off: OrderService no longer knows or cares what’s behind OrderRepository, so you can hand it a real database in production and a fake in tests, and swap stores without ever reopening the business logic.
"Inversion" names the direction change. Normally a caller depends on the callee it invokes — high-level points down at low-level. Dependency Inversion flips it: both sides depend on an interface that belongs to the high-level module, so the low-level detail is the one that must conform. This is exactly what a dependency-injection framework automates — you declare the interface you need, and it supplies the concrete one at wiring time. It’s the backbone of the concurrency patterns and every testable service you’ll write.
The smell each principle attacks
Five letters, five very specific pains. This is the table to have in your head walking into the round — not the definitions, but the symptom each one cures and what it costs.
| Principle | The smell it kills | The move | What it buys |
|---|---|---|---|
| Single Responsibility | one class changes for three unrelated reasons | split by concern | edits stay local, reviews stay small |
| Open/Closed | a switch/if chain you edit for every new case | a strategy behind an interface | add behaviour without reopening code |
| Liskov Substitution | a subtype that throws or lies about its parent | model the real shared contract | subtypes are safely interchangeable |
| Interface Segregation | implementers stubbing methods with exceptions | split into role interfaces | clients see only what they can call |
| Dependency Inversion | high-level logic new-ing a concrete detail | inject an abstraction the caller owns | testable, swappable, decoupled units |
Notice the family resemblance in the last column: every principle buys the same thing — the freedom to change one part without disturbing the rest. They’re five different seams cut into the code so that change flows down a channel instead of ripping across the whole thing.
DRY, KISS, YAGNI — the three that keep SOLID honest
SOLID travels with three shorter maxims that are less about structure and more about restraint. They’re worth a line each, because they’re the guardrails that stop SOLID from turning into over-engineering.
- DRY — Don’t Repeat Yourself. One piece of knowledge lives in one place. If the same tax rule is copied into three classes, a change means finding all three — and you’ll miss one. But be careful: DRY is about knowledge, not about text that happens to look alike. Two identical lines that mean different things are not a duplication to merge.
- KISS — Keep It Simple. The simplest thing that solves the actual problem beats the clever thing that solves an imagined one. A method a teammate reads once and understands is worth more than a generic framework nobody can follow.
- YAGNI — You Aren’t Gonna Need It. Don’t build the extension point until a second case actually shows up. The best time to introduce a
DiscountPolicyinterface is when the second discount appears — not when you’re speculating that it might.
These three are the counterweight to the five. SOLID pushes you to add seams — interfaces, strategies, injected dependencies — and every seam has a cost in indirection. DRY, KISS, and YAGNI are the brakes: add a seam when a real, present change demands it, not on speculation. A ten-line class with one job needs no strategy interface. Applying all five principles to it is its own smell.
Putting all five together
Here’s a small report exporter that obeys every principle at once — proof they compose rather than fight. Report holds data and its own total (S). Formatter is an interface with interchangeable implementations, so a new format is a new class (O), and every formatter honours the same contract (L). ReportStore is a slim, single-purpose interface (I). And ReportService depends on both abstractions, injected through its constructor (D).
package dev.fiveyear.solid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** S — Report holds data and knows its own total; nothing else. */
record Row(String label, int amount) {}
record Report(String title, List<Row> rows) {
int total() {
return rows.stream().mapToInt(Row::amount).sum();
}
}
/** O + L — any Formatter is swappable; a new format is a new class. */
interface Formatter {
String format(Report report);
}
final class CsvFormatter implements Formatter {
public String format(Report report) {
StringBuilder sb = new StringBuilder("label,amount\n");
for (Row row : report.rows()) {
sb.append(row.label()).append(',').append(row.amount()).append('\n');
}
return sb.append("TOTAL,").append(report.total()).toString();
}
}
final class JsonFormatter implements Formatter {
public String format(Report report) {
return "{\"title\":\"" + report.title() + "\",\"total\":" + report.total() + "}";
}
}
/** I — a slim, single-purpose interface; no client over-depends. */
interface ReportStore {
void save(String name, String content);
}
final class InMemoryStore implements ReportStore {
private final Map<String, String> files = new HashMap<>();
public void save(String name, String content) { files.put(name, content); }
public String read(String name) { return files.get(name); }
}
/** D — depends on the Formatter and ReportStore abstractions, injected in. */
final class ReportService {
private final Formatter formatter;
private final ReportStore store;
ReportService(Formatter formatter, ReportStore store) {
this.formatter = formatter;
this.store = store;
}
void export(Report report, String filename) {
store.save(filename, formatter.format(report));
}
}And here it is running — swapping the formatter at the call site with zero change to ReportService, the payoff of Open/Closed and Dependency Inversion made concrete:
Report q1 = new Report("Q1 Sales", List.of(
new Row("Jan", 100),
new Row("Feb", 140),
new Row("Mar", 120)));
InMemoryStore store = new InMemoryStore();
// CSV today…
new ReportService(new CsvFormatter(), store).export(q1, "q1.csv");
System.out.println(store.read("q1.csv"));
// label,amount
// Jan,100
// Feb,140
// Mar,120
// TOTAL,360
// …JSON tomorrow — same service, a different injected strategy.
new ReportService(new JsonFormatter(), store).export(q1, "q1.json");
System.out.println(store.read("q1.json"));
// {"title":"Q1 Sales","total":360}Every seam earned its place: swapping CSV for JSON touched one word at the call site, and testing ReportService needs no real database because InMemoryStore stands in through the same interface. That’s five principles cashing out as one property — change stays local.
The interview corner
In an LLD round, SOLID is rarely asked by name. It’s tested by a follow-up: "now add X." A strong candidate has already left the seam where X plugs in; a weak one starts editing a method five other things depend on. Here’s how to walk in ready.
Ask these before you write a line:
- "How many kinds of X will there be — one, or is this a family that’ll grow?" One customer tier is a field; a growing family of tiers is a
DiscountPolicyinterface. This decides whether Open/Closed earns its keep or is premature. - "What should be swappable for tests — the database, the clock, the payment gateway?" Whatever must be faked in a unit test is exactly what you should depend on through an interface and inject. This is where Dependency Inversion pays for itself.
- "Do these behaviours really share a contract, or do they just sound similar?" A
Squareand aRectanglesharearea()but not mutable setters. Establishing the real shared contract up front is what keeps you out of the Liskov trap.
The follow-up ladder — where a strong candidate keeps climbing:
- "Add a third report format." Add one
Formatterclass. If the interviewer sees you touchReportServiceat all, you’ve failed Open/Closed — the whole point is that the service never reopens. - "Unit-test the service without a real database." Inject a fake
ReportStore. This is Dependency Inversion’s dividend — the seam you built for flexibility is the same seam that makes it testable, which is why the two goals always travel together. - "This one class does validation, formatting, and saving — is that fine?" Name it: three responsibilities, three reasons to change. Split it, and explain that the split shrinks the blast radius of every future edit. (The god-class smell is the one interviewers plant most often.)
- "Where do these principles come from — aren’t they just design patterns?" Invert it: the design patterns are implementations of these principles. Strategy and Decorator exist to satisfy Open/Closed; the patterns are the vocabulary, SOLID is the grammar. (Both rest on the four pillars in object-oriented programming.)
- "Isn’t this over-engineering a 10-line class?" Yes, and say so out loud — that’s the senior signal. Cite YAGNI: introduce the interface when the second case appears, not on speculation. Knowing when not to apply a principle is the mark of someone who’s felt the cost of indirection.
Three mistakes that fail the round:
- Splitting a class into anaemic fragments in the name of SRP. One responsibility is one reason to change, not one method. A class with a hundred one-line classes around it is as hard to follow as the god class you fled.
- Faking Open/Closed with a bigger
switch. Moving theifchain into aformatType.equals("csv")ladder inside one method isn’t Open/Closed — you still reopen the method for every case. The seam has to be a new type, not a new branch. - Applying all five to everything. Wrapping a trivial value object in an interface, a factory, and an injected dependency is over-engineering, and interviewers read it as inexperience. Reach for a principle when a real, present change demands the seam — not reflexively.
Where to go from here
You now own the core: SRP gives each class one reason to change; OCP adds behaviour by adding a class; LSP keeps subtypes safely interchangeable; ISP slims contracts to what clients use; DIP points dependencies at abstractions — and DRY, KISS, YAGNI keep you from overdoing it. Three natural next stops:
- The patterns that implement these principles. Strategy, Factory, Decorator, Observer, Adapter — each is a named, reusable way to satisfy Open/Closed and Dependency Inversion. Learn them next in design patterns explained, and you’ll recognise them as SOLID with proper nouns.
- The pillars underneath. SOLID assumes fluency with encapsulation, inheritance, polymorphism, and abstraction — the four ideas in object-oriented programming. If Liskov or Dependency Inversion felt slippery, that’s the ground to firm up first.
- SOLID under a real machine-coding prompt. Watch these seams appear in a full design — the strategies in a rate limiter, the injected store in an LRU cache, the role interfaces in an elevator. The principles stop being abstract the moment you see them load-bearing in a system you’d actually be asked to build.
Next time an interviewer says "now add a second pricing tier" and you feel that flinch — you’ll know exactly which letter is missing, and you’ll have left the seam that turns the flinch into a shrug.