02. SOLID principles — keep each room doing one sensible job¶
~16 min read. Good classes age well only when responsibilities stay disciplined.
Built on the ELI5 in 00-eli5.md. The room — a class or module with single responsibility — should not secretly become a warehouse.
1) S and O: keep one job, then extend without surgery¶
See. SOLID is not a poem. It is a pressure test for your class design. When change arrives, weak design shouts first.
Start with SRP. Single Responsibility Principle means one reason to change. Not one method. Not one line. One reason.
Bad example first.
class InvoiceService {
void generateInvoice(Order order) { ... }
void saveToDatabase(Invoice invoice) { ... }
void emailInvoice(Invoice invoice) { ... }
void formatAsPdf(Invoice invoice) { ... }
}
Why is this bad? Finance changes invoice rules. DB team changes storage schema. Ops changes email vendor. Three reasons, one class. That room now has kitchen, bathroom, and server rack.
Better split:
class InvoiceCalculator { ... }
interface InvoiceRepository { ... }
interface InvoiceSender { ... }
class PdfInvoiceFormatter { ... }
Now OCP. Open for extension, closed for modification. Simple, no? Add behavior by adding code around stable contracts, not by editing a giant if-else chain each week.
Bad pricing code:
class FareCalculator {
int calculate(String city, int km) {
if (city.equals("Bangalore")) return km * 12;
if (city.equals("Delhi")) return km * 10;
if (city.equals("Mumbai")) return km * 14;
throw new IllegalArgumentException();
}
}
Every new city means touching the same class. Risk rises with every edit.
Better:
interface FarePolicy {
int calculate(int km);
}
class BangaloreFarePolicy implements FarePolicy { ... }
class DelhiFarePolicy implements FarePolicy { ... }
Now a factory or registry can choose policy. The hallway stays stable. New city, new class. Old code sleeps peacefully.
2) L and I: substitutions should work, and clients should stay lean¶
LSP sounds fancy. It is actually practical. If child replaces parent, behavior should still make sense. Do not surprise callers.
Bad inheritance example:
class Bird {
void fly() { ... }
}
class Ostrich extends Bird {
@Override
void fly() {
throw new UnsupportedOperationException();
}
}
See the problem. Caller trusts Bird can fly. Ostrich breaks that promise. This is not substitution. This is betrayal.
Better model:
interface Bird { }
interface FlyingBird extends Bird {
void fly();
}
class Sparrow implements FlyingBird { ... }
class Ostrich implements Bird { ... }
Now the contract matches reality. The floor plan becomes cleaner because relationships are honest.
Next is ISP. Interface Segregation Principle means do not force clients to depend on methods they do not use.
Bad interface:
A junior developer class may not approve budget. A finance system may not deploy. Why force both?
Better split:
interface Coder { void code(); }
interface Deployer { void deploy(); }
interface BudgetApprover { void approveBudget(); }
Now each caller uses a smaller hallway. Smaller contracts change less often. That is the real win.
3) D: depend on abstractions, not concrete wiring¶
Dependency Inversion Principle is very interview-friendly. But do not recite it like a slogan. Show the pain first.
Bad notification code:
class OrderService {
private final SmsSender smsSender = new TwilioSmsSender();
void placeOrder(Order order) {
// save order
smsSender.send(order.userPhone(), "Order placed");
}
}
What is wrong here? OrderService knows too much about vendor choice. Testing becomes awkward. Changing vendor means editing core business code.
Better:
interface MessageSender {
void send(String phone, String text);
}
class OrderService {
private final MessageSender messageSender;
OrderService(MessageSender messageSender) {
this.messageSender = messageSender;
}
}
Now business code depends on abstraction. Infrastructure depends on the same abstraction. That is inversion. The stable policy points inward. The volatile implementation hangs outside.
A quick mental picture helps.
Bad
OrderService ─────→ TwilioSmsSender
Better
OrderService ─────→ MessageSender ←───── TwilioSmsSender
See the room boundary now. Business logic stays drier. External tools become replaceable attachments.
4) Use SOLID as a review checklist, not as religion¶
Many beginners over-apply SOLID. Then they make eight interfaces for three classes. That is also bad.
Use this short review.
- SRP: does this class have one reason to change?
- OCP: can I add one variant without editing core logic?
- LSP: can child truly replace parent?
- ISP: is this interface too fat for one client?
- DIP: does business logic depend on vendor classes?
Worked example with numbers. Suppose payment retries changed three times in two quarters. Email vendor changed once. Receipt format changed twice. If one class handled all three concerns, it changed six times already. That is a smell.
A better split could be this.
┌──────────────────────┐
│ PaymentService │ orchestration
└───────┬───────┬──────┘
│ │
│ ├────────→ RetryPolicy
│ ├────────→ ReceiptFormatter
│ └────────→ PaymentGateway
Now each change lands in one place more often. That is what maintainability looks like. Not slogans. Not posters. Code movement.
Where this lives in the wild¶
- At Amazon, an SDE on checkout keeps tax calculation separate from invoice rendering and payment gateway calls.
- At Zomato, a backend engineer splits restaurant onboarding validation from persistence and partner notifications.
- At PhonePe, a backend engineer depends on
BankConnectorinterfaces instead of bank-specific SDK classes. - At Netflix, a playback engineer keeps device-specific stream selection behind small strategy interfaces.
- At Freshworks, a CRM engineer avoids fat ticket APIs by splitting read, write, and export contracts.
Pause and recall¶
- What does one reason to change actually mean?
- Why is a fat interface dangerous even if it compiles?
- How does DIP make testing cheaper?
- Which principle is violated by
Ostrich extends Bird { fly() }?
Interview Q&A¶
Why SRP not one utility class for all invoice tasks?¶
Because different changes arrive from different stakeholders and timelines. One utility class becomes a change hotspot very quickly. Common wrong answer to avoid: "Utility classes are faster to write, so they are fine."
Why OCP not a growing if-else ladder?¶
Because repeated edits to central logic increase regression risk. Extension classes isolate variation better. Common wrong answer to avoid: "if-else is okay until code becomes
huge."
Why ISP not one big interface for convenience?¶
Because unused methods create fake dependencies and brittle mocks. Clients should pay only for what they use. Common wrong answer to avoid: "More methods in one interface means fewer files."
Why DIP not directly instantiate vendors inside service?¶
Because core policies should not know volatile implementation details. Constructor-injected abstractions keep business code portable. Common wrong answer to avoid: "Dependency inversion is only for frameworks like Spring."
Apply now (5 min)¶
Exercise: Take a PaymentService that validates, charges, emails, and logs. Split it using all five SOLID principles. Write one interface and four concrete classes. Mark which
principle each split improves.
Sketch from memory: Redraw the bad versus better notification dependency diagram. Show where the abstraction sits. Then explain the win in two lines.
Bridge. Once rooms are clean, we need standard ways to create objects without chaos. → 03-creational-patterns.md