Concepts / SOLID and design principles / Law of Demeter and tell-don't-ask

Law of Demeter and tell-don't-ask

Talk to your neighbours, not your neighbours' insides.

01The intuition

The problem it solves

A food-delivery program needs to charge a customer for an order. The first version reaches through objects to get what it wants.

class Checkout:
    def charge(self, order) -> None:
        card = order.customer.wallet.cards[0]
        if card.expiry < today():
            raise ValueError("card expired")
        card.balance -= order.total()
        order.customer.wallet.history.append(("charge", order.total()))

Checkout now knows that an order has a customer, a customer has a wallet, a wallet has a list of cards, a card has an expiry and a balance, and a wallet has a history list. Six facts about other classes' insides. When the wallet switches to a dictionary of cards keyed by name, Checkout breaks. When the card's balance moves to a bank service, Checkout breaks. Nothing stops Checkout from charging an expired card differently from how Wallet would.

The law of Demeter, also called "tell, don't ask", says an object should talk only to its direct friends: its own fields, its parameters, and objects it creates. It should not navigate chains of dots into strangers' internals.

Build it one piece at a time

Step 1: find the longest chain and ask the nearest object to do the job. Checkout holds an order and should ask the order, or the customer, to pay; it should not open the wallet itself.

class Checkout:
    def charge(self, order) -> None:
        order.customer.pay(order.total())

Step 2: push the knowledge down to the owner. The customer knows it has a wallet; the wallet knows about its cards. Each class handles one level of structure.

class Customer:
    def __init__(self, wallet): self._wallet = wallet
    def pay(self, amount: int) -> None:
        self._wallet.pay(amount)

class Wallet:
    def __init__(self, cards): self._cards, self._history = cards, []
    def pay(self, amount: int) -> None:
        card = self._default_card()
        card.charge(amount)
        self._history.append(("charge", amount))
    def _default_card(self):
        return self._cards[0]

Step 3: let the deepest object enforce its own rule. The expiry check belongs to the card. Now it is impossible to charge an expired card from anywhere, not just from Checkout.

class Card:
    def __init__(self, expiry, balance: int): self._expiry, self._balance = expiry, balance
    def charge(self, amount: int) -> None:
        if self._expiry < today():
            raise ValueError("card expired")
        if amount > self._balance:
            raise ValueError("insufficient balance")
        self._balance -= amount

Step 4: let the chain change freely. The wallet can switch to a dictionary of cards or a remote bank call. Only Wallet changes; Checkout and Customer never see it.

StepClasses Checkout knows the insides ofPlaces the expiry rule existsChanging card storage edits
StartOrder, Customer, Wallet, Cardwherever anyone chargesCheckout and every other caller
1 and 2Order onlystill outside the cardWallet
3 and 4Order onlyonce, in CardWallet

Why it works

Each class exposes a verb, pay or charge, instead of its nouns. A caller tells the object what outcome it wants and lets the object decide how, which is why the slogan is "tell, don't ask". The invariant is structural: a method uses at most one dot of navigation into objects it does not own. The payoff is that the shape of the object graph becomes a private matter of each layer, so a change ripples one step, not six.

It also pulls rules to where the data is. A rule enforced by the owner cannot be bypassed; a rule enforced by a caller is only as good as the caller's discipline, and the next caller will forget it.

Where it breaks: value objects and plain data. point.x or order.lines[0].quantity on a frozen dataclass is not a problem, because there is no behaviour to centralise and no rule to protect. The law is about objects with rules. Over-applying it produces dozens of pass-through methods on classes that merely forward.

The trade-off you must say out loud

Forwarding methods cost lines, and a Customer.pay that only calls wallet.pay looks pointless until the day the customer has two wallets. Say the rule as you apply it: "I ask the nearest object that owns the data; I only navigate into values". The follow-up tends to be "isn't Customer just delegating", and the answer is that delegation is the seam where a future rule, such as a spending limit per customer, will live.

Where it shows up in the 100 Google problems

  • Design a hotel booking system: the reservation service asks hotel.reserve(room_type, dates); it never walks hotel.floors[i].rooms[j].bookings.
  • Design Splitwise: group.add_expense(expense) updates balances; the UI never reaches into group.members[k].balances[m].
  • Design a food delivery system: order.assign(rider) and rider.accept(order) keep the state change next to the state.
  • Design Stack Overflow: answer.upvote(user) adjusts the author's reputation through the author, rather than the vote handler editing answer.author.profile.points.

02Used in these problems