Low-Level Design (Object-Oriented Design) Module 4 – Design principles: SOLID and beyond
Law of Demeter, tell-don't-ask and CQS
Three rules for how objects talk: the Law of Demeter, tell-don't-ask and command-query separation, with a chain that breaks and a retry that skips a patient.
What you will learn
- Identify chained calls that couple a caller to the structure of distant objects
- Move behaviour to the object that owns the data, so that callers tell instead of ask
- Separate commands that change state from queries that return data
- Explain why a method that both changes state and returns data makes retries unsafe
Before you start
What you’ll learn · Before you start
What you will learn
- Identify chained calls that couple a caller to the structure of distant objects
- Move behaviour to the object that owns the data, so that callers tell instead of ask
- Separate commands that change state from queries that return data
- Explain why a method that both changes state and returns data makes retries unsafe
Before you start
On this page
Three rules describe how objects should talk to each other. The Law of Demeter says that a method should send messages only to the objects close to it, not to objects it reaches through them. Tell, don’t ask says that instead of reading another object’s data and deciding for it, you tell that object what to do. Command-query separation (CQS) says that a method either changes state or answers a question, never both. All three keep each piece of behaviour next to the data it needs, so that a change in how one object stores something stays inside that object.
The Law of Demeter: talk to your friends
The law began as a style rule in the Demeter project at Northeastern University, where Ian Holland formulated it in 1987. Karl Lieberherr, Ian Holland and Arthur Riel presented it at the OOPSLA conference in 1988, and Lieberherr and Holland described it again in IEEE Software in 1989. The project’s page states it generally:
Each unit should have only limited knowledge about other units: only units “closely” related to the current unit.
Its short form is “only talk to your immediate friends”. For objects, the project spells out who the friends of a method are. A method may send messages to:
- its parameters, counting the object itself (
self) as one; - the objects it creates;
- the object’s immediate parts: its attributes, what its own methods return, and the elements of a collection it holds;
- for practical reasons, global objects.
Everything reached through a friend is a stranger. In order.customer.wallet.balance, the order is a friend of the
code that received it, but the customer is a part of the order, the wallet a part of the customer and the balance a
part of the wallet. A caller that writes this chain knows that orders have one customer, that customers have one
wallet and that wallets store a number called balance. Each of those facts can change, and each change breaks the
caller.
A chain that breaks when the structure changes
A college canteen’s till takes payment for an order. The first version of the model gives every customer one
wallet. Two tills use it. pay_by_asking() reaches through the order to the wallet, compares the balance with the
total and subtracts it. pay_by_telling() calls order.pay() and leaves the rest to the objects that own the money.
Then the canteen gives every student a meal card as well as a top-up wallet, and a payment uses the meal card first.
Both tills, run against both versions of the model:
chain/main.py
"""Both tills, run against both versions of the model: a 70 order for a customer with enough money."""
import model_v1
import model_v2
from checkout import pay_by_asking, pay_by_telling
def customer(model):
if model is model_v1:
return model.Customer("Asha", balance=100)
return model.Customer("Asha", meal_card=50, top_up=50)
for model in (model_v1, model_v2):
for till in (pay_by_asking, pay_by_telling):
order = model.Order(customer(model), prices=[40, 30])
try:
result = till(order)
except AttributeError as error:
result = f"crashed: {error}"
print(f"{model.__name__}, {till.__name__}: {result}") Output
model_v1, pay_by_asking: paid model_v1, pay_by_telling: paid model_v2, pay_by_asking: crashed: 'Customer' object has no attribute 'wallet' model_v2, pay_by_telling: paid
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py
chain/checkout.py
"""Two ways for the till to take payment for an order."""
from errors import InsufficientBalance
def pay_by_asking(order):
"""Reaches through the order and the customer to the wallet, reads its state and decides."""
wallet = order.customer.wallet
if wallet.balance < order.total():
return "declined"
wallet.balance -= order.total()
return "paid"
def pay_by_telling(order):
"""Tells the order to pay; the objects that own the money decide how."""
try:
order.pay()
except InsufficientBalance:
return "declined"
return "paid" chain/model_v1.py
"""A college canteen's model, first version: every customer has one wallet."""
from errors import InsufficientBalance
class Wallet:
def __init__(self, balance):
self.balance = balance
def debit(self, amount):
if amount > self.balance:
raise InsufficientBalance(f"needs {amount}, has {self.balance}")
self.balance -= amount
class Customer:
def __init__(self, name, balance):
self.name = name
self.wallet = Wallet(balance)
def pay(self, amount):
self.wallet.debit(amount)
class Order:
def __init__(self, customer, prices):
self.customer = customer
self.prices = list(prices)
def total(self):
return sum(self.prices)
def pay(self):
self.customer.pay(self.total()) chain/model_v2.py
"""Second version: a customer has a meal card and a top-up wallet, and a payment uses the meal card first."""
from errors import InsufficientBalance
class Wallet:
def __init__(self, balance):
self.balance = balance
def debit(self, amount):
if amount > self.balance:
raise InsufficientBalance(f"needs {amount}, has {self.balance}")
self.balance -= amount
class Customer:
def __init__(self, name, meal_card, top_up):
self.name = name
self.wallets = [Wallet(meal_card), Wallet(top_up)] # the order in which a payment uses them
def pay(self, amount):
if amount > sum(wallet.balance for wallet in self.wallets):
raise InsufficientBalance(f"needs {amount}")
for wallet in self.wallets:
part = min(amount, wallet.balance)
wallet.debit(part)
amount -= part
class Order:
def __init__(self, customer, prices):
self.customer = customer
self.prices = list(prices)
def total(self):
return sum(self.prices)
def pay(self):
self.customer.pay(self.total()) chain/errors.py
"""The canteen's payment error, shared by both versions of the model and by the till."""
class InsufficientBalance(Exception):
pass Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The asking till crashes on the new model, because customer.wallet no longer exists. Its failure would have been
worse if the model had kept a wallet attribute for the top-up wallet: the till would have quietly ignored the meal
card, because the rule “meal card first” lives in Customer.pay() and the asking till has its own copy of an older
rule. The telling till needed no change at all. The change to how customers pay was made in one method, by the class
that knows how customers hold money.
Who talks to whom when the till takes a payment
Text description of the diagram
The diagram shows the same payment in two designs, one above the other. Each arrow points from the code that sends a message to the object that receives it.
- Asking: pay_by_asking(order) calls total() on the order and reads its customer, reads the customer's wallet, then reads and changes the wallet's balance itself. The till depends on three classes and on how each one stores its data.
- Telling: pay_by_telling(order) calls pay() on the order. The order calls pay(total) on its customer, and the customer calls debit(amount) on its wallet. Each object sends messages only to an object it holds, and the till depends on the order alone.
Tests show the same difference. A test of the asking till needs a fake order that returns a fake customer that
returns a fake wallet with a balance, three fakes nested inside each other. A test of the telling till needs one
fake order with a pay() method.
Common mistake
Hiding a chain behind getters, as in order.get_customer().get_wallet().get_balance(), keeps every dependency of
the chain. So does adding a forwarding method for every inner attribute, such as order.customer_wallet_balance(),
which only moves the structure into the order’s interface. Move the behaviour that needs the data, here paying,
to the object that holds the data.
Tell, don’t ask
The asking till shows the habit that tell-don’t-ask names. It asks the wallet for its balance, decides on the wallet’s behalf and then changes the wallet’s state. Martin Fowler credits the phrase to Andy Hunt and Dave Thomas, and describes it as a reminder of what object orientation is for: data and the behaviour that works on it belong together, so instead of pulling data out of an object and deciding outside it, you tell the object what you want done.
Fowler also warns against reading it as a ban on queries. He writes that he does not use tell-don’t-ask as a rule himself, but tries to put data and behaviour together, which leads to much the same place. He has seen it turn people into “GetterEradicators” who remove every query method, although asking an object for information is often the clearest design. A useful reading: when a caller reads an object’s state in order to change that same object, move the decision into the object. Reading data to show it, print it or pass it on is fine.
Command-query separation
Bertrand Meyer, in Object-Oriented Software Construction, divides methods into two kinds. A command changes the state of an object and returns nothing. A query returns a value and changes nothing that callers can observe. Fowler adds a practical exception: popping a stack both changes it and returns the top element, and he keeps that idiom while following the rule wherever he can. The benefit of the split is that queries are safe: they can be called twice, in any order, from a test or a log line, and the answer does not change because someone asked.
The cost of mixing the two shows up when something fails halfway. A clinic’s waiting room calls patients by token
number. The first queue has one method, next_and_announce(), that removes the next token and shows it on the
display. The second has a query, peek(), and a command, remove(token). The display fails on its first call, and
each caller retries once:
cqs/retry_check.py
"""The waiting-room display fails once and works the second time. The caller retries once in both designs."""
from token_queues import MixedQueue, SeparatedQueue
class FlakyDisplay:
def __init__(self):
self.calls = 0
self.shown = []
def announce(self, token):
self.calls += 1
if self.calls == 1:
raise ConnectionError("display did not answer")
self.shown.append(token)
def call_next_mixed(queue, display):
for attempt in (1, 2):
try:
return queue.next_and_announce(display)
except ConnectionError:
continue
def call_next_separated(queue, display):
for attempt in (1, 2):
token = queue.peek()
try:
display.announce(token)
except ConnectionError:
continue
queue.remove(token)
return token
for name, queue, call_next in (
("mixed", MixedQueue(["T-1", "T-2", "T-3"]), call_next_mixed),
("separated", SeparatedQueue(["T-1", "T-2", "T-3"]), call_next_separated),
):
display = FlakyDisplay()
call_next(queue, display)
print(f"{name:10} announced {display.shown}, still waiting {queue.waiting()}") Output
mixed announced ['T-2'], still waiting ['T-3'] separated announced ['T-1'], still waiting ['T-2', 'T-3']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retry_check.py
cqs/token_queues.py
"""A clinic's token queue in two designs: one method that removes and announces, or a query and a command."""
from collections import deque
class MixedQueue:
def __init__(self, tokens):
self._tokens = deque(tokens)
def next_and_announce(self, display):
"""Removes the next token AND announces it: a command and a query in one method."""
token = self._tokens.popleft()
display.announce(token)
return token
def waiting(self):
return list(self._tokens)
class SeparatedQueue:
def __init__(self, tokens):
self._tokens = deque(tokens)
def peek(self):
"""A query: which token is next? It changes nothing, so it is safe to ask again."""
return self._tokens[0]
def remove(self, token):
"""A command: the token has been called. It returns nothing."""
if self._tokens[0] != token:
raise ValueError(f"{token} is not at the front of the queue")
self._tokens.popleft()
def waiting(self):
return list(self._tokens) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With the mixed method, the first attempt removed T-1 before the display failed, so the retry removed and announced T-2, and the patient holding T-1 is never called. With the query and the command, the caller asks which token is next, announces it and removes it only once the announcement worked, so the retry announces T-1. The separated design also makes the order of steps visible in the caller, where the retry policy lives, instead of hiding it inside one method.
What is not a violation
The Law of Demeter is about objects that hide their structure. Code that walks through data whose structure is its public contract is not breaking it, and treating every chain of dots as a fault leads to absurd designs:
- Fluent builders.
Query("orders").where(paid=True).order_by("date")talks to one query object at every step, because each method returns a query. - Values and data structures.
point.x,config["db"]["host"]or a data transfer object passed between layers expose their fields on purpose, and so does a chain of string or path operations such asname.strip().lower(). - Your own parts. Inside
Room,self.minibar.total()sends a message to an object the room holds, which the law allows.
Forwarding methods have a cost too: every one of them widens the interface of the class that holds it, which the Interface Segregation Principle warns about. The fix for a chain is rarely “add a method that returns the far object”; it is to move the operation, so that the caller asks for what it needs done.
In a design interview
When you write a.b.c.do(), stop and ask which object should own do(). Say it out loud: “the order shouldn’t
know how customers store money, so the till calls order.pay() and the customer decides which wallet to use”.
For a method that both changes state and returns data, say how a caller retries it safely.
Interview questions
Warm-up (fresher to mid level): what is the Law of Demeter, and what does a violation look like? It is a style
rule from Northeastern University’s Demeter project: a method should send messages only to its friends, which are its
own object, its parameters, the objects it creates and the objects its object holds directly. Objects reached through
those friends are strangers. A violation is a chain such as order.customer.wallet.balance, where the caller depends
on how three other classes store their data, so a change to any of them, such as customers getting two wallets,
breaks it. The fix is not a getter for each step but moving the behaviour: the caller tells the order to pay, the
order tells its customer, and the customer decides which wallet to use. Fluent builders and plain data structures are
not violations.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A checkout function computes
cart.customer.membership.discount_rate()andcart.customer.address.region.tax_rate(). The business now lets a company account pay for its employees' orders, with its own discount and billing address. What breaks, and how would you redesign the checkout so that the next change like this touches one class? -
Mid to senior
A
charge_and_get_receipt(order)method charges a card and returns the receipt. When the payment provider times out, the client retries, and some customers are charged twice. How does command-query separation help here, and what else does the design need?
Key takeaways
- The Law of Demeter: a method talks to itself, its parameters, the objects it creates and the objects it holds, not to objects reached through them.
- A chain of calls couples the caller to the structure of every object along it, and needs a nested fake for each step in tests.
- Tell, don’t ask: when a caller reads an object’s state to change that object, move the decision into the object. Queries for showing or passing on data are fine.
- Command-query separation: commands change state and return nothing; queries return data and change nothing. A method that does both makes retries skip or repeat work.
- Builders, values and data structures expose their structure on purpose; move operations, not data, to fix a chain.
Exercise
Exercise · Medium · Python
Make three hotel call sites talk only to the booking
A small hotel's code has three call sites that each receive a Booking and then reach through it: invoice_total() adds up booking.room.minibar.items, welcome_line() reads booking.guest.address.city, and add_stay_points() changes booking.guest.loyalty.points itself. Each one depends on how three or four classes store their data.
Move the behaviour to the objects that own the data, so that each call site makes one call on the booking it receives and reads no attribute of an attribute. Inside the classes, an object may still use its own parts, for example a Room using its minibar. The results must stay the same: 7,700 for three nights at 2,500 with 200 from the minibar, the same welcome line, and 10 loyalty points a night. Keep the constructors of Guest, Room and Booking as they are.
The sample tests check the three results and read the source of the three call sites to count chained reads such as booking.guest.name.
Starter code · hotel.py
"""A small hotel's model and three call sites that reach through it. See the exercise prompt for what to change."""
class Address:
def __init__(self, city):
self.city = city
class Loyalty:
def __init__(self, points=0):
self.points = points
class Guest:
def __init__(self, name, city, points=0):
self.name = name
self.address = Address(city)
self.loyalty = Loyalty(points)
class Minibar:
def __init__(self):
self.items = {} # item name -> price
def take(self, item, price):
self.items[item] = price
class Room:
def __init__(self, number, rate):
self.number = number
self.rate = rate
self.minibar = Minibar()
class Booking:
def __init__(self, guest, room, nights):
self.guest = guest
self.room = room
self.nights = nights
# ---- the three call sites --------------------------------------------------------------------------------------
def invoice_total(booking):
minibar = sum(booking.room.minibar.items.values())
return booking.room.rate * booking.nights + minibar
def welcome_line(booking):
return f"Welcome, {booking.guest.name}, from {booking.guest.address.city}"
def add_stay_points(booking):
booking.guest.loyalty.points = booking.guest.loyalty.points + booking.nights * 10 The sample tests · test_hotel.py
import ast
import inspect
import hotel
from hotel import Booking, Guest, Room, add_stay_points, invoice_total, welcome_line
def make_booking():
guest = Guest("Meera", "Pune", points=40)
room = Room(204, rate=2500)
room.minibar.take("water", 40)
room.minibar.take("cashews", 160)
return Booking(guest, room, nights=3)
def chains_in(function):
"""Attribute chains such as a.b.c in a function: an attribute read from the result of another attribute."""
tree = ast.parse(inspect.getsource(function))
return [node for node in ast.walk(tree) if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Attribute)]
def test_invoice_total():
"""3 nights at 2500 plus 200 from the minibar"""
assert invoice_total(make_booking()) == 7700
def test_welcome_line():
"""the guest is greeted by name and home city"""
assert welcome_line(make_booking()) == "Welcome, Meera, from Pune"
def test_add_stay_points():
"""a stay earns 10 points a night"""
booking = make_booking()
add_stay_points(booking)
assert booking.guest.loyalty.points == 70
def test_call_sites_talk_only_to_the_booking():
"""no call site reads an attribute of an attribute: each one talks only to the booking it receives"""
for function in (invoice_total, welcome_line, add_stay_points):
found = chains_in(function)
assert found == [], f"{function.__name__} still reaches through the booking ({len(found)} chained reads)"
def test_the_model_still_builds_the_same_way():
"""Guest, Room and Booking keep their constructors, so other code that creates them still works"""
assert isinstance(hotel.Booking(Guest("Ravi", "Kochi"), Room(101, rate=1800), nights=1), Booking) A hint
Work from the inside out. Minibar can total its own items, Room can say what a stay of some nights costs, and Booking can ask its room for that; then invoice_total() only calls one method of the booking. Do the same for the welcome line (a Guest knows its name and city) and for the points: tell the guest how many points it earned rather than changing its Loyalty from outside.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- Law of Demeter: general formulation (Karl Lieberherr, Northeastern University)
- Law of Demeter: object form (Karl Lieberherr, Northeastern University)
- Object-oriented programming: an objective sense of style (OOPSLA conference proceedings (copy at Northeastern University))
- Assuring good style for object-oriented programs (IEEE Software)
- TellDontAsk (Martin Fowler)
- CommandQuerySeparation (Martin Fowler)
- Object-Oriented Software Construction, second edition (Bertrand Meyer)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress