You write a line of code once. Someone reads it dozens of times: in code review, during debugging, while onboarding, while trying to understand why the build broke at 3am. Every design decision should optimise for the reader. Most don’t.
The Law of Demeter
The Law of Demeter came out of the Demeter project at Northeastern University in 1987. The name makes it sound grander than it is. The rule is simple: only talk to your immediate friends.
A method should only call methods on:
- itself
- its parameters
- objects it creates
- its direct component objects
That’s it. Don’t reach through an object to get to another object to get to another object. Each reach is a dependency. Each dependency is something that can change and break your code.
The simplified version is the “one dot” rule. Count the dots:
box.Subscriber.AllergenFlags.Contains(item.Category)
Three dots. Three objects you need to understand. Three coupling points. If Subscriber changes how it stores allergen data, this line breaks, even though it lives in code that’s supposed to be about boxes, not subscribers.
Now compare:
box.SafeFor(item)
One dot. One concept. The box knows whether it’s safe for an item. You don’t need to know how. You don’t need to know that subscribers have allergen flags, or that those flags are compared against item categories. The box handles its own concern.
The first version requires the reader to understand the internal structure of three objects. The second requires understanding one method name.
Why it matters: reading, not writing
“Writing code is easy. Reading code is hard.” Everyone says this. Few people ask why.
The problem isn’t complexity; complex code can be readable. It’s context. Each line of code requires the reader to hold context in their head. Chained field access forces you to hold the internal structure of objects you shouldn’t need to know about. Every dot is a mental load increase.
Take order.Customer.Address.City. To read this line, you need to know that orders have customers, customers have addresses, and addresses have cities. That’s three pieces of structural knowledge for one data point. If all you needed was the delivery city, the code should say so:
order.DeliveryCity()
The reader’s job went from “understand four structs” to “understand one method.” The code tells you what it means, not how it’s implemented.
The Law of Demeter isn’t really about object-oriented purity; it’s a readability principle expressed in OO terms. It applies equally to functional code, to API design, to data pipelines. Anywhere you’re chaining through structures you don’t own, you’re creating code that’s hard to read and fragile to change.
Go makes this concrete. When you export a struct field, every consumer can chain through it. When you provide a method instead, you control the interface. The field is an implementation detail; the method is a contract. Go doesn’t have private keywords. It has exported and unexported names. That’s the entire access control system, and it’s enough if you use it deliberately.
Tell, Don’t Ask
There’s a related principle that comes at the same insight from a different angle.
Instead of reaching into an object to inspect its state and then deciding what to do:
if order.Customer.VIP {
order.Total = int(float64(order.Total) * 0.9)
}
Tell the object what you need:
order.ApplyDiscount()
“Tell, Don’t Ask” and the Law of Demeter are the same insight. Both say: don’t reach beyond your circle of control. Both say: let each piece of code handle its own concerns. The readable version is always shorter, always clearer, and always easier to change.
When you ask, you’re pulling knowledge out of a struct and making decisions about it elsewhere. When you tell, the knowledge stays where it belongs. The type that knows about VIP discounts is the type that applies them.
Notice what happened to the reader’s workload. The “ask” version requires understanding the customer’s VIP status, the discount calculation, and the order’s total field. The “tell” version requires understanding one method name. The reader trusts that ApplyDiscount does what it says. If they need to know how, they can read the method. But they don’t have to, and that’s the point. Most of the time, knowing what is enough. Forcing the reader to know how is a design failure.
Guard clauses: flatten the pyramid
Nested conditionals are the other readability killer. Every if inside an if adds a level of indentation and a layer of context the reader has to hold:
func dispatchBox(box Box) error {
if box.Subscriber.Active {
if len(box.Contents) > 0 {
if box.SafeForAllergens() {
if box.DeliveryAddress.Verified {
return ship(box)
} else {
return flagAddressIssue(box)
}
} else {
return flagAllergenConflict(box)
}
} else {
return flagEmptyBox(box)
}
}
return nil
}
Six levels of indentation. Five paths. The “happy path”, the thing this function actually does, is buried in the middle. The reader has to parse four conditions before reaching ship(box).
Guard clauses flip it. Handle the edge cases first, return early, and leave the happy path at the end, unindented:
func dispatchBox(box Box) error {
if !box.Subscriber.Active {
return nil
}
if len(box.Contents) == 0 {
return flagEmptyBox(box)
}
if !box.SafeForAllergens() {
return flagAllergenConflict(box)
}
if !box.DeliveryAddress.Verified {
return flagAddressIssue(box)
}
return ship(box)
}
Same logic. Same five paths. But now each edge case is one if block, stated clearly, and the happy path sits at the bottom where the reader’s eye naturally lands. You can read the function top-to-bottom without holding nested context.
This is Go’s natural shape. The language was designed for early returns. if err != nil { return err } is the most written line in Go, and it’s a guard clause. Every error check is a guard that exits early so the happy path stays unindented. Go developers write guard clauses instinctively for errors. The insight is to apply the same pattern to all early exits, not just error returns.
The principle: push the unusual cases to the top, let them exit early, and give the normal case the prime real estate at the bottom with no indentation.
Handler maps: convention over switch statements
Massive switch statements are another code smell that masquerades as clarity:
func handleEvent(event Event) error {
switch event.Type {
case "subscription_created":
return handleSubscriptionCreated(event)
case "subscription_paused":
return handleSubscriptionPaused(event)
case "subscription_cancelled":
return handleSubscriptionCancelled(event)
case "payment_failed":
return handlePaymentFailed(event)
case "payment_succeeded":
return handlePaymentSucceeded(event)
case "delivery_scheduled":
return handleDeliveryScheduled(event)
case "delivery_completed":
return handleDeliveryCompleted(event)
case "allergen_conflict":
return handleAllergenConflict(event)
// ... twelve more
default:
return fmt.Errorf("unknown event type: %s", event.Type)
}
}
Every new event type means editing this function. The mapping is manual. Miss one and you get the default error. Add one and you touch code that has nothing to do with your new event.
Go doesn’t have Ruby’s send/respond_to? reflection for this. It has something more explicit: a handler map.
type EventHandler func(Event) error
var handlers = map[string]EventHandler{
"subscription_created": handleSubscriptionCreated,
"subscription_paused": handleSubscriptionPaused,
"subscription_cancelled": handleSubscriptionCancelled,
"payment_failed": handlePaymentFailed,
"payment_succeeded": handlePaymentSucceeded,
"delivery_scheduled": handleDeliveryScheduled,
"delivery_completed": handleDeliveryCompleted,
"allergen_conflict": handleAllergenConflict,
}
func handleEvent(event Event) error {
handler, ok := handlers[event.Type]
if !ok {
return fmt.Errorf("unknown event type: %s", event.Type)
}
return handler(event)
}
The dispatch function is four lines. Adding a new event type means adding one line to the map and writing the handler function. The dispatch code doesn’t change.
This is the same pattern as the event bus from the DDD posts. The event bus uses a handler map internally. The pattern is universal in Go. HTTP routers, command dispatchers, message processors all use it.
The trade-off is the same in any language: convention-based dispatch trades explicitness for extensibility. In a switch statement, you can see every handler at a glance. With a map, you need to scan for registrations. The trade-off depends on how often new cases are added. If the list is stable, a switch is fine. If it grows every sprint, the map wins, because the switch becomes a merge conflict magnet and a readability burden.
Cyclomatic complexity: a useful proxy, not a perfect one
Cyclomatic complexity counts the number of independent paths through a function. Every if, else, case, for, &&, || adds a path. Higher number = more branches = harder to understand.
It’s a useful proxy for readability, with caveats.
High CC almost always means poor readability. A function with a cyclomatic complexity of 15 has fifteen paths. The reader has to consider which combination of conditions leads to each outcome. That’s cognitively expensive.
Low CC doesn’t guarantee readability. A single 200-line function with no branches has a CC of 1. It’s still terrible to read. CC measures structural complexity, not semantic complexity. A function can be linear and still incomprehensible.
The practical use: treat CC as a smoke detector, not a diagnosis. When a linter flags a function with CC > 10, look at it. The fix is usually guard clauses (flatten the branches), extract function (split the concerns), or a handler map (eliminate the switch). Each of these reduces CC and improves readability. The correlation is real, it’s just not the whole picture.
Go has gocyclo and gocognit linters for this. Most teams that track CC find the same thing: the functions with the highest complexity are the ones that generate the most bugs and take the longest to modify. That’s not because complexity causes bugs directly. It’s because complexity makes the code hard to read, and hard-to-read code is where misunderstandings live.
LLMs and readability
LLMs generate code that works. They don’t generate code that’s readable unless you tell them to.
LLMs love chaining. They’ll generate this without hesitation:
user.Profile.Settings.Notifications.Email.Enabled
It resolves correctly. The LLMA neural network trained to predict the next token in a sequence, large enough that it generalises to tasks it wasn’t explicitly trained for. has no reason to wrap it. Every intermediate field is valid, every access returns the correct type, and the final boolean answers the question. From a correctness standpoint, it’s fine.
From a readability standpoint, it’s five structs deep. The next developer, or the next LLM reading the code as context, has to understand the full chain to modify it. If Settings moves from Profile to Account, every chain breaks. If Notifications becomes a separate service, every chain breaks. The coupling is invisible until something changes.
When you review LLM-generated code, look for chains longer than one dot. Each extra dot is a readability debt and a coupling point. The fix is almost always the same: wrap the chain in an intention-revealing method.
user.EmailNotificationsEnabled()
There’s a compounding problem. Your codebase is the LLM’s context. If the existing code is full of long chains, the LLM mirrors the pattern. Every chain you leave in place trains the next generation of generated code to chain the same way. The style self-replicates. Cleaning up chains isn’t just about the line you’re fixing; it’s about changing the signal for every future generation pass.
When you PromptThe input you hand to an LLM – system instructions, user message, examples, retrieved documents, tool descriptions, the lot. an LLM, say “the code must be readable by someone who doesn’t know the codebase.” This changes the output. The LLM starts wrapping chains in methods with names that describe intent rather than implementation. It starts applying the Law of Demeter not because it understands the principle, but because “readable to a stranger” and “don’t expose internal structure” converge on the same code.
This is the same dynamic as test language: the words you use in prompts shape the code you get back. “Should” produces advisory code. “Must” produces contractual code. Long chains in existing code produce long chains in generated code. The style of your codebase is a few-shot prompt whether you intended it to be or not.
The Greenbox connection
This connects to several things the Greenbox team learned the hard way.
Bounded contexts exist to draw lines around code that changes together. The whole point of those boundaries is that code inside one context doesn’t need to know the internals of code outside it. Long dot-chains cross boundaries. box.Subscriber.AllergenFlags.Contains(item.Category) reaches from the Delivery context into the Subscriber context and then into the Allergen context. Three boundaries crossed in one line. The anti-corruption layer exists to prevent this kind of reach-through.
ADRs are readable because they explain the “why.” Code should be readable for the same reason, the “what” should be obvious from the code itself. box.SafeFor(item) tells you what the code does. The method’s implementation explains how. The ADR explains why.
The allergen incident is the cautionary tale. box.Subscriber.AllergenFlags.Contains(item.Category) spread across three files is how the allergen check got missed. When the logic lived in three places and required understanding three objects, the team couldn’t see it. box.SafeFor(item) in one place is how it got fixed. The knowledge moved to where it belonged, and the check became visible.
The practical test
Read your code aloud. If you have to say “dot” more than once, consider whether the reader needs to know all those intermediate structs.
If a line of code requires you to understand the internal structure of a type you didn’t create, you’re coupled to that structure. When it changes, your code breaks. When someone new reads it, they have to learn the structure before they can understand the line. That’s a cost you’re imposing on every future reader.
The fix is almost always the same: add a method to the type that knows the answer. Move the knowledge to where it belongs. The method name should describe what you’re asking, not how it’s computed. Good method names are tiny pieces of documentation that never go stale; they have to stay accurate because the tests call them.
// Before: reader needs to know three structs
box.Subscriber.AllergenFlags.Contains(item.Category)
// After: reader needs to know one method
box.SafeFor(item)
The second version is shorter, clearer, and changes for fewer reasons. It’s also what the code meant all along: the chain was just an implementation detail that leaked into the interface.
In Go, the fix has a mechanical step: make the fields unexported. Change Subscriber to subscriber. Now the compiler enforces the boundary, and code outside the package can’t chain through the field. You’re forced to provide a method. The Subscription entity in the DDD posts uses this pattern throughout: every field unexported, every access through a method that reveals intent.
This isn’t about purity or following rules for the sake of rules. It’s a simple economic observation: the time spent writing a line of code is dwarfed by the time spent reading it. A chain that saves the writer thirty seconds costs every future reader thirty seconds of cognitive overhead. Multiply that by the number of readers, and the debt is obvious.
Code is read more than it is written. Every dot you remove is a kindness to the next reader. That reader might be a colleague, a new hire, an LLM consuming your code as context, or you in six months when you’ve forgotten why Subscriber has AllergenFlags in the first place.
Write for readers. Count the dots. Move the knowledge.
You don’t have to fix every chain today. But every time you touch a file, look for the chains. Wrap one in a method. Give it a name that describes what it means, not what it traverses. The next person who reads that file, human or LLM, gets a better signal. The improvement compounds.
The code will thank you by being easier to change when, not if, the internals shift underneath it.