What SOLID is and why interviewers care
SOLID is an acronym for five object-oriented design principles popularised by Robert C. Martin:
| Letter | Principle | One-line meaning |
|---|---|---|
| S | Single responsibility | A class should have one reason to change |
| O | Open/closed | Add behaviour by adding code, not by editing working code |
| L | Liskov substitution | Subtypes must be usable wherever the parent type is expected |
| I | Interface segregation | Clients should not depend on methods they do not use |
| D | Dependency inversion | High-level policy should depend on abstractions, not on low-level details |
The principles share a goal: make change cheap and safe. Software that never changes does not need SOLID. Real software changes every week — new payment methods, new pricing rules, new notification channels — and SOLID is a set of heuristics for arranging code so each change touches as little as possible.
Interviewers rarely ask "define SOLID" on its own. They ask you to review a piece of code, or they add a requirement to your design and watch how many classes you edit. The strong answer names the violation, explains the concrete pain it causes, shows the refactor, and admits the cost of the refactor. This lesson does exactly that for each principle, using one running domain — an online food-ordering service — so you can see how the principles interact.
S — Single responsibility principle
The principle
The single responsibility principle (SRP) says a class should have one reason to change. "Reason to change" usually means one actor — one group of people whose requests could force an edit. The finance team, the operations team and the marketing team are different actors.
SRP does not mean "a class should do one tiny thing" or "have one method". A Money class with ten methods is fine; all of them serve the same purpose.
The violation
import java.util.List;
class OrderService {
double placeOrder(String userEmail, List<Double> itemPrices, String coupon) {
// 1. Pricing rules (owned by the business/pricing team)
double total = 0;
for (double p : itemPrices) total += p;
if ("FEAST50".equals(coupon)) total *= 0.5;
// 2. Persistence (owned by the platform team)
System.out.println("INSERT INTO orders ... total=" + total);
// 3. Notification wording and channel (owned by the marketing team)
System.out.println("EMAIL to " + userEmail + ": Your order of Rs " + total + " is placed!");
return total;
}
}
The problem it causes
- A marketing change to email wording requires editing, re-testing and redeploying the class that also computes money.
- Two developers working on pricing and on email will conflict in the same file.
- You cannot unit-test the pricing rule without also triggering the database and email code.
- The class grows forever: every new feature adds another section.
The refactor
Split along the actors. Each piece gets its own class, and OrderService becomes a short coordinator.
import java.util.List;
class PriceCalculator {
long totalPaise(List<Long> itemPricesPaise, String coupon) {
long total = 0;
for (long p : itemPricesPaise) total += p;
return "FEAST50".equals(coupon) ? total / 2 : total;
}
}
class OrderRepository {
void save(String userEmail, long totalPaise) {
System.out.println("saved order total=" + totalPaise);
}
}
class OrderNotifier {
void orderPlaced(String userEmail, long totalPaise) {
System.out.println("notify " + userEmail + ": order of " + totalPaise + " paise placed");
}
}
class OrderService {
private final PriceCalculator calculator;
private final OrderRepository repository;
private final OrderNotifier notifier;
OrderService(PriceCalculator c, OrderRepository r, OrderNotifier n) {
this.calculator = c;
this.repository = r;
this.notifier = n;
}
long placeOrder(String userEmail, List<Long> pricesPaise, String coupon) {
long total = calculator.totalPaise(pricesPaise, coupon);
repository.save(userEmail, total);
notifier.orderPlaced(userEmail, total);
return total;
}
}
class SrpDemo {
public static void main(String[] args) {
OrderService s = new OrderService(new PriceCalculator(), new OrderRepository(), new OrderNotifier());
System.out.println(s.placeOrder("a@x.in", List.of(20_000L, 15_000L), "FEAST50")); // 17500
}
}
We also switched money from double to integer paise; mixing that fix in is fair because the pricing class now has one job and is easy to correct.
Explaining it in an interview
"The original class had three reasons to change — pricing, storage and messaging — owned by different people. I split it so each change touches one small class, and pricing can be unit-tested without a database. OrderService now only orchestrates the use case."
Common misconception
SRP taken too far produces dozens of one-method classes that are harder to follow than the original. The test is "do these lines change for the same reason, at the same time, because of the same people?" If yes, they belong together even if there are many of them.
O — Open/closed principle
The principle
The open/closed principle (OCP) says software entities should be open for extension but closed for modification. When a new variant arrives, you should be able to add it as new code, leaving tested code untouched.
The violation
Delivery charges differ by delivery type:
class DeliveryChargeCalculator {
long chargePaise(String type, double distanceKm) {
if (type.equals("STANDARD")) {
return 3000 + Math.round(distanceKm * 500);
} else if (type.equals("EXPRESS")) {
return 6000 + Math.round(distanceKm * 800);
} else if (type.equals("PICKUP")) {
return 0;
}
throw new IllegalArgumentException("unknown type " + type);
}
}
The problem it causes
Every new type ("SCHEDULED", "DRONE") means editing this method, re-testing all branches and risking a typo that breaks existing types. In real code the same if-else on type tends to be copied into other places — the estimated-time calculator, the receipt printer — so a new type means hunting for every copy.
The refactor
Put the varying rule behind an interface. Each type becomes a class (this is the Strategy pattern, covered in behavioural patterns).
import java.util.Map;
interface DeliveryCharge {
long chargePaise(double distanceKm);
}
class StandardDelivery implements DeliveryCharge {
public long chargePaise(double km) { return 3000 + Math.round(km * 500); }
}
class ExpressDelivery implements DeliveryCharge {
public long chargePaise(double km) { return 6000 + Math.round(km * 800); }
}
class PickupDelivery implements DeliveryCharge {
public long chargePaise(double km) { return 0; }
}
class OcpDemo {
public static void main(String[] args) {
Map<String, DeliveryCharge> rules = Map.of(
"STANDARD", new StandardDelivery(),
"EXPRESS", new ExpressDelivery(),
"PICKUP", new PickupDelivery());
System.out.println(rules.get("STANDARD").chargePaise(4.0)); // 5000
System.out.println(rules.get("EXPRESS").chargePaise(4.0)); // 9200
}
}
Check the numbers: standard is 3000 + 4 × 500 = 5000 paise; express is 6000 + 4 × 800 = 9200 paise.
Adding drone delivery is now one new class plus one line where the map is built. That registration line is still a modification, and that is normal: OCP aims to confine changes to the wiring code (the place where objects are assembled), not to eliminate every edit.
Explaining it in an interview
"The if-else on type means each new delivery option edits tested code. I moved each rule into its own class behind DeliveryCharge. Now a new option is a new class plus one registration line, and existing rules cannot be broken by it."
Interview tip
OCP is about anticipated variation. Do not wrap every method in an interface "just in case". Say: "Delivery types are explicitly expected to grow, so they get an extension point. Tax rules are fixed by law here, so I keep them as a simple method until they need to vary."
L — Liskov substitution principle
The principle
The Liskov substitution principle (LSP), from Barbara Liskov, says that if code works with a parent type, it must keep working when given any subtype. Put precisely, a subtype must not:
- strengthen preconditions (demand more from callers than the parent did),
- weaken postconditions (promise less than the parent did),
- break invariants the parent guaranteed,
- throw new kinds of exceptions callers do not expect.
The violation
import java.util.List;
class PaymentMethod {
void pay(long amountPaise) {
System.out.println("paid " + amountPaise);
}
void refund(long amountPaise) {
System.out.println("refunded " + amountPaise);
}
}
class GiftVoucher extends PaymentMethod {
@Override
void refund(long amountPaise) {
throw new UnsupportedOperationException("vouchers cannot be refunded");
}
}
class CancellationService {
void cancelAll(List<PaymentMethod> payments, long amountPaise) {
for (PaymentMethod p : payments) {
p.refund(amountPaise); // blows up for vouchers
}
}
}
The problem it causes
CancellationService was written against PaymentMethod's promise that refunds work. A new subclass silently broke that promise. The usual "fix" is if (p instanceof GiftVoucher) continue;, which spreads knowledge of every subtype into callers — the exact thing polymorphism was supposed to avoid — and violates OCP too.
The refactor
Model the capabilities honestly. Not every payment can be refunded, so refundability is a separate interface:
import java.util.List;
interface Payable {
void pay(long amountPaise);
}
interface Refundable {
void refund(long amountPaise);
}
class CardPayment implements Payable, Refundable {
public void pay(long a) { System.out.println("card paid " + a); }
public void refund(long a) { System.out.println("card refunded " + a); }
}
class GiftVoucher implements Payable {
public void pay(long a) { System.out.println("voucher used " + a); }
}
class CancellationService {
void refundAll(List<Refundable> refundables, long amountPaise) {
for (Refundable r : refundables) {
r.refund(amountPaise); // every element genuinely supports refunds
}
}
}
class LspDemo {
public static void main(String[] args) {
new CancellationService().refundAll(List.of(new CardPayment()), 500);
}
}
The type system now prevents the mistake: you cannot put a GiftVoucher into a List<Refundable>.
Other common LSP smells
- A subclass overrides a method with an empty body ("do nothing") because the behaviour does not apply.
- A subclass throws
UnsupportedOperationException. - Callers contain
instanceofchecks on subtypes. - The
Square extends RectangleandPenguin extends Bird(withfly()) examples. See OOP for LLD for the square.
Note that Java's own List.of(...) returns lists whose add throws UnsupportedOperationException. The List interface documents those operations as optional, so it is a deliberate trade-off by the library designers — and a frequent source of surprise, which is the cost LSP warns about.
Explaining it in an interview
"GiftVoucher inherited a refund it cannot honour, so any code relying on PaymentMethod breaks when it meets a voucher. LSP says subtypes must keep the parent's promises. I split capabilities into Payable and Refundable, so only types that can really refund claim to."
I — Interface segregation principle
The principle
The interface segregation principle (ISP) says clients should not be forced to depend on methods they do not use. Prefer several small, role-focused interfaces over one large "fat" interface.
The violation
interface RestaurantPartner {
void acceptOrder(String orderId);
void updateMenu(String item, long pricePaise);
void assignRider(String orderId, String riderId);
void generateTaxReport(int month);
}
// A cloud kitchen app on a tablet only accepts orders and edits its menu.
class KitchenTablet implements RestaurantPartner {
public void acceptOrder(String orderId) { System.out.println("accepted " + orderId); }
public void updateMenu(String item, long pricePaise) { System.out.println("menu " + item); }
public void assignRider(String orderId, String riderId) { throw new UnsupportedOperationException(); }
public void generateTaxReport(int month) { throw new UnsupportedOperationException(); }
}
The problem it causes
- Implementers write stub methods that throw, which are LSP violations waiting to happen.
- Clients that only need order acceptance still recompile and redeploy when the tax-report signature changes, because they depend on the whole interface.
- Test fakes must implement every method, even irrelevant ones.
The refactor
Split by role — by what each client actually needs:
interface OrderReceiver {
void acceptOrder(String orderId);
}
interface MenuEditor {
void updateMenu(String item, long pricePaise);
}
interface RiderDispatcher {
void assignRider(String orderId, String riderId);
}
interface TaxReporter {
void generateTaxReport(int month);
}
class KitchenTablet implements OrderReceiver, MenuEditor {
public void acceptOrder(String orderId) { System.out.println("accepted " + orderId); }
public void updateMenu(String item, long pricePaise) { System.out.println("menu " + item); }
}
class OrderInbox {
private final OrderReceiver receiver; // depends only on what it uses
OrderInbox(OrderReceiver receiver) { this.receiver = receiver; }
void deliver(String orderId) { receiver.acceptOrder(orderId); }
}
A full-service restaurant system can still implement all four. Each client now depends on the narrowest interface it needs, which also makes test fakes one-liners (OrderReceiver fake = id -> {};).
Explaining it in an interview
"The partner interface mixed four roles, so the tablet had to stub out two methods it cannot support. I split it by role. Each client depends only on what it calls, fakes become trivial, and a change to tax reporting no longer touches order intake."
D — Dependency inversion principle
The principle
The dependency inversion principle (DIP) has two parts:
- High-level modules (business policy) should not depend on low-level modules (details like databases, SMS providers). Both should depend on abstractions.
- Abstractions should not depend on details; details should depend on abstractions.
"Inversion" refers to the direction of source-code dependencies. Naively, the order service imports the SMS client. After inversion, the order service owns an interface (Notifier), and the SMS client implements it — so the arrow from the detail now points towards the policy.
Before After
+--------------+ +--------------+ +---------------+
| OrderService | | OrderService |--->| <<interface>> |
+------+-------+ +--------------+ | Notifier |
| depends on +-------^-------+
v | implements
+--------------+ +-------+-------+
| VendorSms | | SmsNotifier |
| Client | | (wraps vendor)|
+--------------+ +---------------+
The violation
class MySqlOrderDao {
void insert(String orderId) { System.out.println("MySQL insert " + orderId); }
}
class SmsClient {
void sendSms(String phone, String text) { System.out.println("SMS " + phone + " " + text); }
}
class OrderService {
private final MySqlOrderDao dao = new MySqlOrderDao(); // concrete, hard-wired
private final SmsClient sms = new SmsClient(); // concrete, hard-wired
void place(String orderId, String phone) {
dao.insert(orderId);
sms.sendSms(phone, "Order " + orderId + " placed");
}
}
The problem it causes
- Unit tests of
OrderServicehit a real database and send real SMS messages. - Switching to PostgreSQL, or adding WhatsApp notifications, means editing the business class.
- The most important code (business policy) is the most fragile, because it breaks whenever any detail changes.
The refactor
import java.util.ArrayList;
import java.util.List;
interface OrderStore {
void save(String orderId);
}
interface Notifier {
void notify(String recipient, String message);
}
class OrderService {
private final OrderStore store;
private final Notifier notifier;
OrderService(OrderStore store, Notifier notifier) {
this.store = store;
this.notifier = notifier;
}
void place(String orderId, String recipient) {
store.save(orderId);
notifier.notify(recipient, "Order " + orderId + " placed");
}
}
// Details live at the edges and implement the abstractions.
class InMemoryOrderStore implements OrderStore {
final List<String> saved = new ArrayList<>();
public void save(String orderId) { saved.add(orderId); }
}
class ConsoleNotifier implements Notifier {
public void notify(String r, String m) { System.out.println("to " + r + ": " + m); }
}
class DipDemo {
public static void main(String[] args) {
InMemoryOrderStore store = new InMemoryOrderStore();
OrderService service = new OrderService(store, new ConsoleNotifier());
service.place("ORD-42", "+91-90000-00000");
System.out.println(store.saved); // [ORD-42]
}
}
DIP vs dependency injection
These are related but different, and interviewers like the distinction:
- Dependency inversion is a design principle about which way dependencies point (towards abstractions).
- Dependency injection is a technique for supplying dependencies from outside (constructor, setter).
- Inversion of control (IoC) is a broader idea: a framework or container calls your code and manages object creation, rather than your code doing it.
You can inject a concrete class (DI without DIP), and you can depend on an interface but locate it via a global registry (DIP without DI). Constructor injection of interfaces gives you both.
Explaining it in an interview
"The order service, our core policy, was welded to MySQL and one SMS vendor. I introduced OrderStore and Notifier interfaces owned by the business layer and passed implementations in through the constructor. Now tests use in-memory fakes, and changing the database or adding WhatsApp is a new class at the edge."
How the five fit together
The principles reinforce each other. Take the delivery-charge refactor:
- Splitting each rule into a class is SRP (each rule changes for its own reason).
- Adding a new rule without editing others is OCP.
- Every
DeliveryChargeimplementation honouring "returns a non-negative charge" is LSP. DeliveryChargehas a single method, which is ISP.- The checkout depends on
DeliveryCharge, not onExpressDelivery, which is DIP.
| Principle | Typical smell | Typical refactor |
|---|---|---|
| SRP | Class with unrelated sections; "and" in its description | Extract classes per actor/reason |
| OCP | if-else / switch on a type code, repeated | Polymorphism, Strategy, registration map |
| LSP | Overrides that throw or do nothing; instanceof in callers | Split capabilities; rethink hierarchy; composition |
| ISP | Implementers stubbing methods; fat interfaces | Role interfaces |
| DIP | new ConcreteDetail() inside business code | Interfaces owned by the policy + constructor injection |
Other principles you should know
DRY — Don't repeat yourself
DRY says every piece of knowledge should have one authoritative place in the code. The key word is knowledge, not text. If the GST rate appears in three files, a rate change needs three edits and someone will miss one.
The trap: two pieces of code that look alike but represent different knowledge. A delivery-charge minimum and a cancellation-fee minimum might both be ₹30 today, but they are owned by different teams and will diverge. Merging them into one constant couples unrelated rules. Duplication of coincidence is fine; duplication of knowledge is not.
KISS — Keep it simple
KISS says prefer the simplest design that meets the requirements. In interviews, simple means fewer classes and layers to read, standard library data structures, and no clever tricks. A HashMap repository beats a hand-rolled persistence framework.
YAGNI — You aren't gonna need it
YAGNI says do not build features or extension points for requirements that do not exist yet. It balances OCP: add an abstraction when variation is stated or very likely, not for every imaginable future. A good interview line: "I will keep this as a concrete class; if a second implementation appears, extracting an interface is a five-minute refactor."
Law of Demeter — "talk only to your friends"
The law of Demeter (principle of least knowledge) says a method should call methods only on: itself, its fields, its parameters, and objects it creates. It should not reach through a chain of other objects.
class Wallet {
private long balancePaise;
Wallet(long b) { balancePaise = b; }
long balancePaise() { return balancePaise; }
void debit(long a) { balancePaise -= a; }
}
class Customer {
private final Wallet wallet;
Customer(Wallet wallet) { this.wallet = wallet; }
Wallet wallet() { return wallet; }
// Demeter-friendly: the customer decides how to pay.
boolean pay(long amountPaise) {
if (wallet.balancePaise() < amountPaise) return false;
wallet.debit(amountPaise);
return true;
}
}
class Checkout {
// Violation: the checkout reaches into the customer's wallet internals.
boolean chargeBad(Customer c, long amount) {
if (c.wallet().balancePaise() < amount) return false;
c.wallet().debit(amount);
return true;
}
// Better: tell, don't ask.
boolean chargeGood(Customer c, long amount) {
return c.pay(amount);
}
}
The violating version couples Checkout to the fact that customers have a wallet. If customers later pay via UPI or a mix of wallet and card, every caller with c.wallet().… breaks. The rule is often summarised as "tell, don't ask": tell an object what to do instead of pulling out its data and deciding for it.
Fluent builders and stream chains (list.stream().filter(...).map(...)) look like violations but are not: each call returns an object of the same conceptual "friend" family, not a stranger's internals.
Composition over inheritance
Covered in depth in OOP for LLD: inheritance couples a subclass to its parent's implementation and is fixed at compile time; composition couples only to an interface and can be swapped at runtime. Many LSP problems disappear when you compose behaviours instead of inheriting them.
How the principles trade off
No principle is free, and interviewers respect candidates who say so.
| Tension | What happens | How to balance |
|---|---|---|
| OCP / DIP vs KISS / YAGNI | Each extension point adds an interface and indirection | Add abstractions only at stated or likely variation points |
| SRP vs readability | Over-splitting scatters one idea across many files | Group by reason to change, not by method count |
| DRY vs decoupling | Merging similar code couples things that should evolve separately | Merge only identical knowledge, not similar-looking code |
| ISP vs discoverability | Many tiny interfaces are hard to find and name | Split by client role, not one method per interface by default |
| Demeter vs class bloat | Adding wrapper methods for every need fattens classes | Wrap behaviour that belongs to the object; plain data objects can expose fields |
| Immutability vs performance | Creating new objects on every change | Usually negligible; optimise only if measured |
A useful rule of thumb in a 90-minute round: apply SOLID fully where the problem statement hints at variation (payment types, pricing rules, vehicle types, notification channels), and keep the rest simple and concrete.
Interview tip
When you skip an abstraction, say so and why: "I am not adding an interface for the ticket ID generator; there is only one way to generate IDs here and YAGNI applies. If we need a distributed ID scheme, I'd extract one then." This shows judgement, which is what SOLID questions really test.
Worked example: reviewing a notification service
Interviewers often hand you code and ask "what would you improve?" Here is a realistic snippet and a structured review.
class NotificationService {
void send(String type, String to, String msg) {
if (type.equals("EMAIL")) {
System.out.println("SMTP connect smtp.example.com");
System.out.println("send email to " + to + ": " + msg);
} else if (type.equals("SMS")) {
if (msg.length() > 160) msg = msg.substring(0, 160);
System.out.println("SMS gateway: " + to + ": " + msg);
} else if (type.equals("PUSH")) {
System.out.println("push to device " + to + ": " + msg);
}
System.out.println("log: sent " + type + " to " + to);
}
}
Step 1 — List the reasons to change (SRP). Email transport, SMS truncation rules, push transport, and logging. Four reasons in one method.
Step 2 — Find the type switch (OCP). Adding WhatsApp means editing this method. An unknown type is silently ignored, which is a bug.
Step 3 — Check dependencies (DIP). Transport details are hard-coded; there is no way to test without real gateways.
Step 4 — Refactor.
import java.util.EnumMap;
import java.util.Map;
enum Channel { EMAIL, SMS, PUSH }
interface ChannelSender {
void send(String to, String message);
}
class EmailSender implements ChannelSender {
public void send(String to, String m) { System.out.println("email " + to + ": " + m); }
}
class SmsSender implements ChannelSender {
static final int MAX = 160;
public void send(String to, String m) {
String body = m.length() > MAX ? m.substring(0, MAX) : m;
System.out.println("sms " + to + ": " + body);
}
}
class PushSender implements ChannelSender {
public void send(String to, String m) { System.out.println("push " + to + ": " + m); }
}
class NotificationService {
private final Map<Channel, ChannelSender> senders;
NotificationService(Map<Channel, ChannelSender> senders) {
this.senders = new EnumMap<>(senders);
}
void send(Channel channel, String to, String message) {
ChannelSender sender = senders.get(channel);
if (sender == null) throw new IllegalArgumentException("no sender for " + channel);
sender.send(to, message);
}
}
class ReviewDemo {
public static void main(String[] args) {
NotificationService svc = new NotificationService(Map.of(
Channel.EMAIL, new EmailSender(),
Channel.SMS, new SmsSender(),
Channel.PUSH, new PushSender()));
svc.send(Channel.SMS, "+91-90000-00000", "Your OTP is 4821");
}
}
Step 5 — State what improved and what it cost. Each channel is testable alone; WhatsApp is a new class and one map entry; an unconfigured channel now fails loudly; the string type became a type-safe enum. The cost is four small classes instead of one method — worthwhile here because channels are an obvious axis of growth. Logging was dropped from the core; in a full answer you would add it as a decorator around ChannelSender (see structural patterns).
The same ideas in Python
Python's duck typing (an object is usable if it has the right methods, regardless of declared type) makes interfaces optional, but the principles still apply. typing.Protocol gives structural interfaces that type checkers verify:
from typing import Protocol
class Notifier(Protocol):
def notify(self, recipient: str, message: str) -> None: ...
class ConsoleNotifier:
def notify(self, recipient: str, message: str) -> None:
print(f"to {recipient}: {message}")
class FakeNotifier:
def __init__(self):
self.sent = []
def notify(self, recipient: str, message: str) -> None:
self.sent.append((recipient, message))
class OrderService:
def __init__(self, notifier: Notifier):
self.notifier = notifier # DIP: depends on the protocol, injected
def place(self, order_id: str, recipient: str) -> None:
self.notifier.notify(recipient, f"Order {order_id} placed")
fake = FakeNotifier()
OrderService(fake).place("ORD-1", "asha")
print(fake.sent) # [('asha', 'Order ORD-1 placed')]
Interview questions
Q1. Explain the single responsibility principle with an example.
A class should have one reason to change, meaning one actor whose requests can force edits. An order service that computes prices, writes to the database and sends emails changes for the pricing team, the platform team and marketing. Splitting it into a calculator, a repository and a notifier lets each change and be tested independently, with the service just coordinating.
Q2. Does SRP mean a class should have only one method?
No. It means all the class's methods serve one purpose and change for the same reason. A Money value object with addition, comparison and formatting methods is fine. Splitting cohesive behaviour into many one-method classes scatters logic and hurts readability.
Q3. How do you apply the open/closed principle without over-engineering?
Identify axes of change the requirements actually mention — payment types, pricing rules, notification channels — and put those behind interfaces with one class per variant. Leave stable logic concrete. New variants then mean new classes plus a registration line, while unrelated code stays simple.
Q4. Give an LSP violation and its fix.
A GiftVoucher subclass of PaymentMethod that throws on refund breaks any code that refunds a list of payment methods. Callers then add instanceof checks, spreading subtype knowledge. The fix is to model capabilities separately — Payable and Refundable interfaces — so only types that can truly refund implement refunds and the compiler prevents misuse.
Q5. What are the precise rules of Liskov substitution?
A subtype must not demand stronger preconditions than the parent, must deliver at least the parent's postconditions, must preserve the parent's invariants, and must not throw exceptions callers of the parent would not expect. Informally: a caller who only knows the parent type must never be surprised by a subtype.
Q6. What is the interface segregation principle, and how does it relate to LSP?
Clients should not depend on methods they do not use, so fat interfaces are split into role interfaces. Fat interfaces force implementers to stub irrelevant methods, often by throwing — which is an LSP violation. Segregating interfaces removes the temptation and makes fakes trivial.
Q7. Explain dependency inversion. How is it different from dependency injection?
DIP says high-level policy and low-level details should both depend on abstractions, with the abstraction owned by the policy side, so source dependencies point inward to business logic. Dependency injection is the technique of passing dependencies in from outside. DIP is about direction; DI is about delivery. Constructor-injecting an interface achieves both.
Q8. What is the law of Demeter? Give an example of violating it.
A method should only call methods on itself, its fields, its parameters and objects it creates. order.getCustomer().getWallet().debit(x) violates it because the caller depends on the customer having a wallet. Better is customer.pay(x), letting the customer decide how — "tell, don't ask".
Q9. When is duplicated code acceptable under DRY?
When the two pieces only look alike by coincidence but represent different knowledge that will evolve independently, such as two fees that happen to share a value today. DRY targets duplicated knowledge, like a tax rate defined in three places. Merging coincidental duplicates couples unrelated rules.
Q10. How do YAGNI and OCP conflict, and how do you resolve it?
OCP pushes you to add extension points; YAGNI warns against building for imagined needs. Resolve it by adding abstractions only at variation points the requirements state or strongly imply, and by keeping other code simple enough that extracting an interface later is cheap. Say this reasoning out loud in the interview.
Q11. Review this: if (shape instanceof Circle) area = ...; else if (shape instanceof Square) .... What principle is broken?
It breaks OCP, because every new shape edits this code, and it typically signals missing polymorphism. The fix is an area() method on a Shape interface implemented by each shape. If you cannot change the shape classes, the Visitor pattern or a type-keyed map of calculators are alternatives.
Q12. How does SOLID help testability?
SRP produces small units with one behaviour to test. DIP and constructor injection let tests pass fakes instead of databases and gateways. ISP keeps fakes small. Together they let you test business rules quickly and deterministically.
Q13. Can following SOLID make code worse?
Yes, when applied mechanically. Too many interfaces with single implementations, layers that only forward calls, and logic split across many tiny classes slow down reading and change. The principles are heuristics for managing change; apply them where change is expected and keep the rest simple.
Key takeaways
- SOLID exists to make change cheap: each change should touch as little tested code as possible.
- SRP: one reason to change, judged by actors, not by method count.
- OCP: put stated axes of variation behind interfaces; new variants become new classes.
- LSP: subtypes must keep the parent's promises; overrides that throw or
instanceofchecks are warning signs. - ISP: split fat interfaces into role interfaces so clients and fakes stay small.
- DIP: business policy owns abstractions; details implement them and are injected.
- DRY targets duplicated knowledge; KISS and YAGNI keep you from over-abstracting; Demeter says "tell, don't ask".
- In interviews, name the violation, the concrete pain, the refactor and its cost.
Next lesson
Continue with UML and class diagrams.

