Engineering Atlas › Low-Level Design

The Law of Demeter: talk to friends only

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The rule, precisely

The rule comes from the Demeter project at Northeastern University in 1987. Inside a method m of object o, you may call methods on:

  1. o itself.
  2. Parameters of m.
  3. Objects created inside m.
  4. o's direct fields (its components).

Objects that those calls return are strangers. You may hold them and pass them on, but calling methods on them means you know how your neighbour is built.

The train wreck

A shipping label service needs a city:

def label(order):
    city = order.customer().address().city()
    if order.customer().account().tier() == "gold":
        return Label(city, priority=True)
    return Label(city, priority=False)

label now knows that orders have customers, customers have one address and an account, accounts have tiers, and that shipping goes to the customer's address. That last one is wrong as soon as a customer can ship to a different address per order, and the fix has to touch every chain like this in the codebase. In the worked example below, the team found 37 of them.

Three fixes

Tell, don't ask. Move the decision to the object that has the data: order.make_label(). The order knows where it ships and how urgent it is.

Delegate one level. If callers truly need the value, add order.shipping_city(). The order can answer from its own shipping address, its customer's default, or a cached field, and callers do not care.

Pass the narrow thing. If label only needs a city and a priority, make those the parameters: label(city, priority). The caller that already has the order extracts them once, and label becomes trivial to test.

Where the law does not apply

Over-applying the law produces a different smell: classes full of one-line forwarders like getCustomerAddressCityName(). If an object grows ten of these, the callers probably want a behaviour on that object, not more getters.

Mocks expose violations

A test for the train-wreck label needs a mock order that returns a mock customer that returns a mock address that returns a city. Three levels of stubs to test one line of logic is a reliable sign that the code knows too much. With label(city, priority), the test needs no mocks at all.

Reviewing agent-written code

Agents produce train wrecks because they navigate whatever object graph they can see in context: ctx.request.user.org.settings.billing.currency. The code works, so tests pass. In review, look for chains through domain objects, especially those that appear in several files, and ask for a method on the nearest owner or a narrower parameter. Telling an agent "functions take the values they use, not the objects that contain them" prevents most of it.

The same shape elsewhere

LoD is the coupling problem that networks solved with layering. An IP host does not know the routers between it and its destination; it hands packets to its default gateway, and each router knows only its next hop. Routes change constantly without any host noticing. Service architectures follow the same rule: a client that calls the order service, then uses an ID from the response to call the customer service, then the address service, is a train wreck across the network.

Down the stack

Layer 1The contract

A method that follows LoD depends only on the interfaces of its immediate collaborators. The testable promise: if a class changes its internal structure (its fields and the types of objects it holds) without changing its own public methods, no method outside it that obeys LoD needs to change. The original paper distinguishes a strict form, which counts only fields declared in the class itself, and a weaker form, which also allows inherited fields.

Layer 2The mechanism

Languages offer tools to obey the law without writing every forwarder by hand. Rails' delegate :city, to: :address generates forwarding methods. Kotlin's by keyword delegates a whole interface to a field. Go struct embedding promotes the inner type's methods onto the outer type, which shortens chains but also publishes the inner API, so embedding a type is a public commitment to its whole method set.

Static analysis can flag violations. PMD for Java has a LawOfDemeter rule that tracks whether each receiver is a field, parameter, local created in the method, or a returned value. It is noisy on fluent APIs, which is why it is rarely enabled by default.

ORMs turn chains into queries. With lazy loading in Django or ActiveRecord, order.customer.address.city issues a SELECT for the customer and another for the address, the first time each is touched. Inside a loop, that is the N+1 query problem. select_related in Django and includes in ActiveRecord fix it with a join, but only if the code that knows what will be read is the code that loads it, which is exactly what a method like Order.shipping_city() centralises.

Null propagation multiplies too. Each hop is a place where the value can be missing. Optional chaining (order?.customer?.address?.city in TypeScript or Kotlin) stops the crash, but it hides which link was missing and silently turns "no address" into undefined.

Layer 3The machine

