Design principles: KISS, DRY, YAGNI, and beyond
Six practical principles that guide clean code decisions: KISS, DRY, YAGNI, Law of Demeter, Single Level of Abstraction, and Principle of Least Astonishment.
Introduction
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 is one well-known family of principles. These six complement it by offering practical heuristics for complexity, knowledge ownership, coupling, method structure, and caller expectations.
| 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.
TL;DR / Mental Model
Treat these principles as questions to ask while designing or reviewing code:
- KISS: Is this the simplest design that solves the current problem?
- DRY: Is duplicated code expressing the same knowledge, or only similar syntax?
- YAGNI: Is this capability required now, or is it speculative infrastructure?
- Law of Demeter: Does this method know too much about an object's collaborators?
- Single Level of Abstraction: Can the method be read at one consistent level?
- Principle of Least Astonishment: Would a caller predict the name, result, and side effects?
The mental model is to reduce accidental complexity without making future change impossible. These are heuristics, not a checklist that overrides requirements, correctness, performance, or a clear domain boundary.
Definitions and Boundaries
A design principle is a reusable decision heuristic, not a guarantee that one structure is always best. The six principles address related but different problems:
- KISS limits unnecessary structural complexity; it does not excuse unclear code or missing validation.
- DRY centralizes the same knowledge; it does not require merging code that only happens to look alike.
- YAGNI delays speculative capability; it does not justify ignoring a requirement or a known integration boundary.
- Law of Demeter limits knowledge of indirect collaborators; it does not ban builders, streams, or every method chain.
- Single Level of Abstraction keeps a method coherent; it does not require every method to be tiny or contain only one statement.
- Principle of Least Astonishment aligns behavior with caller expectations; it does not prohibit explicit side effects when names and contracts make them clear.
How It Works
Apply the principles as a small feedback loop:
- Name the current requirement and the change that is actually needed.
- Identify the dominant smell: speculative complexity, duplicated knowledge, deep coupling, mixed abstraction levels, or surprising behavior.
- Make the smallest local change that restores a clear boundary.
- Re-check the trade-off: a little duplication or delegation can be cheaper than a shared abstraction with the wrong ownership.
The sections below use the same order-service domain to show the before-and-after shape of each decision.
KISS: Keep It Simple, Stupid
Rule
Solve the problem in front of you with the simplest code that works. Prefer code whose complexity remains easy to change or remove.
A common over-engineering mistake is building a framework when a function is enough. This order-service example shows the shape:
// β 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 that needs maintenance, tests, documentation, and explanation. The KISS version has one branch. If a third discount type arrives, add a case; if the switch becomes unwieldy or variations need independent testing, introduce a strategy interface. The change in requirements is the useful trigger, rather than a guessed future.
Design note: Before choosing a factory or strategy, ask how many discount types exist today and how often they change. If there are two types that rarely change, a switch may be the clearest choice.
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),
// there are now two update points, and they can drift if one is missed.
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.
Design note: Extract shared validation, domain rules, and transformations when they represent the same knowledge. Keep methods separate when they only look similar but represent different concepts.
YAGNI: You Aren't Gonna Need It
Rule
Don't implement something until you actually need it. Prefer the simplest architecture that solves today's problem.
A team might spend two sprints building a PaymentGatewayRegistry with abstract factories and XML config files for a system that processes one payment method: Stripe. If two years pass with no second gateway, the registry can remain unused and eventually be removed during cleanup.
// β 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, extract a PaymentGateway interface, make StripeGateway implement it, create PayPalGateway, and wire the implementations through the registry. That refactoring may be small while the code is still focused; speculative infrastructure can consume a sprint or more and may still guess the wrong 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.
Design reflection: Ask which concrete requirement would justify the extra gateway abstraction today. If there is no such requirement, keep the current path small and leave a clear seam for later change.
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 and may read well, but it couples the calling code to the internal structure of Order, Customer, and Address simultaneously. A change to one of those classes can break the line; across many call sites, a simple refactor can create a broad change cascade.
The Law of Demeter (LoD) helps address this form of structural coupling. It can sound restrictive at first, but it often reduces downstream refactoring when object boundaries change.
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 explored how limiting method call chains could reduce the ripple effects of code changes.
In design terms, LoD reduces coupling by restricting which objects a method can talk to. Fewer dependencies can make a change cheaper, but the delegate methods have a maintenance cost.
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 can indicate a coupling point. A useful heuristic is to inspect a non-builder chain with three or more dots; it may be a violation, but the object types and API style still matter.
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 does not need to know that Customer and Address exist. If the Order team later restructures how addresses are stored, ShippingService may not need to change.
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 LoD Delegation 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.
A useful heuristic: if the delegate method adds zero logic (literally return this.x.getY()), a few such methods may be reasonable. If they grow into a large surface, question whether the abstraction boundary is helping or merely forwarding calls.
Design note: name the trade-off
When evaluating LoD, name the cost as well as the benefit. Following it at service boundaries while relaxing it inside a cohesive module can be reasonable when the classes change together; wrapper-method overhead is still part of the decision.
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.
A static-analysis rule can lead to dozens of delegate methods being added mechanically, turning a clean domain model into a forwarding layer. That can be worse than the original chain when the extra methods add no meaningful boundary.
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 can trigger N+1 queries when lazy-loaded. LoD-inspired boundary methods can reduce these chains and prompt developers to define the data they actually need, which may support better fetch strategies.
A practical reason to avoid deeply chained calls on returned objects is that each returned object adds another structural dependency and may require another test double. Each dot can therefore represent coupling and 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.
Design checklist: Count coupling arrows and inspect methods that traverse several collaborators. A large number of direct dependencies can indicate that LoD violations are hidden inside methods, but the ownership boundaries determine whether a refactor is worthwhile.
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.
Design note: Rename methods until the main method reads like a to-do list. That list represents the orchestration level; each item can delegate to a method that explains its own details.
Principle of Least Astonishment
Rule
Code should do what its name and contract lead callers to expect, with side effects made explicit. If a comment is repeatedly needed to explain surprising behavior, reconsider the name or the contract.
Two Java standard library examples that can surprise developers:
// β 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, a common POLA violation is a method whose name hides 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.
Design checklist: Look for getter methods with side effects, boolean parameters that flip behavior, and method names that do not match their implementation.
Design Implications, Trade-offs, and Exceptions
These principles change different parts of a design, so applying one can create a cost elsewhere:
- Simplicity can leave a switch or a concrete dependency in place; add an abstraction when a real variation or boundary makes it useful.
- Centralizing knowledge can create coupling; keep similar code separate when the concepts have different owners or change for different reasons.
- Delegation can hide representation, but too many forwarding methods can create a middle-man layer.
- Extracting helpers can clarify abstraction levels, but excessive fragmentation can make a simple flow harder to follow.
- Explicit side effects improve predictability, while some operationsβsuch as caching, metrics, or lazy loadingβmay need effects that should be documented and named.
The exception in every case is context. A DTO may expose data, a builder may use a fluent chain, a small module may share a tightly coupled object graph, and a known compliance or integration requirement may justify infrastructure before the second implementation exists. Use the principle to surface the decision and its cost, not to avoid making a decision.
Which Principle Do I Need?
Keep this reference for code reviews. Walk the tree top-to-bottom on any method you're reviewing.
30-Second Explanation
KISS keeps the structure as simple as the current problem allows. DRY gives the same knowledge one authoritative home, while YAGNI delays speculative capability. The Law of Demeter limits knowledge of indirect collaborators, Single Level of Abstraction keeps each method readable, and the Principle of Least Astonishment aligns names with behavior. Together they are review questions, not rigid laws.
5-Minute Explanation
For an order service, begin with the smallest design that satisfies today's discount and payment requirements. Extract validation only when the duplicated code represents the same rule, and introduce an interface when a real second implementation or a clear boundary requires it. Keep callers from traversing an order's internal object graph, let orchestration methods delegate detail to methods at the next level, and make reads, writes, and side effects explicit in names and contracts. Then check the exceptions: DTOs may expose data, builders may chain, a little duplication may preserve separate concepts, and an abstraction may be justified by a known integration or compliance requirement.
Common Mistakes and Misconceptions
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 only about readability; it is about change cost. If Customer changes to support multiple addresses, the call sites may need updates. A useful rule is: "Each dot is a dependency. Add order.getDeliveryCity() and push the traversal into Order. LoD is most valuable at module boundaries; inside a cohesive module, a short chain may be acceptable."
Mistake 3: "I'll add this just in case" During a design review, treat "just in case" as a prompt to ask for a concrete scenario. A speculative feature adds maintenance, testing, and documentation cost; if no current need 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.
Design reflection: Ask which speculative capability has already earned a concrete requirement. For example, a configurable rule engine may add hundreds of lines before any caller needs configuration; keeping the first rule focused leaves a smaller refactoring path if variation appears later.
Test Your Understanding
Recap
- KISS: solve the problem in front of you. Introduce abstraction when a second concrete case or boundary warrants it.
- 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: inspect deep chains, while remembering that builders, streams, and data carriers have different boundaries.
- 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 deserves review. Make side effects and names explicit.
- Together: these principles often interact. A method can violate SLA while its class still has a reasonable responsibility boundary, and speculative infrastructure can introduce deeper object graphs that make LoD harder to maintain.
Related OOP Concepts
- Encapsulation: keeps representation and invariant-protecting behavior behind an object boundary.
- Abstraction: chooses which details a caller should see; these principles help keep that public surface useful.
- Composition: assembles focused collaborators without forcing callers to know their internal graph.
- SOLID principles: provide complementary guidance on responsibility, substitution, interfaces, and dependency direction.
- Enums and state modeling: give a closed set of domain values a type-safe home; YAGNI and KISS help decide when that model is warranted.
Related Articles
Inheritance models IS-A but couples tightly. Composition models HAS-A and stays flexible. Learn when each is right and how to migrate from one to the other.
Abstraction separates what an object can do from how it does it. Callers depend on contracts, not implementations, which makes systems extensible and independently testable.