Single responsibility, open/closed, Liskov
Part of the free Low-Level Design course on LogicWiz, in the chapter: SOLID, without the slogans. The class-diagram exercises run in the app.
Week 4: The class everyone was afraid to touch
"I added a festival coupon to OrderService, and somehow the order SMS stopped going out."
Arjun, at his Monday code review with Priya
One class, four reasons to change
Here is the class Arjun opened on Monday. It has grown for two years, one small change at a time.
public class OrderService {
private final Database database = new Database();
private final SmsGateway smsGateway = new SmsGateway();
public Order placeOrder(Cart cart, String couponCode) {
long total = 0;
for (CartLine line : cart.lines()) {
total += line.unitPricePaise() * line.quantity();
}
if ("FLAT50".equals(couponCode)) {
total -= 5000;
} else if ("PCT10".equals(couponCode)) {
total -= total / 10;
} else if ("FREEDEL".equals(couponCode)) {
total -= cart.deliveryFeePaise();
}
Order order = new Order(cart.userId(), total);
database.insert("orders", order.toRow());
smsGateway.send(cart.phone(), "Order " + order.id() + " placed");
return order;
}
}
Priya asks one question: who asks you to change this class?
- Marketing asks for new coupons.
- Finance asks for new pricing rules, such as tax.
- The platform team asks to move orders to a new database.
- The growth team asks for new message text.
Four groups means four reasons to change. The single responsibility principle (SRP) says a class should have one reason to change. Robert Martin, who named it, later put it as "one actor": one group of people whose requests change the class. When two groups' changes meet in one method, a change for one group can break the other. That is how a coupon edit broke the SMS.
Arjun asks, "So SRP means each class does one thing? One method each?" No. A class can have many methods. The test is the list of people who would ask for a change. If that list has more than one group, think about splitting.
public class OrderService {
private final PriceCalculator calculator;
private final OrderRepository repository;
private final OrderNotifier notifier;
public OrderService(PriceCalculator calculator, OrderRepository repository, OrderNotifier notifier) {
this.calculator = calculator;
this.repository = repository;
this.notifier = notifier;
}
public Order placeOrder(Cart cart, String couponCode) {
long total = calculator.total(cart, couponCode);
Order order = repository.save(new Order(cart.userId(), total));
notifier.orderPlaced(order);
return order;
}
}
Now OrderService only coordinates. Each helper class has one group of people behind it. Do not split further than that. Twenty one-line classes are as hard to read as one huge class. Split along reasons to change, not along lines of code.
Open for new coupons, closed for edits
The coupon if chain moved into PriceCalculator, but the pain stays. Every new coupon means editing the calculator and testing every old coupon again.
The open/closed principle (OCP) says code should be open for extension and closed for modification. Extension means you can add new behaviour. Closed means you do not edit code that already works. The usual tool is an interface plus polymorphism: each rule is its own class.
public interface DiscountRule {
boolean appliesTo(Cart cart, String couponCode);
long discountPaise(Cart cart, long subtotalPaise);
}
public class PriceCalculator {
private final List<DiscountRule> rules;
public PriceCalculator(List<DiscountRule> rules) {
this.rules = List.copyOf(rules);
}
public long total(Cart cart, String couponCode) {
long subtotal = cart.subtotalPaise();
for (DiscountRule rule : rules) {
if (rule.appliesTo(cart, couponCode)) {
return subtotal - rule.discountPaise(cart, subtotal);
}
}
return subtotal;
}
}
A "buy 2, get 1 free" coupon is now a new class, BuyTwoGetOne implements DiscountRule, added to the list in main. The calculator does not change.
OCP is never complete. Some code must still build the list of rules, so that one line changes. The goal is that the change lands in one predictable place. Also, do not add an interface for a rule that has never varied. Many teams wait for the second or third variation before adding one.
Try it on a real "before" design. The board has one OrderManager doing everything. Split it by reason to change, and put coupons behind an interface.
Liskov: a subtype keeps its parent's promises
Next, Arjun reviews refunds. Every payment method implements one interface.
public interface PaymentMethod {
Receipt pay(long amountPaise);
/** Returns the money to the customer. Works for any captured payment. */
void refund(Receipt receipt);
}
public class CashOnDelivery implements PaymentMethod {
public Receipt pay(long amountPaise) {
return Receipt.pending(amountPaise);
}
public void refund(Receipt receipt) {
throw new UnsupportedOperationException("Cash cannot be refunded online");
}
}
It compiles. The method signatures match. But the order cancellation code loops over orders and calls refund, and it crashes on every cash order.
The Liskov substitution principle (LSP), named after Barbara Liskov, says this. If code works with a type, it must keep working when you hand it any subtype. LSP is about behaviour and contracts, not about signatures. A contract is what a caller is allowed to rely on:
- Preconditions: what must be true before the call. A subtype must not demand more.
- Postconditions: what is true after the call. A subtype must not promise less.
- Invariants: what is always true about the object. A subtype must keep them.
CashOnDelivery promises less than the interface says. The interface promises a refund; the subtype throws an exception the caller was never told about.
A candidate in a mock interview said, "It implements the interface, so it is substitutable." The compiler checks the shape of the methods. It cannot check the promises. A clear sign of a Liskov problem is a caller that checks the type first: if (method instanceof CashOnDelivery).
You may meet the classic example in interviews: a Square that extends Rectangle. Setting the width of a square also changes its height. Code written for a rectangle does not expect that. It is the same lesson: the shapes match, the behaviour does not.
The fix is to stop promising what some methods cannot do. Keep pay in PaymentMethod. Move refund to a smaller interface, RefundablePayment, that only UPI and card implement. Cancellation asks for a RefundablePayment. Cash orders are refunded another way, for example as wallet credit. Now the types tell the truth.
⚠️ Warning: Returning silently from
refundinstead of throwing is worse, not better. The caller believes the money went back when it did not.
How the three fit together
The three principles answer three different questions.
- SRP: where does this code live? With the one group that asks for its changes.
- OCP: how do I add a new case? As a new class behind an interface.
- LSP: is the new class safe to plug in? Only if it keeps every promise of its parent.
In a machine-coding round, the interviewer often says "now add a new coupon type". If your answer is "one new class and one line in main", you have shown all three.
None of these principles is a law. They are questions to ask when code becomes hard to change. If a class has never changed and nobody asks for changes, leave it as it is.
📌 Summary: SRP: one reason to change per class. OCP: add new behaviour as new classes behind an interface. LSP: a subtype keeps its parent's contract, not only its method signatures.
What Arjun learns next
OrderService now takes its helpers through the constructor. Next, Arjun learns why those helpers should be interfaces, and how that lets him test checkout without sending a real SMS.
Check your understanding
- OrderService prices the cart, saves the order and sends the SMS. A teammate says it follows SRP because it has only three methods. What is the best reply?
- Marketing adds a new coupon type every month. Which design follows the open/closed principle?
- PaymentMethod says refund works for any captured payment. WalletPayment.refund throws when the amount is over 2,000 rupees. Does this break Liskov substitution?
- Which line in the cancellation code is the clearest sign of a Liskov problem?