SOLID gets most of the attention, but in a design round you will also hear "isn't this a Law of Demeter violation?" or "why not just keep it simple?". These are the other principles that come up again and again.
Two things to keep in mind before reading:
- None of these are laws. They are rules of thumb, and some of them pull in opposite directions.
- Interviewers care less about the definition and more about whether you know when to break the rule. Every section below has a "where people overdo it" part for that reason.
If you have not read the SOLID notes yet, start with SRP and DIP. For the "how tangled is this code" side of things, see Coupling and Cohesion.
1. DRY: Don't Repeat Yourself
Every piece of business knowledge should live in exactly one place.
Note the word knowledge. DRY is not about lines of code that look alike. It is about rules like "GST on restaurant food is 5%" or "a coupon can be used once per user".
When the same rule lives in two places, someone will update one and forget the other.
class CartService {
double totalWithTax(double amount) {
return amount + amount * 0.05;
}
}
class InvoiceService {
double finalAmount(double amount) {
return amount + amount * 0.05;
}
}The rate goes from 5% to 12%. A developer updates CartService only, and now a ₹500 order shows ₹560 in the cart while the invoice says ₹525.
final class Gst {
static final double FOOD_RATE = 0.05;
private Gst() {}
static double addTo(double amount) {
return amount + amount * FOOD_RATE;
}
}Both services call Gst.addTo(amount). One rule, one place. (Real money code should use BigDecimal; double keeps this short.)
When duplication is fine
Two pieces of code can look the same today and still be different knowledge. Swiggy's restaurant coupon check and Instamart's grocery coupon check might both say "min order ₹199, one use per user". They are owned by different teams and will change for different reasons. Merging them couples two teams for no gain.
The usual guide is the rule of three, popularised by Martin Fowler's Refactoring: write it once, copy it the second time (and wince), extract it on the third. By the third copy you know what actually varies.
The wrong abstraction
This is what happens when you extract too early:
boolean isValid(Coupon coupon, Cart cart, boolean isInstamart,
boolean isFirstOrder, boolean skipMinOrder) {
if (!skipMinOrder && cart.total() < coupon.minOrder()) return false;
if (isInstamart && cart.hasRestaurantItems()) return false;
if (isFirstOrder && !coupon.isNewUserOnly()) return false;
return coupon.notExpired();
}Every new case adds a boolean. Nobody can read a call like isValid(c, cart, true, false, true). Sandi Metz put it well: duplication is far cheaper than the wrong abstraction. If you are here, inline the method back into its callers and start again.
Where people overdo it: extracting a helper the moment two lines match, or sharing a DTO (a plain class that only holds data) between two services just because the fields are the same today.
2. KISS: Keep It Simple
Pick the simplest design that solves the problem in front of you.
Simple code is faster to read, easier to debug at 2 am, and has fewer places for bugs to hide.
Say IRCTC lets you cancel online only if the chart is not prepared and the train has not left. Here is the "enterprise" version:
interface CancellationRule { boolean test(Ticket ticket); }
class ChartNotPreparedRule implements CancellationRule { /* ... */ }
class NotDepartedRule implements CancellationRule { /* ... */ }
class CancellationRuleEngine {
private final List<CancellationRule> rules;
CancellationRuleEngine(RuleRegistry registry) {
this.rules = registry.load("cancellation");
}
boolean canCancel(Ticket ticket) {
return rules.stream().allMatch(rule -> rule.test(ticket));
}
}Four classes and a registry for two conditions. The simple version:
boolean canCancelOnline(Ticket ticket, LocalDateTime now) {
return !ticket.isChartPrepared() && now.isBefore(ticket.departureTime());
}If the rules grow to fifteen and the ops team wants to toggle them without a deploy, then a rule engine makes sense. Not before.
Where people overdo it: using "KISS" to defend a 600-line method. Simple is not the same as easy or short. A 600-line method is easy to write and hard to understand. Simple means fewer moving parts and fewer ideas to hold in your head at once.
3. YAGNI: You Aren't Gonna Need It
Do not build features or hooks for needs you only imagine.
Code you write "just in case" still has to be read, tested and maintained. Most of those cases never arrive, and when they do they look different from what you guessed.
class PaymentService {
Payment pay(String payee, BigDecimal amount, Currency currency,
PaymentProvider provider, FxConverter fx, RetryPolicy retry) {
// ...
}
}The product only takes UPI in rupees. There is no second currency and no second provider on the roadmap.
class UpiPaymentService {
Payment pay(String vpa, BigDecimal amountInr) {
// ...
}
}When international cards do come, you will know the real requirements and can design for them then.
Where people overdo it: YAGNI is about speculative features, not about basic quality. You do need input validation, logging and an idempotency key (a unique ID, so a retry cannot charge twice) on a payment API, today. Also, some options are cheap to keep open. Putting the payment gateway behind an interface costs five lines and makes testing easier, so that is not a YAGNI violation.
4. Separation of Concerns
Different kinds of work (HTTP, business rules, storage, notifications) should live in different parts of the code.
That way you can change how orders are stored without touching pricing, and you can test pricing without starting a web server.
@PostMapping("/orders")
ResponseEntity<String> placeOrder(@RequestBody Map<String, Object> body) {
String userId = (String) body.get("userId");
double total = ((Number) body.get("amount")).doubleValue();
if (total > 500) total = total * 0.9;
jdbc.update("INSERT INTO orders(user_id, total) VALUES (?, ?)", userId, total);
smsClient.send(userRepo.phoneOf(userId), "Order placed! Total ₹" + total);
return ResponseEntity.ok("done");
}Parsing, a discount rule, SQL and SMS all in one controller method. Split it:
@PostMapping("/orders")
ResponseEntity<OrderResponse> placeOrder(@RequestBody PlaceOrderRequest request) {
Order order = orderService.place(request.userId(), request.items());
return ResponseEntity.ok(OrderResponse.from(order));
}OrderService handles the rules, PricingService handles discounts, OrderRepository handles SQL, Notifier handles SMS.
Separation of Concerns (SoC) and SRP are close cousins. SRP is about one class having one reason to change. SoC is wider: it covers layers, modules and even services.
Where people overdo it: ten layers for a simple CRUD screen, where each layer just passes the call to the next one. If a layer adds nothing, it is not separating a concern, it is just adding a hop.
5. Law of Demeter
A method should only talk to its close friends, not to strangers it reached through a friend.
More precisely, a method of an object may call methods on:
- the object itself (
this) - its own fields
- its parameters
- objects it creates
It should not call a method on an object that another call handed back.
Every extra hop is one more class you depend on. Here is a long chain of calls, nicknamed a "train wreck":
class DeliveryFeeCalculator {
int feeFor(Order order) {
String city = order.getCustomer().getAddress().getCity();
return city.equals("Bengaluru") ? 40 : 30;
}
}DeliveryFeeCalculator now knows that an order has a customer, a customer has an address, and an address has a city. If Zomato starts delivering to a saved "Work" address instead of the profile address, this calculator breaks even though fees did not change.
The fix is to let Order answer the question, since it knows where it is being delivered:
class Order {
private final Address deliveryAddress;
String deliveryCity() {
return deliveryAddress.getCity();
}
}
class DeliveryFeeCalculator {
int feeFor(Order order) {
return order.deliveryCity().equals("Bengaluru") ? 40 : 30;
}
}Even better, if the calculator only needs the city, pass the city: feeFor(String city). Now it does not know about Order at all.
Where people overdo it: counting dots. orders.stream().filter(...).map(...).toList() has many dots but every call returns the same kind of thing (a stream), so there is no stranger. Same for builders and plain data records. The other trap is adding thirty one-line wrapper methods to Order (customerPhone(), customerPincode(), customerName()...) just to dodge the rule. If you need that many, ask whether the caller should be given a smaller object to begin with.
6. Tell, Don't Ask
Tell an object what to do, instead of pulling its data out, making the decision outside, and pushing the result back in.
The rule stays next to the data it protects. Nobody can debit a wallet without the balance check, because the check lives inside the wallet.
if (wallet.getBalance().compareTo(amount) >= 0) {
wallet.setBalance(wallet.getBalance().subtract(amount));
} else {
throw new InsufficientBalanceException();
}This code is asking. Every caller that debits a wallet has to repeat it, and one of them will forget.
class Wallet {
private BigDecimal balance;
void debit(BigDecimal amount) {
if (balance.compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
balance = balance.subtract(amount);
}
}
wallet.debit(amount);Tell Don't Ask and Law of Demeter often fix the same code. Both push behaviour towards the object that owns the data.
Where people overdo it: reports and screens need to read data, and that is fine. A "monthly spend" page should call getters, not force Wallet to grow a renderMonthlyReport() method. Also keep infrastructure out: wallet.debit() should not send an SMS.
7. Composition over Inheritance
Get behaviour by holding other objects instead of extending classes.
Inheritance trees multiply fast. Uber rides can be Go, Premier or Auto, and pricing can be normal, surge or pool. With inheritance you get SurgeGoRide, PoolPremierRide, SurgePoolAutoRide... one class per combination.
class Ride {
private final VehicleType vehicle;
private final PricingRule pricing;
Ride(VehicleType vehicle, PricingRule pricing) {
this.vehicle = vehicle;
this.pricing = pricing;
}
BigDecimal fare(double km) {
return pricing.fareFor(vehicle, km);
}
}Now vehicle types and pricing rules are two small separate groups, and you can pair any two. This is the Strategy pattern in practice. The full comparison is in Composition vs Inheritance.
Where people overdo it: refusing inheritance even for a real is-a with a stable base class. A small abstract base with shared template code is often the simplest answer.
8. Program to an Interface
Depend on what a thing does (its interface), not on which concrete class does it.
Then you can swap the implementation, and you can pass a fake in tests.
class CheckoutService {
private final RazorpayGateway gateway = new RazorpayGateway();
private final ArrayList<Order> recentOrders = new ArrayList<>();
}class CheckoutService {
private final PaymentGateway gateway;
private final List<Order> recentOrders = new ArrayList<>();
CheckoutService(PaymentGateway gateway) {
this.gateway = gateway;
}
}The gateway is now an interface passed in, and the list is typed List, not ArrayList. Moving from Razorpay to PayU, or testing checkout without a real gateway, no longer means editing CheckoutService. This is DIP in everyday use.
Where people overdo it: an IOrderService plus OrderServiceImpl for every class, each with exactly one implementation forever. Add an interface when you have a second implementation, a real need to swap in a fake during tests, or a boundary with an outside system (gateway, SMS, maps).
9. Fail Fast
When input is wrong, stop right away with a clear error, close to where the problem came in.
A bad value that travels deep fails far from its cause. You get an IndexOutOfBoundsException in PNR generation when the real problem was an empty passenger list three layers up.
class BookingService {
Pnr book(String trainNo, LocalDate date, List<Passenger> passengers) {
Train train = trainRepo.find(trainNo);
SeatAllocation seats = allocator.allocate(train, date, passengers.size());
return pnrGenerator.generate(seats, passengers.get(0));
}
}With an empty list this allocates zero seats, then blows up on get(0). Check at the door instead:
record BookingRequest(String trainNo, LocalDate date, List<Passenger> passengers) {
BookingRequest {
Objects.requireNonNull(trainNo, "trainNo is required");
Objects.requireNonNull(date, "date is required");
if (passengers == null || passengers.isEmpty() || passengers.size() > 6) {
throw new IllegalArgumentException("A ticket needs 1 to 6 passengers");
}
passengers = List.copyOf(passengers);
}
}The block inside the record runs every time one is created. requireNonNull throws if a value is null, and List.copyOf keeps a read-only copy of the list. So a BookingRequest that exists is always valid. More on exceptions in 09-Exceptions.md.
Where people overdo it: failing fast is about one request, not the whole process. If a Kafka consumer gets one bad order event out of 10,000, send that one to a dead letter queue (a side queue for failed messages) and move on. Do not crash the consumer. Also, do not fail on things that are truly optional, like a missing profile photo.
10. Principle of Least Surprise (Least Astonishment)
Code should do what a reasonable reader expects from its name and signature.
Surprises turn into bugs, because people use code based on what it looks like it does.
BigDecimal getTotal() {
if (coupon != null) {
couponService.markUsed(coupon, userId);
return subtotal.subtract(coupon.discount());
}
return subtotal;
}A getter with a side effect. The cart screen calls getTotal() on every refresh, so the second refresh says "coupon already used". The fix:
BigDecimal getTotal() {
return coupon == null ? subtotal : subtotal.subtract(coupon.discount());
}
Order placeOrder() {
if (coupon != null) {
couponService.markUsed(coupon, userId);
}
return orderRepo.save(new Order(userId, getTotal()));
}Getters read. Actions have verb names. Java itself has a famous surprise: List.of(...) returns a List, but calling add on it throws UnsupportedOperationException. Nothing in the type tells you that.
Other common surprises: isValid() that makes a network call, save() that also sends an email, a method that returns null sometimes and an empty list other times.
Where people overdo it: "surprise" depends on who is reading. Follow your team's and framework's conventions, even if you personally find another style more obvious.
Should I abstract this?
A lot of these principles meet at one question: do I pull this into a shared piece or not? This is the check I use.
Quick comparison
| Principle | Question it asks | Smell when broken |
|---|---|---|
| DRY | Does this rule live in one place? | A change needs edits in several files that must stay in sync |
| KISS | Is there a simpler design that works? | Factories, registries and engines around a two-line check |
| YAGNI | Do we need this today? | Parameters, flags or plugins no caller uses |
| Separation of Concerns | Is each kind of work in its own place? | SQL, HTTP parsing and business rules in one method |
| Law of Demeter | Am I only talking to close friends? | a.getB().getC().doSomething() chains |
| Tell, Don't Ask | Who should make this decision? | if (x.getY() > ...) x.setY(...) outside the class |
| Composition over Inheritance | Can I hold this behaviour instead of extending it? | Class explosion like SurgePoolAutoRide |
| Program to an Interface | Am I tied to one concrete class? | new RazorpayGateway() inside business logic |
| Fail Fast | Does bad input stop at the door? | Exceptions deep in the call stack, far from the cause |
| Least Surprise | Does it do what its name says? | Getters with side effects, inconsistent return values |
How they pull against each other
DRY vs KISS. Removing duplication usually means adding a shared function, a parameter, maybe an interface. If the shared version needs flags to cover every caller, you traded a little repetition for a lot of complexity. In that case two simple copies win.
YAGNI vs OCP. OCP says design so new behaviour can be added without editing old code. YAGNI says do not build for changes you only imagine. The usual compromise: write the simple version first. The first time a real change forces you to edit that code (say, a second payment method arrives), refactor to make it open for that kind of change. Do not guess the extension point on day one.
Law of Demeter vs class size. Following it strictly can bloat a class with wrapper methods. If Order needs fifteen delegate methods, the caller probably wants a different object, not more wrappers.
Program to an Interface vs KISS. Interfaces add a file and a jump in the IDE. Worth it at boundaries and when you need a test fake, not for every class.
Fail Fast vs availability. Rejecting a bad request is good. Taking down a whole service because of one bad record is not. Fail fast per unit of work.
When you mention a principle in an interview, pair it with its cost. "I'd extract this, but only once a third use shows up" sounds much more senior than "DRY says extract it".
Interview questions
"What does DRY actually mean? Is all duplicate code a DRY violation?"
DRY is about knowledge, not text. Two blocks that look the same but belong to different features and change for different reasons are not a violation, and merging them often creates the wrong abstraction. The rule of three is a good default: extract on the third real copy.
"What is the Law of Demeter? Give an example of breaking it."
A method should only call methods on itself, its fields, its parameters and objects it creates.
order.getCustomer().getAddress().getCity()breaks it because the caller now depends on Customer and Address too. The fix is something likeorder.deliveryCity(), or passing just the city.
"How is Tell, Don't Ask different from just using getters?"
With getters, the caller pulls data out, decides, and pushes changes back, so the rule lives outside the object and gets copied around. Tell Don't Ask moves the decision into the object, like
wallet.debit(amount)doing its own balance check. Getters are still fine for read-only uses like reports.
"Don't YAGNI and the Open/Closed Principle contradict each other?"
They pull in different directions. YAGNI stops you building extension points for imagined needs, and OCP asks for extension points where change happens. In practice you start simple and make code open for extension the first time a real change hits that spot.
"What is the wrong abstraction and how do you fix it?"
It is shared code that was extracted too early and now needs boolean flags and special cases for each caller. The fix is to inline it back into the callers, let each one keep only what it needs, and extract again later if a real common idea shows up.
"What does fail fast mean, and when is it a bad idea?"
Validate input where it enters and throw a clear error immediately, so problems surface close to their cause. It is a bad idea when one bad record would take down a whole process, like a consumer handling thousands of events. There you reject or park that one record and keep going.
"Give an example of violating the principle of least surprise."
A getter with a side effect, such as
getTotal()marking a coupon as used. Callers expect getters to be safe to call many times, so the second call breaks things. Keep getters pure and put side effects in clearly named actions likeplaceOrder().