Software Design Reference Core Designer · Seeing Structural Problems

Lesson 0003 · Core Designer · Module 1

The Law of Demeter

A method should only talk to its immediate neighbors. When code walks through strangers, it learns their geography — and every geography change breaks it.

Reading ≈ 10 minPractice ≈ 20 min

Mission tie-in: chain-walking is one of the most common structural defects in AI-generated code. Master the tell, and you can spot it in any output in seconds.

Knowledge: no talking to strangers

The Law of Demeter limits a method's acquaintances to: itself, its own fields, its arguments, objects it creates, and the direct members of its own collections. Everything else is a stranger.

This is the failure it prevents — a "train wreck":

def is_free_shipping(order: Order) -> bool:
    city = order.customer.address.city
    return city in FREE_SHIPPING_CITIES

is_free_shipping reaches through order into customer, then address, then city. The caller now knows the internal geography of three objects it doesn't own. Change customer.address to customer.delivery_address and this breaks even though nothing about shipping changed.

The fix is tell, don't ask: move the behavior to the object that owns the data.

def is_free_shipping(order: Order) -> bool:
    return order.has_free_shipping_to(FREE_SHIPPING_CITIES)

# inside Order:
def has_free_shipping_to(self, cities: set[str]) -> bool:
    return self.customer.delivery_city() in cities

The caller talks to one neighbor (order). The chain still exists — but it lives inside the object that owns the data, where geography changes are contained. The Law of Demeter is not "no chains ever"; it is "nobody walks through strangers."

† The same idea, at object level: a mediator object that only forwards calls is a violation with extra steps — moving the chain doesn't fix it, asking the owner does.
The principle behind the principle

The reason a chain is dangerous is that the signature lies. is_free_shipping(order: Order) declares one dependency. The body has four: Order, Customer, Address, and the fact that a city is a plain string that can be compared to a set. Every reviewer, every type checker, and every dependency-graph tool reasons about the declared dependency. Only the runtime knows about the other three. Structural defects that are invisible to your tools are the ones that survive longest.

The second mechanism is multiplication. Each hop is an independent assumption about someone else's internals, so the chance the line breaks is one minus the chance none of them changes. If each of three types has a modest 20% chance of being restructured in a year, the line has roughly a 49% chance of breaking within twelve months — for a reason that has nothing to do with shipping. Depth is not a style preference; it is compounding risk.

The third mechanism is ownership. A hop that stays inside one module, owned by one team, on one release train, is cheap: the person who renames the field can fix the caller in the same commit. A hop that crosses into a shared library owned by another team is expensive: the rename lands in their release, your code breaks in yours, and neither pull request contains both halves of the story. Count the ownership boundaries a chain crosses, not the dots.

Where it stops applying. Chains through stable, structural things are fine and forcing them through methods makes code worse. datetime.now().date().isoformat() is three hops into a standard library that will outlive your company. Walking a parsed JSON document, a configuration tree, or an abstract syntax tree is normal, because those are data structures, not objects with behaviour to ask — there is nobody to tell. And fluent interfaces — query builders, test assertions, dataframe pipelines — are chains by design: the chain is the interface, not a walk through someone's privates.

How a senior designer judges a chain

The first question is not "how many dots" but "how many owners". Open the three types in the chain. If Order, Customer, and Address all live in domain/orders/, maintained by the same four people, the chain is a mild readability issue and probably not worth a refactor. If Address comes from a shared identity package on its own release cadence, every hop into it is a dependency on another team's calendar, and the same three dots are a real liability.

The second question is what kind of thing you are extracting. There is a real difference between pulling out a value and moving a decision. Both look like refactors; only one helps.

The rejected alternative. The reflex fix is to add a forwarding accessor:

# inside Order — looks like the law, isn't
def customer_city(self) -> str:
    return self.customer.address.city

The caller now says order.customer_city() and the reviewer nods. But nothing was decided anywhere new: Order has acquired an opinion about address geography, the chain still exists one frame down, and if Address gains a second delivery address, Order is the thing that breaks. This is Fowler's middle man: a class whose methods mostly delegate. The test that catches it: does the new method express something the object is responsible for, or is it a tunnel dug for one caller's convenience? "What city does this customer receive deliveries in?" is Customer's business. "Does this order ship free?" is Order's business. "What is the string inside my customer's address?" is nobody's business.

