I'll Be Backend
Principles · Python

Law of Demeter: break the getter chain

Let each class ask its neighbor, so inner changes stay inside.

11 steps4 chapters~5 min at 1.25×#coupling#encapsulation#getters#code smells
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–7

Payoff

Steps 8–9

Recap

Steps 10–11

The code: before and after

Before

@dataclass(frozen=True)
class Membership:
    level: str


@dataclass(frozen=True)
class Customer:
    name: str
    membership: Membership


@dataclass(frozen=True)
class Rental:
    car: str
    customer: Customer


class ReturnDesk:
    def late_fee(self, rental: Rental, hours: int) -> str:
        level = rental.customer.membership.level
        rate = 5 if level == "gold" else 15
        return f"{rental.car} late fee, {hours * rate} USD"


class Receipts:
    def header(self, r: Rental) -> str:
        level = r.customer.membership.level
        return f"{r.customer.name} ({level})"


def main() -> None:
    rental = Rental("Golf", Customer("Ana", Membership("gold")))
    print(ReturnDesk().late_fee(rental, 2))
    print(Receipts().header(rental))


if __name__ == "__main__":
    main()

After

@dataclass(frozen=True)
class Membership:
    level: str


@dataclass(frozen=True)
class LoyaltyAccount:
    _membership: Membership

    def level(self) -> str:
        return self._membership.level


@dataclass(frozen=True)
class Customer:
    name: str
    _account: LoyaltyAccount

    def membership_level(self) -> str:
        return self._account.level()


@dataclass(frozen=True)
class Rental:
    car: str
    _customer: Customer

    def membership_level(self) -> str:
        return self._customer.membership_level()

    def customer_name(self) -> str:
        return self._customer.name


class ReturnDesk:
    def late_fee(self, rental: Rental, hours: int) -> str:
        level = rental.membership_level()
        rate = 5 if level == "gold" else 15
        return f"{rental.car} late fee, {hours * rate} USD"


class Receipts:
    def header(self, r: Rental) -> str:
        level = r.membership_level()
        return f"{r.customer_name()} ({level})"


def main() -> None:
    account = LoyaltyAccount(Membership("gold"))
    rental = Rental("Golf", Customer("Ana", account))
    print(ReturnDesk().late_fee(rental, 2))
    print(Receipts().header(rental))


if __name__ == "__main__":
    main()

Transcript, step by step

Problem

Step 1: The return counter Before

This is the same car rental desk from last time, and now we're at its return counter. A Rental holds the car and the customer, and each Customer holds a Membership with a level. Return Desk charges gold members five dollars a late hour and everyone else fifteen. Receipts prints the customer's name and level, and main is the caller that runs both. The run prints a Golf late fee of ten dollars, and then Ana, gold. A getter is a method that only hands out a field, and in Python reading a frozen dataclass field like level does the same job. Every getter is already there, so reaching through them is the shortest line you can type.

Step 2: Memberships move into an account Change request

Marketing moves memberships into a new loyalty account. The quick edit adds a Loyalty Account dataclass that holds the membership and hands it out as a field. Customer now holds the account instead of the membership, and main builds one for Ana. Both chains grow by one getter, so each one now reaches four objects deep. The type checker passes, and the run prints the same two lines as before.

Step 3: A getter chain Code smell

A getter chain is one line that calls a getter on whatever the last getter returned. Fowler's name for this smell is Message Chains, and here Return Desk and Receipts walk through Rental and Customer to reach a Membership they were never given. You can see it in Return Desk here, in Receipts here, and in the second reach for the name here. A change inside Customer, a class neither of them owns, broke both of them. So every class that walks the chain has to be found and edited, and here a new account class meant edits in two classes that only wanted one word, the level. The Law of Demeter says a method talks to its own fields, its parameters and the objects it creates, and not to the objects those hand back. In short, talk only to your immediate friends, and the only friend each desk was given is the rental.

Refactor

Step 4: Refactor first Back it out

So let's undo the edit, refactor first, and then add the account. The chains are back to three getters, and every step from here prints the same two lines.

Step 5: Customer answers the level Hide Delegate

The first move is Hide Delegate, where a class forwards a call to an object it holds, so callers don't reach past it. Customer gets a membership level method that returns its own membership's level. Both chains now stop at the customer and ask it for the membership level. Neither desk knows a Membership exists anymore, but both still talk to the Customer.

Step 6: Rental answers too Hide Delegate

We hide the delegate once more, this time in Rental. Rental gets membership level and customer name, and each one forwards to its customer. Return Desk and Receipts now call only methods of the rental they were given.

Step 7: Mark the inner fields private Encapsulate Field

Nothing outside reads Rental's customer field or Customer's membership field anymore. Python has no getter method to delete here, so the move is Encapsulate Field, and both fields get a leading underscore. Nothing enforces it, but the underscore tells readers the field is private to its class, so no one should start the chain again. The delegates now read the renamed fields, so Rental is the only class that talks to its customer.

Payoff

Step 8: Add the account again Payoff

Now we add the loyalty account again. Loyalty Account holds the membership and answers its level. Customer holds the account, and its membership level now asks the account. Main builds the account for Ana, and the run prints the same two lines. Return Desk, Receipts and Rental did not change.

Step 9: One friend each Payoff

Return Desk now makes one call on the rental here, Receipts makes one here, and the name is one call here. The account change touched Customer, the one class that holds the account, and main, which builds it. By the Law of Demeter, each desk is given a Rental, so it talks only to the Rental. The desks no longer know a Customer or a Membership exists, so no change inside Customer or its account can reach them. To be fair, the first move alone already kept this change out of the desks. The second and third moves cut the last link they knew, the Customer, so the next change inside it, like a split of the name, stays out too.

Recap

Step 10: Where we started, where we ended Before / after

On the left, each desk walked from the rental through the customer to the membership. Receipts walked through the customer a second time to get the name. On the right, Rental answers membership level and customer name itself. So each desk makes one call on the rental and knows nothing behind it.

Step 11: When to reach for it Takeaways

Use it when a class goes through one object to get at another one's data, and does it in more than one place. Skip it for a builder or string calls like strip and lower, where each call returns a type you already depend on, so you reach into no inner structure. Skip it too for plain data records, whose fields are their public interface, because delegates would only copy them. You'll spot it by a line with several getters in a row, each called on what the last one returned.