The last three letters of SOLID are about making code safe, lean, and resilient. They're the refactoring targets when you find brittle hierarchies or fat interfaces.
Liskov Substitution Principle (LSP)
Definition: A subclass should be usable anywhere its parent type is expected, without breaking behavior.
Violation trap: A Square extends Rectangle, but Square enforces equal width/height. Now setWidth(5) on a Square sets both, but callers expecting setWidth() to only affect width break.
The Classic Trap
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; }
}
class Square extends Rectangle {
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // enforce square property
}
@Override
public void setHeight(int h) {
this.width = h;
this.height = h; // enforce square property
}
}
// Caller code:
void testArea(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // Fails if r is a Square! area() == 25
}
// When r is a Square, setWidth(5) then setHeight(4) results in a 4x4 square.
// The contract is broken: Rectangle promises setWidth() and setHeight() are independent.
The Fix: Respect the Contract
interface Shape {
int area();
}
class Rectangle implements Shape {
private int width, height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int area() { return width * height; }
}
class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
@Override
public int area() { return side * side; }
}
// Both are Shapes. No setWidth/setHeight nonsense.
// The interface respects the mathematical truth: a square is NOT a rectangle in terms of mutability.
In code review: If a subclass overrides a method to change its contract (e.g., setWidth now sets both width and height), LSP is broken. Fix it with composition or separate interfaces.
Interface Segregation Principle (ISP)
Definition: Clients should not depend on interfaces they don't use. Split fat interfaces into lean, focused ones.
Why it matters: If a class implements a fat interface, it's forced to stub methods it doesn't care about. Tests, contracts, and clarity suffer.
The Fat Interface Problem
interface PaymentProcessor {
boolean charge(double amount);
void refund();
void sendReceipt(String email);
void updateInventory();
void logTransaction();
}
class StripeProcessor implements PaymentProcessor {
@Override
public boolean charge(double amount) { /* ... */ }
@Override
public void refund() { /* ... */ }
@Override
public void sendReceipt(String email) { /* Stripe doesn't send receipts! */ throw new UnsupportedOperationException(); }
@Override
public void updateInventory() { /* Not Stripe's job */ throw new UnsupportedOperationException(); }
@Override
public void logTransaction() { /* Not Stripe's job */ throw new UnsupportedOperationException(); }
}
Stripe shouldn't need to implement sendReceipt or updateInventory. It only cares about charge and refund.
Refactored: Segregated Interfaces
interface ChargeProcessor {
boolean charge(double amount);
}
interface Refundable {
void refund(String transactionId);
}
interface ReceiptSender {
void sendReceipt(String email, String content);
}
interface InventoryManager {
void updateInventory(String itemId, int delta);
}
interface TransactionLogger {
void log(String transactionId, String details);
}
class StripeProcessor implements ChargeProcessor, Refundable {
@Override
public boolean charge(double amount) { /* ... */ }
@Override
public void refund(String transactionId) { /* ... */ }
}
class EmailReceiptSender implements ReceiptSender {
@Override
public void sendReceipt(String email, String content) { /* ... */ }
}
class InventoryService implements InventoryManager {
@Override
public void updateInventory(String itemId, int delta) { /* ... */ }
}
class OrderOrchestrator {
private final ChargeProcessor charger;
private final ReceiptSender receiptSender;
private final InventoryManager inventory;
public OrderOrchestrator(ChargeProcessor charger, ReceiptSender receiptSender, InventoryManager inventory) {
this.charger = charger;
this.receiptSender = receiptSender;
this.inventory = inventory;
}
public void processOrder(Order order) {
charger.charge(order.getAmount());
receiptSender.sendReceipt(order.getEmail(), "...");
inventory.updateInventory(order.getItemId(), -order.getQty());
}
}
Now:
- StripeProcessor only implements what Stripe can do.
- EmailReceiptSender focuses on sending.
- InventoryService owns inventory.
- Each class has a focused responsibility and no fake implementations.
Dependency Inversion Principle (DIP)
Definition: Depend on abstractions, not concretions. High-level modules should not depend on low-level modules; both should depend on abstractions.
Why it matters: If OrderService directly instantiates StripeProcessor and EmailService, it's tightly coupled. Swapping payment gateways or testing with mocks is painful. Invert the dependency: pass them in.
The Coupled Version
class OrderService {
public void processOrder(Order order) {
StripeProcessor stripe = new StripeProcessor(); // concrete dependency
stripe.charge(order.getAmount());
EmailService email = new EmailService(); // concrete dependency
email.send(order.getEmail(), "Thank you!");
Database db = new Database(); // concrete dependency
db.insertOrder(order);
}
}
Problems:
- Hard to test: you can't mock Stripe or the database.
- Hard to swap: switching to PayPal means editing OrderService.
- Tight coupling across layers.
Refactored: Depend on Abstractions
interface PaymentGateway {
boolean charge(double amount);
}
interface EmailService {
void send(String to, String body);
}
interface OrderRepository {
void save(Order order);
}
class OrderService {
private final PaymentGateway paymentGateway;
private final EmailService emailService;
private final OrderRepository orderRepository;
// Inject abstractions, not concretions
public OrderService(PaymentGateway paymentGateway, EmailService emailService, OrderRepository orderRepository) {
this.paymentGateway = paymentGateway;
this.emailService = emailService;
this.orderRepository = orderRepository;
}
public void processOrder(Order order) {
paymentGateway.charge(order.getAmount());
emailService.send(order.getEmail(), "Thank you!");
orderRepository.save(order);
}
}
// Concrete implementations (low-level modules):
class StripeGateway implements PaymentGateway {
@Override
public boolean charge(double amount) { /* call Stripe API */ }
}
class SMTPEmailService implements EmailService {
@Override
public void send(String to, String body) { /* call SMTP */ }
}
class PostgresOrderRepository implements OrderRepository {
@Override
public void save(Order order) { /* insert into DB */ }
}
// Testing:
class MockPaymentGateway implements PaymentGateway {
@Override
public boolean charge(double amount) { return true; } // always succeeds
}
OrderService service = new OrderService(
new MockPaymentGateway(),
new MockEmailService(),
new MockOrderRepository()
);
// Now you test logic without Stripe, SMTP, or Postgres.
DIP Note: On this platform, we inject a Clock interface so tests can set time. Same principle: high-level business logic (the LLD problem) doesn't hard-depend on the system clock; it depends on a Clock abstraction. That's DIP in action.
Bringing It Together
Imagine a ride-sharing LLD:
- LSP: A Vehicle is a Vehicle. A Car's drive() works as expected. Don't have Truck override drive() to charge a toll—that's a different concern.
- ISP: A Driver interface doesn't force methods like payTollFee(). Make it a separate TollPayer interface. A Driver who never drives on tolls doesn't implement it.
- DIP: RideService doesn't instantiate a StripeProcessor. It accepts a PaymentProcessor in its constructor. Tests swap in a mock; production wires Stripe.
In the interview
-
Spot the inheritance smell. If a subclass can't faithfully implement a parent method, something's wrong. Consider whether inheritance is the right relationship.
-
Ask 'Will this ever need mocking?' If yes, pass it as an interface parameter. If a class directly constructs its dependencies, testing is a nightmare.
-
Split interfaces when you stub methods. If you're writing
throw new UnsupportedOperationException(), the interface is too fat. Split it. -
Name the principle when you see it. Interviewer asks about testing: "I'll inject the payment gateway as an interface so tests can mock it. That's Dependency Inversion." They eat that up.