The consequence at scale. An identity team changes Customer.address to Customer.addresses, a list, to support a separate billing address. In a 200,000-line codebase, grep -rn "\.address\." returns something in the hundreds across a dozen modules, and each hit has to be judged individually: which address did this caller mean? Nobody can answer that from the call site, because the call site never said. Where the question had been moved into Customer, the same migration is one file and one decision made once, by the team that actually knows the answer. That asymmetry — one deliberate decision versus hundreds of undocumented ones — is the whole cost of the law, and it only shows up on the day someone restructures.

Field notes · what it looks like in real code
You see thisWhat it costsThe move
a.b.c.dThe caller depends on the shape of three objects it does not own.Ask the owner one question: a.answer().
get_x().get_y().get_z()The same wreck in getter clothing — chained accessors still walk strangers.Move the question into the first object.
a mock returning a mock returning a mockThe mock chain mirrors the call chain: proof of a train wreck.Flatten the interface first, then rewrite the test.
self.customer.address inside OrderLegal: customer is Order’s own field, a direct neighbour.Leave it — the law limits callers, not owners.

When this is the wrong move

Leave the chain alone when it walks a data structure rather than an object: parsed JSON, a frozen configuration tree, an AST node. There is no owner to ask, and inventing one produces a wrapper class per node type for no benefit. Leave it alone when the chain is the published interface — query builders, assertion libraries, and pipeline APIs are designed to be chained, and "fixing" them destroys the thing you paid for. Leave it alone inside an aggregate root reaching into its own parts, where the root is the owner (lesson 0012). And resist the fix when it would push a foreign concern into a domain object: adding Order.to_html_row() to spare a template three dots trades a small coupling for a large one, because now presentation lives in the domain.

What people get wrong after reading this
The Law of Demeter means "at most one dot".
Dots are not the unit; strangers are. self._lines.append(item) has two dots and is entirely legal — _lines is your own field and append is its own method. Meanwhile a.b() can be a violation if b() hands you an object you then interrogate.
Adding a delegating getter fixes it.
A forwarding accessor keeps the dependency and adds a method to the wrong class. You have paid for a new API surface and bought nothing: when the far end restructures, the delegator breaks instead of the caller. Move the decision, not the value.
It applies to data transfer objects and records too.
A record with no behaviour has nobody to tell. Walking a response payload or a config tree is reading a structure, not violating an encapsulation — the structure is the contract. The law is about objects that could answer a question and were asked for their insides instead.
It is really about avoiding None errors.
Null-safety is a side effect, not the point. A chain of non-nullable fields still couples you to three shapes. Conversely, optional-chaining syntax makes the crash go away while leaving every coupling exactly where it was.

Skill: spot the stranger

Which snippet walks through a stranger?

Why does a train wreck hurt maintainability?

The best fix for order.customer.address.city is to:

Practice on your own code

Search a file you own (or an AI-generated one) for chains: patterns like a.b.c.d or repeated get_x().get_y(). Pick one and, before touching it, answer the two senior questions: how many owners does this chain cross, and what decision is the caller really trying to make? Then move the decision to the object that owns the data — and check you did not just build a middle man.

In the lab: the returns-resolution service reaches through several objects to decide whether a return is eligible. The phase-1 checkpoint asks you to find the deepest chain in that decision path, name the owner that should be answering it, and move the question there — then confirm the test for that rule no longer needs to build three nested objects to run.

Reveal: a worked example
# before
if booking.flight.airline.contact_email:
    notify(booking.flight.airline.contact_email)

# after — the airline answers; Booking has no opinion on airline internals
if booking.flight.has_contact_email():
    notify(booking.flight.contact_email())
Teach it back
  1. Explain to a junior why a.b.c is riskier than a.answer() in two sentences, without mentioning dots.
  2. Find one chain in your own code this week and name its cost concretely: how many teams' release schedules that one line depends on, and how many call sites would need judging if the far end were restructured.
  3. Predict what breaks in a year when the type at the end of that chain gains a second variant — a second address, a second contact, a second account.

Your win

You can spot a train wreck at a glance and know the one fix that works: hand the question to the owner of the data. Add this to your code-review checklist and your AI-review checklist.

Read and watch deeper

Unsure whether a chain is a violation? Ask your agent-teacher to judge a snippet with you — the boundary cases are where the law becomes judgment.