Design principles: KISS, DRY, YAGNI, and beyond
Six timeless principles that guide clean code decisions: KISS, DRY, YAGNI, Law of Demeter, Single Level of Abstraction, and Principle of Least Astonishment.
Six principles. No acronym to unify them. They were discovered independently, named by different people, and yet they all push in the same direction: toward code that is easy to read, change, and delete.
SOLID gets all the attention in interviews. These six principles are just as important and more often violated in production code I actually review. Every senior engineer can spot a YAGNI violation in five seconds. Fewer can articulate exactly why it matters.
| Principle | One-line version |
|---|---|
| KISS | The simplest solution is usually the right one |
| DRY | Every piece of knowledge has one authoritative location |
| YAGNI | Don't build it until you need it |
| Law of Demeter | Only talk to your immediate collaborators |
| Single Level of Abstraction | One method, one level |
| Principle of Least Astonishment | Code should do what its name says |
The running example is an e-commerce order service. All six principles appear in the same codebase so you can see how they interact.
KISS: Keep It Simple, Stupid
Rule
Solve the problem in front of you with the simplest code that works. The best code is code you can delete.
The mistake I see most often is building a framework when you need a function. Here is a real shape of over-engineering I encounter in order service reviews:
// β Three abstract classes, one factory, one registry... for two discount types.
// This is ~150 lines of infrastructure to solve a ~5 line problem.
public abstract class AbstractDiscountStrategyBuilder<T extends AbstractDiscountStrategy> {
protected abstract T build(DiscountConfig config);
}
public class DiscountStrategyRegistry {
private final Map<String, AbstractDiscountStrategyBuilder<?>> registry = new HashMap<>();
public void register(String type, AbstractDiscountStrategyBuilder<?> builder) {
registry.put(type, builder);
}
public AbstractDiscountStrategy resolve(String type, DiscountConfig config) {
AbstractDiscountStrategyBuilder<?> builder = registry.get(type);
if (builder == null) throw new IllegalArgumentException("Unknown discount: " + type);
return builder.build(config);
}
}
// ... and so on for another 80 lines
After KISS:
// β
Two discount types. A switch expression. Done.
// If a third type arrives, add one case. No new classes needed.
public double applyDiscount(Order order, String discountType) {
return switch (discountType) {
case "percentage" -> order.getTotal() * 0.9;
case "flat" -> Math.max(0, order.getTotal() - 10.0);
default -> throw new IllegalArgumentException("Unknown discount: " + discountType);
};
}
The over-engineered version adds a DiscountStrategyRegistry class you have to maintain, test, document, and onboard new engineers on. The KISS version is five lines with one branch. When a third discount type arrives, add one case. When you have five types and the switch is getting unwieldy, then introduce a strategy interface. That is the right moment, not before.
Interview tip: When asked "how would you design a discount system?" don't immediately jump to factory patterns. Ask: "How many discount types exist today? How often do they change?" If the answer is "two, rarely", a switch is the right answer.
DRY: Don't Repeat Yourself
Rule
Every piece of knowledge must have a single, unambiguous, authoritative representation in the system.
DRY is about knowledge duplication, not code duplication. This distinction matters enormously. Here is the violation:
// β Identical validation block copy-pasted between two services.
// When the validation rule changes (add a minimum items check, change amount limit),
// you must update two places. One will be missed. Production will diverge.
public class OrderService {
public void processOrder(Order order) {
if (order == null) throw new IllegalArgumentException("Order cannot be null");
if (order.getItems().isEmpty()) throw new IllegalArgumentException("Order must have items");
if (order.getTotal() <= 0) throw new IllegalArgumentException("Order total must be positive");
if (order.getCustomerId() == null) throw new IllegalArgumentException("Customer ID required");
// ... 26 more lines of the same logic in QuoteService
}
}
public class QuoteService {
public void processQuote(Order order) {
if (order == null) throw new IllegalArgumentException("Order cannot be null");
if (order.getItems().isEmpty()) throw new IllegalArgumentException("Order must have items");
if (order.getTotal() <= 0) throw new IllegalArgumentException("Order total must be positive");
if (order.getCustomerId() == null) throw new IllegalArgumentException("Customer ID required");
// ... same 26 lines
}
}
After DRY:
// β
One authoritative location for order validation knowledge.
// Both services call it. One change propagates everywhere.
public class OrderValidator {
public void validate(Order order) {
if (order == null) throw new IllegalArgumentException("Order cannot be null");
if (order.getItems().isEmpty()) throw new IllegalArgumentException("Order must have items");
if (order.getTotal() <= 0) throw new IllegalArgumentException("Order total must be positive");
if (order.getCustomerId() == null) throw new IllegalArgumentException("Customer ID required");
}
}
The premature DRY trap. Identical code that represents different concepts should NOT be merged. If OrderService.validateOrder() and PaymentService.validatePayment() happen to look the same today but will diverge in 3 sprints (different fields, different rules), merging them now creates accidental coupling. You'll end up splitting them back apart under pressure.
The test: "Is this the same piece of knowledge, or two coincidentally similar pieces of knowledge?" If the former, DRY it. If the latter, keep them separate.
Interview tip: If asked "how do you avoid code duplication?" be specific: "I extract shared validation, shared domain rules, and shared transformations. I don't merge methods that look similar but represent different concepts."
YAGNI: You Aren't Gonna Need It
Rule
Don't implement something until you actually need it. The best architecture is the simplest one that solves today's problem.
I worked with a team that spent two sprints building a PaymentGatewayRegistry with abstract factories and XML config files. Their system processed exactly one payment method: Stripe. They said "when clients want other gateways, we'll be ready." Two years later: still only Stripe. The registry was never used and was deleted in a cleanup sprint.
// β YAGNI violation: 5 classes for a system with one payment method.
// AbstractPaymentGateway, PaymentGatewayFactory, PaymentGatewayRegistry,
// GatewayConfig, GatewayNotFoundException... all for Stripe.
public class PaymentProcessor {
private final PaymentGatewayRegistry registry;
public Receipt charge(Order order, String gatewayId) {
AbstractPaymentGateway gateway = registry.resolve(gatewayId);
return gateway.process(order.getTotal(), order.getCurrency());
}
}
After YAGNI:
// β
You have Stripe. Inject Stripe. Add the interface when you have a second gateway.
public class PaymentProcessor {
private final StripeService stripe;
public PaymentProcessor(StripeService stripe) {
this.stripe = stripe;
}
public Receipt charge(Order order) {
return stripe.charge(order.getTotal(), order.getCurrency());
}
}
When a second payment gateway arrives, then extract PaymentGateway interface, make StripeGateway implement it, create PayPalGateway, wire through the registry. That refactoring takes 30 minutes because the code is small. The speculative infrastructure takes 2 sprints and is wrong anyway because you guessed wrong about the API shape.
YAGNI and SOLID are compatible, not contradictory. YAGNI says: don't add a PaymentGateway interface until you have two gateways. SOLID says: once you have two gateways, use an interface. They agree on the trigger: when you have the second implementation, not before.
Interview tip: "When did you violate YAGNI and regret it?" is a great interview question for senior candidates. Every engineer has a story. Having your story ready shows self-awareness.
Law of Demeter: Talk Only to Your Friends
Rule
A method should only call methods on: its own fields, its parameters, objects it creates, and its direct instance fields. Not on objects retrieved from those objects.
You have probably written this line before:
String city = order.getCustomer().getAddress().getCity();
It compiles. It reads well. It is also a coupling disaster. That single line ties the calling code to the internal structure of Order, Customer, and Address simultaneously. Change any of those classes and this line breaks. Multiply this across 200 call sites and you have a codebase where a simple refactor triggers a week-long change cascade.
The Law of Demeter (LoD) exists to prevent exactly this. It is one of those principles that sounds restrictive at first and then saves you dozens of hours of downstream refactoring.
What Is the Law of Demeter
The Law of Demeter, sometimes called the "Principle of Least Knowledge," boils down to one sentence: only talk to your immediate friends.
An object should only interact with things it directly knows about. It should not reach through one collaborator to pull data from a second collaborator it has never been formally introduced to. Think of it like a workplace rule: you can ask your direct teammate a question, but you should not reach into their desk, pull out their contact book, and call their dentist.
The name comes from the Demeter project at Northeastern University in 1987, where researchers noticed that limiting method call chains dramatically reduced the ripple effects of code changes.
For your interview: LoD reduces coupling by restricting which objects a method can talk to. Fewer dependencies means cheaper change.
The Rule
A method M of class C may only call methods on:
| Allowed Target | Why |
|---|---|
C itself (this) | You always know your own class |
Objects passed as parameters to M | The caller explicitly handed you this collaborator |
Objects that M creates or instantiates | You built it, you own it |
C's direct instance fields | These are your declared dependencies |
That is it. Four targets. No reaching through a return value to call a method on the result.
The forbidden case is the one everyone trips over. When you call this.customer.getAddress(), the returned Address is none of your business. You asked customer a question and used the answer to interrogate a stranger.
Getters are not the problem
LoD does not ban getters. It bans chaining through getters to reach objects you were never directly given. A single this.customer.getName() is fine because customer is a direct field. this.customer.getAddress().getCity() is a violation because Address is not your friend.
Train Wrecks
The colloquial name for LoD violations is "train wrecks," because the dotted chains look like a line of train cars:
// β Train wreck: four dots, three classes you shouldn't know about
String zip = order.getCustomer().getAddress().getZipCode();
// β Another classic: reaching through a service's internals
boolean active = userService.getRepository().findById(id).isActive();
// β Fluent-API-looking code that is actually structural coupling
double total = invoice.getLineItems().get(0).getProduct().getPrice();
Each dot is a coupling point. I count the dots in code reviews as a quick heuristic: three or more dots on a non-builder chain is almost always a violation.
Here is how the coupling spreads visually. The ShippingService needs to know the internal structure of three classes it should never touch:
In the compliant version, ShippingService asks Order one question and gets one answer. It has no idea that Customer and Address even exist. If the Order team later restructures how addresses are stored, ShippingService does not change at all.
Implementation
Here is the full before-and-after. The "before" code reaches through Order into Customer into Address for every operation. The "after" code pushes knowledge to the objects that own it.
/**
* β BEFORE: Order exposes its internal graph freely.
* Callers chain through getCustomer().getAddress().getCity()
* and couple themselves to three classes at once.
*/
public class Order {
private final String id;
private final Customer customer;
private final List<LineItem> items;
public Order(String id, Customer customer, List<LineItem> items) {
this.id = id;
this.customer = customer;
this.items = List.copyOf(items);
}
public String getId() { return id; }
public Customer getCustomer() { return customer; }
public List<LineItem> getItems() { return items; }
}The key change: ShippingService went from knowing three classes (Order, Customer, Address) to knowing one (Order). Order delegates to Customer, Customer delegates to Address. Each object talks to its immediate friend only.
How It Works
This sequence diagram traces a calculateShipping call through both versions. Notice how the "before" version crosses three boundaries while the "after" version stays within one.
In the compliant version, the delegation chain still exists internally. The data still flows through Customer and Address. The difference is that ShippingService does not know about those intermediate steps. Each object forwards the question to its own direct collaborator. If Customer someday stores addresses differently (maybe a list of addresses with a getPrimaryAddress() method), only Customer changes. ShippingService and Order are untouched.
Benefits vs Cost
| Benefit | Cost |
|---|---|
| Lower coupling: callers depend on fewer classes | More wrapper/delegate methods on intermediate classes |
| Easier refactoring: internal structure changes are localized | Risk of "middle man" classes that do nothing but forward calls |
| Better testability: mock one collaborator instead of three | Slightly more verbose class interfaces |
Clearer intent: order.getShippingCity() reads better than order.getCustomer().getAddress().getCity() | Developers must learn when to delegate vs when to expose |
The fundamental tension is coupling vs verbosity. Strict LoD adds delegate methods to intermediate classes. If a class has 20 delegate methods and zero logic of its own, it has become a "middle man" and you should question whether the abstraction layer is worth keeping.
My rule of thumb: if the delegate method adds zero logic (literally return this.x.getY()), that is acceptable up to about 5-6 methods per class. Beyond that, you are probably wrapping too aggressively and should look for a better abstraction boundary.
Interview tip: name the trade-off
When you mention LoD in an interview, always mention the cost. Saying "we should follow LoD" without acknowledging the wrapper-method overhead sounds like textbook recitation. Saying "I follow LoD at service boundaries but relax it inside a single module where the classes change together" sounds like real engineering judgment.
Common Mistakes
Mistake 1: Treating Fluent Builders as Violations
// This is NOT a violation. StringBuilder is a builder, not a data traversal.
String result = new StringBuilder()
.append("Hello")
.append(" ")
.append("World")
.toString();
Fluent APIs and builder patterns return this at each step. You are talking to the same object the entire time, not reaching through a chain of different objects. Same applies to Java Streams, Optional.map().filter().orElse(), and similar functional chains.
Mistake 2: Over-Applying LoD Inside a Module
If Order, Customer, and Address live in the same bounded context and always change together, wrapping every field access in a delegate method adds ceremony without reducing real coupling. LoD pays the biggest dividends at module or service boundaries where classes are maintained by different teams.
I have seen teams add 40+ delegate methods to a class just to satisfy a static analysis rule, turning a clean domain model into a bureaucratic forwarding station. That is worse than the original train wreck.
Mistake 3: Creating "Middle Man" Classes
// β CustomerFacade does nothing but forward calls. It owns zero logic.
public class CustomerFacade {
private final Customer customer;
public String getName() { return customer.getName(); }
public String getCity() { return customer.getAddress().getCity(); }
public String getState() { return customer.getAddress().getState(); }
public String getCountry() { return customer.getAddress().getCountry(); }
public String getZip() { return customer.getAddress().getZipCode(); }
public String getStreet() { return customer.getAddress().getStreet(); }
public String getEmail() { return customer.getEmail(); }
}
If a class exists only to delegate and adds no behavior, decisions, or invariant checks, it is a symptom of mechanical LoD application. Better to push the delegate methods into Customer itself (as shown in the "after" code above) so the forwarding layer has a reason to exist.
Mistake 4: Confusing Data Structures with Objects
Plain data transfer objects (DTOs) and records exist to expose their fields. Applying LoD to a record that is explicitly a data carrier creates unnecessary indirection. LoD applies to objects with behavior, not to bags of data.
// This is a data record. Accessing its fields is fine.
public record ShippingLabel(String name, String street, String city, String state, String zip) {}
// Calling label.city() is not a violation. ShippingLabel is data, not behavior.
When to Apply Strictly vs When to Relax
The honest answer: LoD is most valuable at architectural boundaries and least valuable inside tightly cohesive modules. Apply it where change is expensive, relax it where the classes evolve as a unit.
Real-World Examples
Spring Framework's RestTemplate / WebClient: Spring deliberately hides the HTTP client internals behind a fluent API. You never call restTemplate.getHttpClient().getConnectionManager().getRoute(). Spring exposes what you need (exchange, retrieve, body) and hides everything else. That is LoD at the library level.
JPA/Hibernate lazy loading traps: Entity relationship chains like order.getCustomer().getAddress().getCity() in JPA trigger N+1 queries when lazy-loaded. LoD compliance via delegate methods often eliminates these chains and forces developers to think about what data they actually need, which leads to better fetch strategies.
Google's internal style guides recommend against "deeply chained method calls on returned objects" in both Java and C++. Their reasoning: each dot is a place where a mock is needed in tests, meaning each dot is a coupling point and a maintenance cost.
The second flow has more hops internally but the external coupling is far lower. OrderService has one dependency. That is the trade-off LoD asks you to make.
Interview tip: When drawing class diagrams in interviews, count your coupling arrows. If a service has 8 direct dependencies, it likely has LoD violations hidden inside methods.
Single Level of Abstraction
Rule
Every statement in a method should be at the same level of abstraction. Don't mix high-level orchestration with low-level implementation.
A method that mixes levels is exhausting to read:
// β SLA violation: this method mixes 4 abstraction levels.
// Level 1 (high): "process this order"
// Level 2 (medium): "validate the customer"
// Level 3 (low): "build a SQL query"
// Level 4 (very low): "format the JSON response"
public String processOrder(Order order) {
// Level 2: medium-level validation
if (order.getCustomerId() == null) throw new IllegalArgumentException("...");
Customer c = customerRepo.findById(order.getCustomerId());
if (c.getCreditScore() < 600) throw new IllegalStateException("Credit too low");
// Level 3: raw SQL
String sql = "INSERT INTO orders (id, customer_id, total) VALUES ('" +
order.getId() + "', '" + order.getCustomerId() + "', " + order.getTotal() + ")";
jdbcTemplate.execute(sql);
// Level 4: JSON formatting
return "{\"status\":\"confirmed\",\"orderId\":\"" + order.getId() +
"\",\"total\":" + order.getTotal() + "}";
}
After SLA: the orchestrator reads like a table of contents. The detail lives one level down.
// β
Every line in this method is at the same abstraction level: "orchestrate the order flow".
// To understand the big picture, read this method. To understand any step, drill down.
public OrderConfirmation processOrder(Order order) {
validateCustomerEligibility(order);
orderRepository.save(order);
return buildConfirmation(order);
}
private void validateCustomerEligibility(Order order) {
Customer customer = customerRepository.findById(order.getCustomerId());
if (customer.getCreditScore() < MIN_CREDIT_SCORE) {
throw new IneligibleCustomerException(order.getCustomerId());
}
}
private OrderConfirmation buildConfirmation(Order order) {
return new OrderConfirmation(order.getId(), "confirmed", order.getTotal());
}
The second version reads like a requirements document at the top level. Drilling into validateCustomerEligibility shows you exactly the validation logic. You never have to mentally skip over SQL string concatenation to understand the business flow.
Interview tip: "Step one is always: rename methods until the main method reads like a to-do list. The to-do list is the abstraction level. Each item on the list is one method."
Principle of Least Astonishment
Rule
Code should do exactly what its name says, with no side effects or surprises. If you need a comment to explain what a method does, the name is wrong.
Two Java standard library examples that astonish developers every time:
// β Surprising: remove(int) removes by INDEX. remove(Object) removes by VALUE.
// Same method name, antithetical semantics. Integer boxing makes it worse.
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
list.remove(1); // removes index 1 (value '2'), not value '1'
list.remove(Integer.valueOf(1)); // removes value '1'
In your own code, the most common POLA violation is a method that lies about its side effects:
// β getOrder() sounds like a read. It's actually a write.
// A caller reading the code sees "get" and assumes no state change.
public Order getOrder(String orderId) {
Order order = repository.findById(orderId);
order.setLastAccessedAt(Instant.now()); // β surprise! side effect.
auditLog.record("accessed", orderId); // β another surprise.
return order;
}
After POLA:
// β
Reading and side effects are separate, named operations.
// The caller decides which to call.
public Order findOrder(String orderId) {
return repository.findById(orderId);
}
public void recordOrderAccess(String orderId) {
Order order = repository.findById(orderId);
order.setLastAccessedAt(Instant.now());
auditLog.record("accessed", orderId);
}
Design rule: if you need a comment to explain what a method does, the method name is wrong. If you need a comment to explain what a method also does beyond the name, extract the side effect into its own method.
Interview tip: POLA violations are common in legacy code reviews. Say: "When I review a pull request, I look for getter methods with side effects, boolean parameters that flip behaviour, and method names that don't match their implementation."
Which Principle Do I Need?
Keep this reference for code reviews. Walk the tree top-to-bottom on any method you're reviewing.
Interview Tips
Most common DRY misunderstanding
"DRY means no duplicate lines of code anywhere." Wrong. DRY means no duplicate knowledge. Two methods with identical code that represent different business concepts should stay separate. Merging them creates accidental coupling that you'll have to unpick when they diverge.
Mistake 1: Speculative abstraction justified as SOLID
"I added a PaymentGatewayRegistry because SOLID says extensibility." YAGNI says you're solving tomorrow's problem with today's complexity budget. SOLID and YAGNI are compatible: add the registry when you have the second gateway, not before.
Mistake 2: Train-wreck chains defended as "just getters"
order.getCustomer().getAddress().getCity() creates coupling to three unrelated classes. It is not about readability; it is about change cost. When Customer changes to support multiple addresses, every call site breaks. In an interview, say: "Each dot is a dependency. I'd add order.getDeliveryCity() and push the traversal into Order. LoD is most valuable at module boundaries. Inside a single module where classes change together, a two-dot chain is fine. But three or more dots? Usually over-coupled and worth fixing.""
Mistake 3: "I'll add this just in case" If a candidate says "just in case" during a design review, that is a YAGNI red flag. Every "just in case" feature has to be maintained, tested, documented, and eventually deleted. Ask: "What concrete scenario drives this? If none exists, simplify."
Mistake 4: SLA and SRP conflation SRP is about class design (one reason to change). SLA is about method design (one abstraction level per method). They are complementary. A class with SRP compliance can still violate SLA inside one of its methods.
The interview question to prepare for: "When did you violate YAGNI and regret it?" Every senior engineer has a story. Having yours ready signals that you learn from failures. A good answer: "I built a configurable rule engine for a validation step that turned out to never need configuration. It was 400 lines of dead infrastructure. I deleted it six months later."
Test Your Understanding
Quick Recap
- KISS: solve the problem in front of you. Introduce abstraction when the second case arrives, not before.
- DRY: every piece of knowledge has one home. Identical code that represents different concepts stays separated.
- YAGNI: the best architecture is the simplest one that solves today's problem. "Just in case" is a code smell.
- Law of Demeter: count your dots. More than two is usually a design problem hiding in a method chain.
- Single Level of Abstraction: your orchestrator method reads like a table of contents. Details are one level down.
- POLA: if a caller would be surprised, the design is wrong. No hidden side effects, no misleading names.
- In interviews: these principles compound. A method that violates SLA usually also violates SRP. A YAGNI violation often introduces LoD violations because the speculative infrastructure creates deep object graphs to navigate.