The short version
- Single responsibility: a class should have one reason to change. If a pricing change and an email change both edit the same class, it does too much.
- Open/closed: add new behaviour by adding new code, not by editing tested code. Interfaces mark where behaviour will vary.
- Liskov substitution: a child must work anywhere its parent works. A subclass that throws where the parent succeeds breaks every caller that trusted the parent, and Java’s own List.of does exactly this.
- Interface segregation: small interfaces that classes can fully implement, instead of one big one that forces fake methods.
- Dependency inversion: depend on interfaces and receive concrete classes from outside. It’s what makes a class testable with fakes, in milliseconds.
Before we start
SOLID is an acronym for five principles collected by Robert C. Martin in the early 2000s:
| Letter | Principle | In one line |
|---|---|---|
| S | Single responsibility | One class, one reason to change |
| O | Open/closed | Extend by adding code, not by editing it |
| L | Liskov substitution | A child must work wherever its parent works |
| I | Interface segregation | Small interfaces, fully implemented |
| D | Dependency inversion | Depend on interfaces; receive the concrete classes |
They’re not laws. They’re warnings about specific kinds of pain: changes that ripple everywhere, code that can’t be tested, subclasses that surprise their callers. Each section below shows that pain in running code, then the fix, then how to spot the problem in an interview and when it’s fine to bend the rule.
S: Single responsibility
The rule
A class should have one reason to change. “Reason to change” means a person or team who might ask for a change: the pricing team, the marketing team, the database team. If two different teams’ requests both mean editing the same class, it has two responsibilities.
Breaking it
class OrderServiceAllInOne {
final List sent = new ArrayList<>(), saved = new ArrayList<>();
long placeOrder(String user, List itemPrices, String coupon) {
if (itemPrices.isEmpty()) throw new IllegalArgumentException("empty"); // check
long total = 0; // price
for (long p : itemPrices) total += p;
if ("HAPPY10".equals(coupon)) total -= total / 10;
if (total < 199_00) total += 30_00; // fee
saved.add(user + ":" + total); // store
sent.add("SMS to " + user + ": placed, ₹" + total / 100); // message
return total;
}
} Four teams can ask to change this one method: whoever owns validation rules, the pricing team (coupons and fees), the database team, and the marketing team (message wording). Every change risks breaking the other three. And to test “is HAPPY10 applied correctly?”, you have to run validation, save to storage and send an SMS too.
The fix
Split it so each class has one job, and one class coordinates them:
final class PriceCalculator {
long total(List itemPrices, String coupon) {
long total = 0;
for (long p : itemPrices) total += p;
if ("HAPPY10".equals(coupon)) total -= total / 10;
if (total < 199_00) total += 30_00;
return total;
}
}
final class OrderStore {
final List saved = new ArrayList<>();
void save(String user, long total) { saved.add(user + ":" + total); }
}
final class Messenger {
final List sent = new ArrayList<>();
void orderPlaced(String user, long total) {
sent.add("SMS to " + user + ": order placed, ₹" + total / 100);
}
}
final class OrderService {
private final PriceCalculator prices;
private final OrderStore store;
private final Messenger messenger;
OrderService(PriceCalculator p, OrderStore s, Messenger m) {
prices = p; store = s; messenger = m;
}
long placeOrder(String user, List items, String coupon) {
if (items.isEmpty()) throw new IllegalArgumentException("empty");
long total = prices.total(items, coupon);
store.save(user, total);
messenger.orderPlaced(user, total);
return total;
}
}
class SrpDemo {
public static void main(String[] args) {
PriceCalculator prices = new PriceCalculator(); 1
System.out.println("₹180 with HAPPY10: "
+ prices.total(List.of(180_00L), "HAPPY10"));
System.out.println("₹250, no coupon: " + prices.total(List.of(250_00L), null));
OrderStore store = new OrderStore();
Messenger msg = new Messenger();
new OrderService(prices, store, msg)
.placeOrder("asha", List.of(250_00L), "HAPPY10");
System.out.println(store.saved + " " + msg.sent);
}
} - 1tested alone
- The pricing rules can now be tested with no storage and no messaging at all.
₹180 with HAPPY10: 19200
₹250, no coupon: 25000
[asha:22500] [SMS to asha: order placed, ₹225]The first line shows a pricing rule worth testing: ₹180 drops to ₹162 after the coupon, which falls under ₹199, so the ₹30 delivery fee is added. That’s exactly the kind of edge case you want to check in a fast test of PriceCalculator alone.
How to spot it
- A class name ending in Manager, Service or Helper with a dozen unrelated methods.
- The word “and” when you describe the class: “it validates and prices and saves”.
- A test that needs a database just to check arithmetic.
When to bend it
Splitting too far is a mistake too: five classes of three lines each, for logic that never changes separately, is harder to read than one. Split along the lines where changes really come from.
Where it shows up
In the parking lot, ParkingLot deliberately does not allocate spots, price stays or talk to banks; it coordinates classes that do. Its “doesn’t do” column is single responsibility written down.
O: Open/closed
The rule
Open for extension, closed for modification. You should be able to add new behaviour by adding new code, without editing code that’s already written and tested.
Breaking it
class DiscountsByType {
long apply(String type, long total) {
if (type.equals("NONE")) return total;
if (type.equals("PERCENT10")) return total - total / 10;
if (type.equals("FLAT50")) return Math.max(0, total - 50_00);
throw new IllegalArgumentException("unknown discount " + type);
}
}Next week marketing wants a festival discount: 20% off, capped at ₹100. That means editing apply, which means re-testing every existing discount, and if the code is shared across teams, everyone’s build waits on that change. Every new rule repeats the cycle.
The fix
Make each discount a class behind one interface. A new discount is a new class; nothing existing changes.
interface Discount {
long apply(long total);
}
record Percent(int pct) implements Discount {
public long apply(long t) { return t - t * pct / 100; }
}
record Flat(long paise) implements Discount {
public long apply(long t) { return Math.max(0, t - paise); }
}
record FestivalCap(int pct, long maxOff) implements Discount { 1
public long apply(long t) { return t - Math.min(t * pct / 100, maxOff); }
}
class OcpDemo {
public static void main(String[] args) {
long total = 800_00;
List all =
List.of(new Percent(10), new Flat(50_00), new FestivalCap(20, 100_00));
for (Discount d : all) System.out.println(d + " -> " + d.apply(total));
}
} - 1added later
- The festival discount arrived later, as one new record.
PercentandFlatweren’t touched, so their tests didn’t need to run again.
Percent[pct=10] -> 72000
Flat[paise=5000] -> 75000
FestivalCap[pct=20, maxOff=10000] -> 70000How to spot it
- A
switchorifchain on a type or a string that keeps growing. - “Every time we add an X, we have to edit Y, Z and W.”
When to bend it
You can’t make everything open to every change; that’s endless interfaces. Put the extension points where the requirements say change will come (“pricing rules change every quarter”), and nowhere else. The parking lot walkthrough explicitly gives no interface to spots and floors, because nothing suggested they’d vary.
Where it shows up
Every follow-up section in the problem walkthroughs is titled “extended without editing”: weekend pricing as a decorator in the parking lot, gomoku as a new WinRule in tic-tac-toe, a combined limit as a new algorithm in the rate limiter.
L: Liskov substitution
The rule
If code works with a parent type, it must keep working when given any child type. Named after Barbara Liskov, who described it in 1987. A child can do more than its parent promised, never less: no extra exceptions, no stricter inputs, no weaker results.
Breaking it
A wallet app has accounts you can withdraw from. Someone adds fixed deposits as a kind of account:
class Account {
protected long balance;
Account(long balance) { this.balance = balance; }
void withdraw(long paise) {
if (paise > balance) throw new IllegalArgumentException("insufficient funds");
balance -= paise;
}
long balance() { return balance; }
}
class FixedDeposit extends Account {
FixedDeposit(long balance) { super(balance); }
@Override void withdraw(long paise) { 1
throw new UnsupportedOperationException("locked until maturity");
}
}
class LspDemo {
static long payBill(List accounts, long bill) { 2
for (Account a : accounts) {
long take = Math.min(bill, a.balance());
a.withdraw(take);
bill -= take;
if (bill == 0) break;
}
return bill;
}
public static void main(String[] args) {
try {
List accounts =
List.of(new FixedDeposit(10_000_00), new Account(5_000_00));
payBill(accounts, 3_000_00);
} catch (UnsupportedOperationException e) {
System.out.println("bill payment crashed: " + e.getMessage());
}
try {
List tags = List.of("veg", "spicy"); 3
tags.add("new");
} catch (UnsupportedOperationException e) {
System.out.println("List.of(...).add threw UnsupportedOperationException");
}
}
} - 1“is a” account, but…
- 2written for Account
- 3Java’s own example
- A fixed deposit is an account in everyday language, so inheriting seemed natural. But it can’t do something every account promised to do.
payBillwas written and tested againstAccount. It’s correct. It breaks only because a child broke its parent’s promise.- The Java library has the same issue:
List.of(...)returns aListwhoseaddthrows. Code that accepts anyListand adds to it fails on these lists.
bill payment crashed: locked until maturity
List.of(...).add threw UnsupportedOperationExceptionThe fix
Model what each kind of account can actually do. Withdrawing is a capability some accounts have, not something every account promises:
interface Holding {
long balance();
}
interface Withdrawable extends Holding {
void withdraw(long paise);
}
final class SavingsAccount implements Withdrawable {
private long balance;
SavingsAccount(long balance) { this.balance = balance; }
public long balance() { return balance; }
public void withdraw(long paise) {
if (paise > balance) throw new IllegalArgumentException("insufficient funds");
balance -= paise;
}
}
final class Deposit implements Holding { 1
private final long balance;
Deposit(long balance) { this.balance = balance; }
public long balance() { return balance; }
}
class LspFixedDemo {
static long payBill(List from, long bill) { // (2)
for (Withdrawable a : from) {
long take = Math.min(bill, a.balance());
a.withdraw(take);
bill -= take;
}
return bill;
}
public static void main(String[] args) {
SavingsAccount s = new SavingsAccount(5_000_00);
List all = List.of(new Deposit(10_000_00), s);
long worth = all.stream().mapToLong(Holding::balance).sum();
System.out.println("net worth " + worth + ", left to pay "
+ payBill(List.of(s), 3_000_00) + ", savings now " + s.balance());
}
} - 1no withdraw to break
- A deposit has a balance, but no withdraw method, so nobody can call one on it.
payBillasks for exactly what it needs. Passing a deposit is now a compile error, not a crash in production.
net worth 1500000, left to pay 0, savings now 200000How to spot it
- A subclass method that throws
UnsupportedOperationException, or does nothing. if (x instanceof SomeChild)checks before calling a parent’s method.- The classic textbook case:
Square extends Rectangle, where setting the width also changes the height, surprising code that sets them separately.
When to bend it
Rarely. The Java collections bend it on purpose (unmodifiable lists) to keep one List type, and document it heavily. In your own designs, prefer splitting the interface.
Where it shows up
In tic-tac-toe, bots and win rules get a read-only BoardView, not the Board, so nothing can call a method it shouldn’t. And the chess walkthrough explains why canMove on each piece can’t keep its promise once pins exist.
I: Interface segregation
The rule
No class should be forced to implement methods it doesn’t use. Prefer several small interfaces to one big one.
Breaking it
interface RestaurantPartner {
String cook(String dish);
String bookTable(int people);
String deliver(String orderId);
}
class CloudKitchen implements RestaurantPartner { 1
public String cook(String dish) { return "cooking " + dish; }
public String bookTable(int people) { throw new UnsupportedOperationException(); }
public String deliver(String orderId) { throw new UnsupportedOperationException(); }
}- 1no tables, no riders
- A cloud kitchen only cooks. It’s forced to implement table booking and delivery, so it throws, which is an LSP violation caused by an interface that’s too big. Anyone holding a
RestaurantPartnercan’t trustbookTablewithout knowing which class it really is.
The fix
Split the interface by capability. Each class implements exactly what it can do; each caller asks for exactly what it needs.
interface Kitchen { String cook(String dish); }
interface DineIn { String bookTable(int people); }
interface OwnDelivery { String deliver(String orderId); }
final class CloudKitchen2 implements Kitchen {
public String cook(String dish) { return "cloud kitchen cooking " + dish; }
}
final class FamilyRestaurant implements Kitchen, DineIn {
public String cook(String dish) { return "restaurant cooking " + dish; }
public String bookTable(int people) { return "table for " + people; }
}
class IspDemo {
static List lunchRush(List kitchens) { 1
List out = new ArrayList<>();
for (Kitchen k : kitchens) out.add(k.cook("thali"));
return out;
}
public static void main(String[] args) {
FamilyRestaurant r = new FamilyRestaurant();
System.out.println(lunchRush(List.of(new CloudKitchen2(), r)));
System.out.println(r.bookTable(4));
}
} - 1needs only cooking
lunchRushaccepts anything that cooks. It can’t callbookTable, so it can’t hit a missing one.
[cloud kitchen cooking thali, restaurant cooking thali]
table for 4How to spot it
- Methods that throw
UnsupportedOperationExceptionor return dummy values. - Callers that use one method of a ten-method interface.
- Test fakes that must implement many methods just to test one thing.
When to bend it
Don’t split into one-method interfaces for things that always go together. charge and refund on a payment gateway belong in one interface, because every gateway does both.
Where it shows up
BoardView in tic-tac-toe: the read-only half of the board, which is all that bots and win rules are allowed to see.
D: Dependency inversion
The rule
High-level code should depend on interfaces, not on concrete low-level classes. The concrete classes are handed in from outside. “Inversion” because the usual direction (business logic reaches down and creates the database class it needs) is flipped: the business logic declares what it needs, and something outside supplies it.
Breaking it
class SmsGateway { // a real provider in real life
String send(String to, String text) { return "REAL SMS to " + to; }
}
class ReminderServiceHardwired {
private final SmsGateway sms = new SmsGateway(); 1
String remind(String user, java.time.LocalTime now) {
if (now.getHour() < 9) return "too early";
return sms.send(user, "Your table is booked for 8 pm");
}
}- 1creates its own
- The service creates its own SMS gateway and reads the time from a parameter (better than calling
LocalTime.now()inside, but the gateway is still fixed). A test of “don’t send before 9 am” sends a real SMS when it passes the other branch, and there’s no way to see what was sent.
The fix
Depend on interfaces, and receive the implementations through the constructor. This is dependency injection, the usual way to follow the principle. Time is a dependency too.
interface Sms {
String send(String to, String text);
}
final class ReminderService {
private final Sms sms;
private final java.time.Clock clock;
ReminderService(Sms sms, java.time.Clock clock) { 1
this.sms = sms;
this.clock = clock;
}
String remind(String user) {
if (java.time.LocalTime.now(clock).getHour() < 9) return "too early";
return sms.send(user, "Your table is booked for 8 pm");
}
}
class DipDemo {
public static void main(String[] args) {
List outbox = new ArrayList<>();
Sms fake = (to, text) -> { // (2)
outbox.add(to + ": " + text);
return "queued";
};
java.time.ZoneId ist = java.time.ZoneId.of("Asia/Kolkata");
java.time.Clock early = java.time.Clock.fixed(
java.time.LocalDateTime.of(2026, 11, 14, 7, 30).atZone(ist).toInstant(), ist);
java.time.Clock noon = java.time.Clock.fixed(
java.time.LocalDateTime.of(2026, 11, 14, 12, 0).atZone(ist).toInstant(), ist);
System.out.println("7:30 am: " + new ReminderService(fake, early).remind("asha"));
System.out.println("noon: " + new ReminderService(fake, noon).remind("asha"));
System.out.println("outbox: " + outbox);
}
} - 1given, not created
- The service is handed an SMS sender and a clock. In production,
mainpasses the real gateway andClock.systemDefaultZone(). - A test passes a fake that records messages, and fixed clocks. Both cases run in microseconds, deterministically, with no network.
7:30 am: too early
noon: queued
outbox: [asha: Your table is booked for 8 pm]How to spot it
newof an infrastructure class (database, HTTP client, SMS sender) inside business logic.Instant.now(),LocalDate.now()ornew Random()deep inside a class.- Tests that need a network, a database or
Thread.sleep.
When to bend it
Don’t inject everything. Plain values (ArrayList, String, records) and simple helpers with no side effects can be created directly. Inject things that talk to the outside world, things that are slow, and things you’ll want to fake: time, randomness, I/O.
Where it shows up
Every problem walkthrough in this track passes in a clock and a payment processor or dice: the parking lot tests 75-minute stays instantly; movie booking moves a fake clock past a hold’s expiry; snakes and ladders plays scripted dice.
How the five fit together
The principles reinforce each other:
| If you follow | You get | Which makes this easier |
|---|---|---|
| Single responsibility | small classes with one job | interfaces that are naturally small (I) |
| Interface segregation | small interfaces | implementations that keep their promises (L) |
| Liskov substitution | children that are safe to swap in | extension by adding classes (O) |
| Open/closed | extension points behind interfaces | depending on those interfaces (D) |
| Dependency inversion | concrete classes handed in | tests with fakes, and swapping implementations |
And they share one cost: more, smaller pieces. The skill is applying them where the requirements say change is coming, not everywhere.
How this shows up in interviews
Interviewers rarely say “SOLID”. They ask questions whose best answers happen to follow it:
| They ask | They’re checking |
|---|---|
| “How would you add a new discount type?” | Open/closed |
| “How would you test this without a real payment gateway?” | Dependency inversion |
| “What does this class do?” (and your answer has three “and”s) | Single responsibility |
| “Can a guest user do everything a user can?” | Liskov substitution |
| “Does every implementation need all these methods?” | Interface segregation |
Interviewer: Your NotificationService sends SMS, email and push, retries failures, picks templates and records analytics. Anything you’d change?
You: It has at least four reasons to change. I’d keep NotificationService as a coordinator, and give each job its own piece: a Channel interface with SMS, email and push implementations (so a new channel is a new class), a retry wrapper around a channel, a TemplateRenderer, and analytics as a listener. Each is injected, so tests can check “a failed SMS is retried twice” with a fake channel and no real provider.
Stay in the loop
Get new problems and walkthroughs by email
One short email when something new is published: a deep-dive walkthrough, an LLD problem, or a real outage retold. No spam, unsubscribe any time.