Each hop in a.b.c.d is a load whose address depends on the previous load. The CPU cannot start loading c until b arrives, so out-of-order execution cannot overlap them. An L1 hit costs about 1 ns, but an object graph that is spread across the heap can miss to main memory at roughly 80 to 100 ns per hop. Three hops over cold objects can cost several hundred nanoseconds per call; in a loop over a million orders, that is a visible fraction of a second.

LoD does not fix pointer chasing by itself. A delegating method in Go or on the JVM is inlined, so order.ShippingCity() compiles to the same loads as the chain. In CPython there is no inlining, and each extra Python-level call adds very roughly 50 to 100 ns. What the law does enable is changing the representation: once only Order knows where the city lives, it can cache it in its own field and turn three dependent loads into one.

Across processes, every hop becomes a round trip. A client that calls three services in sequence pays three network latencies, about 0.5 to 1 ms each inside one data centre, and depends on three schemas deployed by three teams. A protobuf response read as resp.order.customer.address.city couples the client to the nesting of messages it does not own; moving address breaks it on the next release.

Why it's built this way

  1. Internal structure changes more often than public behaviour, because representations get optimised and requirements reshape relationships.
  2. Every caller that knows a structure must change when it changes, so the cost of change grows with how far that knowledge spreads.
  3. Limiting each method to its immediate neighbours bounds that spread to one level.
  4. Wrapping every path costs boilerplate, so the law is applied to objects with behaviour and relaxed for values, builders and data.

Diagram

flowchart LR
  L["label()"] -->|"allowed"| O["Order (parameter)"]
  L -.->|"violation"| C["Customer"]
  L -.->|"violation"| A["Address"]
  O -->|"field"| C
  C -->|"field"| A
  O2["Order.shipping_city()"] --> SA["shipping address field"]
  L -->|"after fix"| O2

Worked example

An order history page shows 50 orders, each with its shipping city. The template does order.customer.address.city. With lazy loading, that is 1 query for orders, 50 for customers and 50 for addresses: 101 queries. At about 1 ms per round trip to the database, the page spends roughly 100 ms on queries before rendering.

The team adds Order.shipping_city() and a repository method recent_with_shipping(user_id, limit) that joins orders to addresses in one query. The page drops to 1 query at about 3 ms. The template now calls one method on its own parameter.

Six months later, orders gain a per-order shipping address, separate from the customer's default. The change touches Order.shipping_city() and the repository query. Before the refactor, a search found 37 chains through customer.address in templates, emails, the label service and two reports. Each would have kept printing the customer's default address, silently wrong, after the schema change.

Trade-offs

OptionStrengthsWeaknessesChoose when
Chain through objectsNo extra codeCallers coupled to structure; N+1 queries; null at every hopPlain data, DTOs, config
Tell, don't askBehaviour lives with data; callers stay smallOwner class growsThe caller is making a decision the owner could make
Delegate methodOne place knows the pathForwarders can pile upA few values many callers need
Pass the narrow valueSimplest to test; no mocksCaller does the extractionFunctions that use one or two fields
Read model or viewShaped for the consumer, one queryAnother model to keep in syncPages and reports that read across many objects

Pitfalls in production

Leadership lens

Check yourself

  1. Why is items.filter(...).map(...).sum() not a Law of Demeter violation?

    Show answer

    Each step works on a collection or a value that the caller already holds, not on a stranger's internal parts; no domain object's structure is exposed.

  2. A delegating method in Go compiles to the same loads as the chain it replaced. So what did the refactor buy?

    Show answer

    Only one class now knows the path, so the representation can change in one place, for example to cache the value or load it with a join.

  3. How do long chains through ORM objects cause performance problems?

    Show answer

    Each hop through a lazily loaded relation can issue a query, so a chain inside a loop becomes N+1 queries.

  4. Why does a test needing mocks that return mocks suggest a design problem?

    Show answer

    The code under test is navigating its collaborators' structure, so the test must rebuild that structure; taking the needed values directly removes both the coupling and the mocks.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →