Low-Level Design (Object-Oriented Design) Module 4 – Design principles: SOLID and beyond
GRASP: assigning responsibilities
GRASP in practice: decide which class should do what with Information Expert, Creator, Controller and six more patterns, each one checked by running code.
What you will learn
- Apply Information Expert, Creator and Controller to decide where a method belongs
- Use Low Coupling and High Cohesion to judge between two candidate owners
- Use a Pure Fabrication or an Indirection when no domain class fits
- Justify each assignment in an interview answer with one GRASP name and one reason
Before you start
What you’ll learn · Before you start
What you will learn
- Apply Information Expert, Creator and Controller to decide where a method belongs
- Use Low Coupling and High Cohesion to judge between two candidate owners
- Use a Pure Fabrication or an Indirection when no domain class fits
- Justify each assignment in an interview answer with one GRASP name and one reason
Before you start
On this page
Every low-level design is a long series of small decisions about the same question: which class should do this? GRASP gives the good answers names. Craig Larman collected them in his book Applying UML and Patterns as nine General Responsibility Assignment Software Patterns: Information Expert, Creator, Controller, Low Coupling, High Cohesion, Polymorphism, Pure Fabrication, Indirection and Protected Variations. Each one turns a placement into a reason you can say in a sentence, such as “the booking adds up its total because it holds the lines”.
Responsibilities, and nine ways to place them
A responsibility is something a class knows or does. Knowing covers its own data and what it can work out from it; doing covers acting, creating other objects and coordinating them. The previous lessons asked how to split classes. GRASP starts from a responsibility you already have, such as “compute the bill” or “start a rental”, and asks who should get it. Each pattern answers one form of that question:
- Information Expert. Who should do this? The class that has the data the job needs.
- Creator. Who should create an object? A class that contains it, records it, works closely with it or has the data to start it.
- Controller. Who receives a request from the user interface? An object behind the interface that stands for the system or for one use case, and delegates.
- Low Coupling. Which of two options is better? The one that adds fewer dependencies.
- High Cohesion. Which of two options is better? The one that keeps each class focused on one job.
- Polymorphism. Where does behaviour that varies by type go? Into the types, not into switch statements.
- Pure Fabrication. What if no domain class fits? An invented class, such as a repository or a printer, that keeps the domain classes cohesive.
- Indirection. How do two parts avoid depending on each other? An object between them, such as an adapter.
- Protected Variations. How is a design protected from a change that is expected? A stable interface around the point that varies.
Two of the nine, Low Coupling and High Cohesion, are not answers but judges: they decide between the candidates that the others suggest. The first lesson of this module defined both.
Five decisions in a cinema booking model
A cinema sells seats for its shows. Recliners and regular seats have different prices, a customer books several seats at once, the card is charged, and a ticket is printed. Here is a small model whose comments name the pattern behind each placement, and a script that checks five of the decisions by running them:
booking/decisions.py
"""Five "who should do this?" decisions in the cinema model, each checked by running the code."""
from cinema import Booking, BookingService, PaymentDeclined, Seat, SeatTaken, Show, TicketPrinter
class RecordingGateway:
def __init__(self):
self.charges = []
def charge(self, amount, reference):
self.charges.append((amount, reference))
return f"pay-{len(self.charges)}"
class DecliningGateway:
def charge(self, amount, reference):
raise PaymentDeclined("card declined")
def new_show():
seats = [Seat("A1", "recliner"), Seat("A2", "recliner"), Seat("B4", "regular")]
return Show("show-7", seats, prices={"recliner": 220, "regular": 100})
def check(name, passed, detail):
print(f"{'ok' if passed else 'FAILED':6} {name}: {detail}")
show = new_show()
booking = show.book(["A1", "A2", "B4"], "Asha")
check("Information Expert", booking.total() == 540, f"Booking.total() adds its own lines: {booking.total()}")
check("Creator", isinstance(booking, Booking) and not show.is_free("A1"), "Show.book() creates the Booking from its seats and prices")
gateway = RecordingGateway()
service = BookingService([new_show()], gateway, TicketPrinter())
ticket = service.book_seats("show-7", ["B4"], "Ravi")
check("Controller", gateway.charges == [(100, "show-7:Ravi")], "BookingService.book_seats() takes the event, delegates and charges once")
check("Pure Fabrication", ticket == "show-7 | Ravi | seats B4 | paid 100" and not hasattr(Booking, "render"), "TicketPrinter formats the ticket; Booking has no layout code")
show = new_show()
declining = BookingService([show], DecliningGateway(), TicketPrinter())
try:
declining.book_seats("show-7", ["A1"], "Meera")
except PaymentDeclined:
pass
check("Protected Variations", show.is_free("A1"), "any PaymentGateway fits; after a declined card the seat is free again")
try:
new_show().book(["A1", "A1"], "Kabir")
duplicate = "accepted"
except SeatTaken:
duplicate = "refused"
print(f"booking the same seat twice in one request: {duplicate}") Output
ok Information Expert: Booking.total() adds its own lines: 540 ok Creator: Show.book() creates the Booking from its seats and prices ok Controller: BookingService.book_seats() takes the event, delegates and charges once ok Pure Fabrication: TicketPrinter formats the ticket; Booking has no layout code ok Protected Variations: any PaymentGateway fits; after a declined card the seat is free again booking the same seat twice in one request: refused
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 decisions.py
booking/cinema.py
"""A small cinema booking model. Each responsibility sits where a GRASP pattern puts it (comments name the pattern)."""
from dataclasses import dataclass
from typing import Protocol
class SeatTaken(Exception):
pass
class PaymentDeclined(Exception):
pass
@dataclass(frozen=True)
class Seat:
seat_id: str
category: str # "recliner" or "regular"
@dataclass(frozen=True)
class BookedSeat:
seat: Seat
price: int
class Booking:
def __init__(self, customer, show_id, lines):
self.customer = customer
self.show_id = show_id
self.lines = list(lines)
def total(self):
# Information Expert: the booking holds its lines, so it adds them up.
return sum(line.price for line in self.lines)
def seat_ids(self):
return [line.seat.seat_id for line in self.lines]
class Show:
def __init__(self, show_id, seats, prices):
self.show_id = show_id
self._seats = {seat.seat_id: seat for seat in seats}
self._prices = dict(prices) # category -> price
self._taken = set()
def is_free(self, seat_id):
# Information Expert: the show knows which of its seats are taken.
return seat_id not in self._taken
def book(self, seat_ids, customer):
# Creator: the show has the data a booking starts from (its seats and prices), so it creates the Booking.
if len(set(seat_ids)) != len(seat_ids):
raise SeatTaken("the same seat appears twice in one request")
taken = [seat_id for seat_id in seat_ids if not self.is_free(seat_id)]
if taken:
raise SeatTaken(", ".join(taken))
self._taken.update(seat_ids)
seats = [self._seats[seat_id] for seat_id in seat_ids]
return Booking(customer, self.show_id, [BookedSeat(seat, self._prices[seat.category]) for seat in seats])
def release(self, booking):
self._taken.difference_update(booking.seat_ids())
class PaymentGateway(Protocol):
# Protected Variations and Indirection: payment providers change, so the service depends on this interface.
def charge(self, amount: int, reference: str) -> str: ...
class TicketPrinter:
# Pure Fabrication: no cinema concept "prints"; this class exists so that Booking stays free of text layout.
def render(self, booking):
seats = ", ".join(booking.seat_ids())
return f"{booking.show_id} | {booking.customer} | seats {seats} | paid {booking.total()}"
class BookingService:
# Controller: the first object behind the user interface that receives the "book seats" system event. It
# coordinates and decides nothing that another object knows better.
def __init__(self, shows, gateway: PaymentGateway, printer: TicketPrinter):
self._shows = {show.show_id: show for show in shows}
self._gateway = gateway
self._printer = printer
def book_seats(self, show_id, seat_ids, customer):
show = self._shows[show_id]
booking = show.book(seat_ids, customer)
try:
self._gateway.charge(booking.total(), reference=f"{show_id}:{customer}")
except PaymentDeclined:
show.release(booking)
raise
return self._printer.render(booking) 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
- Information Expert.
Booking.total()adds the prices of its own lines. The show is the expert on which of its seats are free, sois_free()belongs toShow. - Creator.
Show.book()creates theBooking, because the show has what a booking starts from: the seats and their prices. Any other creator would have to ask the show for them first. - Controller.
BookingService.book_seats()receives the “book seats” request from whatever user interface exists, finds the show, asks it to book, charges and asks for a ticket. It works nothing out itself. - Pure Fabrication. No cinema concept prints tickets.
TicketPrinteris invented so thatBookingdoes not change every time the ticket layout does. - Protected Variations. Payment providers change, so the service depends on a
PaymentGatewayinterface. A declined card releases the seats, and any gateway, real or fake, fits.
The last line of the output is an edge case that a first version of this model got wrong: a request for the same
seat twice was accepted and charged twice. Where should the check go? The show is the expert on its seats, so the
fix is two lines in Show.book(), not a check in the controller that every other caller of book() would miss.
Who does what in the cinema model, and the GRASP pattern behind each choice
Text description of the diagram
The diagram shows the cinema booking model in two panels, one above the other. Each arrow points from the object that calls or creates to the one it calls or creates.
- Booking seats: the screen or command line sends the "book seats" event to BookingService, the Controller. BookingService calls book() on the Show. The Show is the Creator of the Booking, because it has the seats and prices a booking starts from, and the expert on which of its seats are free. The Booking is the expert on its own total.
- Around the booking: BookingService calls render() on TicketPrinter, a Pure Fabrication that keeps text layout out of the Booking, and charge() on PaymentGateway, an interface that protects the service from changes of payment provider (Protected Variations). A card gateway implements that interface in production, and a fake one in tests.
Moving work to the expert
A common first design gives the controller the work, because the controller is where the request arrives. In this
bookshop checkout, the controller adds up the order by reading every line’s price and quantity, and applies the rule
“10 % off a line of three or more copies”. The second version moves the line’s arithmetic into Line.subtotal() and
the sum into Order.total(). The same checks run against both, and the script reports where each version keeps the
discount rule:
expert/same_checks.py
"""The same checks against both versions, and where each version keeps the discount rule."""
import ast
from pathlib import Path
import after
import before
def run_checks(module):
controller = module.CheckoutController()
order = module.Order([module.Line("Godan", 300, 1), module.Line("Malgudi Days", 250, 4)])
results = [
controller.total(module.Order([])) == 0,
controller.total(module.Order([module.Line("Godan", 300, 2)])) == 600,
controller.total(order) == 300 + 900, # 4 x 250 = 1000, less 10 %
controller.summary(order) == "2 titles, total 1200",
]
return sum(results), len(results)
def where_the_rule_lives(module):
"""The class and method that compare a quantity with 3: the discount rule."""
tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8"))
for cls in (node for node in tree.body if isinstance(node, ast.ClassDef)):
for method in (node for node in cls.body if isinstance(node, ast.FunctionDef)):
for node in ast.walk(method):
if isinstance(node, ast.Compare) and any(getattr(c, "value", None) == 3 for c in node.comparators):
return f"{cls.name}.{method.name}()"
for module in (before, after):
passed, total = run_checks(module)
print(f"{module.__name__}.py: {passed} of {total} checks pass; the discount rule is in {where_the_rule_lives(module)}") Output
before.py: 4 of 4 checks pass; the discount rule is in CheckoutController.total() after.py: 4 of 4 checks pass; the discount rule is in Line.subtotal()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 same_checks.py
expert/before.py
"""A bookshop's checkout, first version: the controller computes the total from the order's data."""
class Line:
def __init__(self, title, price, quantity):
self.title = title
self.price = price
self.quantity = quantity
class Order:
def __init__(self, lines):
self.lines = list(lines)
class CheckoutController:
def total(self, order):
total = 0
for line in order.lines:
amount = line.price * line.quantity
if line.quantity >= 3:
amount -= amount // 10 # 10 % off a line of three or more copies
total += amount
return total
def summary(self, order):
return f"{len(order.lines)} titles, total {self.total(order)}" expert/after.py
"""The same checkout after moving each calculation to the class that has the data (Information Expert)."""
class Line:
def __init__(self, title, price, quantity):
self.title = title
self.price = price
self.quantity = quantity
def subtotal(self):
amount = self.price * self.quantity
if self.quantity >= 3:
amount -= amount // 10 # 10 % off a line of three or more copies
return amount
class Order:
def __init__(self, lines):
self.lines = list(lines)
def total(self):
return sum(line.subtotal() for line in self.lines)
class CheckoutController:
def total(self, order):
return order.total()
def summary(self, order):
return f"{len(order.lines)} titles, total {order.total()}" 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
Nothing a caller sees has changed, and the rule now lives with the data it reads. A second caller that needs an
order’s total, such as an invoice, calls order.total() instead of copying the loop, and a change to the discount
touches one method.
When the expert is the wrong answer
Information Expert, followed alone, would put strange things in strange places. An Order has all the data needed to
save itself to a database, and a Booking all the data a ticket shows. If they did those jobs, Order would change
whenever the database does and Booking whenever the ticket layout does, and both would depend on code that has
nothing to do with orders or bookings. That is where the judges step in: High Cohesion and Low Coupling overrule the
expert, and a Pure Fabrication takes the job. A repository saves orders; a printer prints tickets. They are invented
classes, and they are legitimate because they keep the domain classes focused.
Common mistake
A controller that works everything out itself grows into the class that every change touches: it computes fees, picks seats, formats tickets and talks to the payment provider. Keep controllers thin. Each line of a controller method should be a call to an object that knows more about that step than the controller does.
Controllers, polymorphism and protected variations
Larman describes two kinds of controller. A facade controller stands for the whole system or a large part of it,
such as BookingService. A use-case controller stands for one scenario, such as RefundHandler, and suits systems
with many use cases. In a machine-coding round the service layer called by your command parser plays this role.
The other three patterns appeared earlier in this module under other names. Polymorphism is the vehicle types of the Open/Closed lesson: behaviour that varies by type goes into the types. Indirection is any object placed between two others so that they do not depend on each other, as an adapter between the booking service and a payment provider does. Protected Variations asks you to find the points where change is expected, such as the payment provider or the pricing rules, and to put a stable interface around each. Larman’s column on it in IEEE Software is called “Protected Variation: The Importance of Being Closed”, a nod to the Open/Closed Principle, and the idea goes back to Parnas’s advice to hide each design decision that is likely to change.
In a design interview
Say the reason as you place each method: “the show creates the booking because it has the seats and the prices”; “the service is the controller, so it only coordinates”; “the printer is a fabrication, so the booking has no layout code”. One pattern name and one reason per decision is enough, and it answers the follow-up “why did you put that there?” before it is asked.
Interview questions
Warm-up (fresher to mid level): what is GRASP, and what does Information Expert say? GRASP is Craig Larman’s set of nine patterns for deciding which class gets a responsibility: Information Expert, Creator, Controller, Low Coupling, High Cohesion, Polymorphism, Pure Fabrication, Indirection and Protected Variations. Information Expert says to give a responsibility to the class that has the information needed to carry it out, so a booking computes its own total from its lines instead of a controller reading every line. It is usually the first pattern to try, because it keeps behaviour next to its data and needs no extra dependencies. It is overruled when following it would hurt cohesion or coupling: an order has the data to save itself, but saving belongs to a repository, a Pure Fabrication, so the order does not depend on the database.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
In a machine-coding round your
ParkingServicehas grown to 600 lines: it computes fees, picks free spots, prints tickets and calls the payment API. Using GRASP, how would you redistribute these responsibilities, and how would you justify each move in one sentence? -
Fresher to mid
A candidate argues that
Ordershould havesave()andto_pdf()methods, because by Information Expert it has all the data they need. Do you agree? Answer with GRASP, and say where those two responsibilities should go.
Key takeaways
- GRASP names nine ways to decide which class gets a responsibility; Low Coupling and High Cohesion judge between the options the others suggest.
- Information Expert: give the job to the class with the data. Creator: let the class that contains, records or initialises an object create it.
- Controller: one object behind the user interface receives each request and delegates; it decides nothing that another object knows better.
- Pure Fabrication and Indirection are legitimate invented classes when a domain class would lose cohesion or gain a dependency.
- Protected Variations puts a stable interface around each expected change, the idea behind many design patterns.
Exercise
Exercise · Medium · Python
Place two car-rental responsibilities with GRASP
A car-rental company's model has a Car, a Rental and a RentalDesk. Two responsibilities are still missing, and the classes they belong to are already chosen:
- Working out what a rental costs, in
Rental.amount_due(km_driven). The rental knows its days and its car, and the car knows its rates, so the rental is the Information Expert. A rental costs the car'sdaily_ratefor each day, plusextra_km_ratefor every kilometre driven above the allowance, which iskm_per_dayfor each day. - Starting a rental, split between two classes.
Car.rent_to(customer, days)creates theRental(Creator: the car has the data a rental starts from): it raisesValueErrorwhendaysis below 1 andCarUnavailablewhen the car is already rented, and otherwise marks the car as rented and returns the new rental.RentalDesk.start_rental(customer, plate, days)receives the request from the counter (Controller): it finds the car by its plate, asks it to rent itself, records the rental inrentalsand returns it.
The sample tests check three bills, a recorded rental, a refused second rental of the same car and a rental of 0 days.
Starter code · car_rental.py
"""A car-rental model. Write the three methods that the exercise prompt describes; the rest is done."""
class CarUnavailable(Exception):
pass
class Rental:
def __init__(self, customer, car, days):
self.customer = customer
self.car = car
self.days = days
def amount_due(self, km_driven):
"""Write this: the car's daily rate for each day, plus its extra-kilometre rate for every kilometre driven
above the allowance of km_per_day for each day."""
return 0
class Car:
def __init__(self, plate, daily_rate, km_per_day, extra_km_rate):
self.plate = plate
self.daily_rate = daily_rate
self.km_per_day = km_per_day
self.extra_km_rate = extra_km_rate
self.rented = False
def rent_to(self, customer, days):
"""Write this: refuse with ValueError when days is below 1, and with CarUnavailable when the car is already
rented; otherwise mark the car as rented and return a new Rental."""
return None
class RentalDesk:
def __init__(self, cars):
self._cars = {car.plate: car for car in cars}
self.rentals = []
def start_rental(self, customer, plate, days):
"""Write this: find the car by its plate, ask it to rent itself, record the rental and return it."""
return None The sample tests · test_car_rental.py
from car_rental import Car, CarUnavailable, Rental, RentalDesk
def hatchback():
return Car("KA-01-AB-1234", daily_rate=1800, km_per_day=250, extra_km_rate=12)
def test_amount_within_the_allowance():
"""3 days and 600 km stay inside the 750 km allowance: 3 x 1800"""
assert Rental("Asha", hatchback(), days=3).amount_due(km_driven=600) == 5400
def test_amount_at_the_allowance():
"""exactly 750 km in 3 days costs nothing extra"""
assert Rental("Asha", hatchback(), days=3).amount_due(km_driven=750) == 5400
def test_amount_with_extra_kilometres():
"""900 km in 3 days is 150 km over the allowance, at 12 a kilometre"""
assert Rental("Asha", hatchback(), days=3).amount_due(km_driven=900) == 5400 + 150 * 12
def test_the_desk_starts_and_records_a_rental():
"""the desk finds the car, the car creates the rental, the desk records it"""
car = hatchback()
desk = RentalDesk([car])
rental = desk.start_rental("Ravi", "KA-01-AB-1234", days=2)
assert isinstance(rental, Rental)
assert (rental.customer, rental.car, rental.days) == ("Ravi", car, 2)
assert car.rented is True
assert desk.rentals == [rental]
def test_a_rented_car_is_refused():
"""a second rental of the same car is refused and nothing is recorded"""
desk = RentalDesk([hatchback()])
desk.start_rental("Ravi", "KA-01-AB-1234", days=2)
try:
desk.start_rental("Meera", "KA-01-AB-1234", days=1)
except CarUnavailable:
pass
else:
raise AssertionError("a car that is already rented must be refused")
assert len(desk.rentals) == 1
def test_days_must_be_at_least_one():
"""a rental of 0 days is refused"""
try:
hatchback().rent_to("Kabir", days=0)
except ValueError:
pass
else:
raise AssertionError("days below 1 must raise ValueError") A hint
For the bill, compute the allowance first (km_per_day times the days), then the extra kilometres, which are never negative: max(0, km_driven - allowance). For a new rental, keep the checks in Car.rent_to(), because the car is the one that knows whether it is free; the desk only finds the car, calls it and records what it returns.
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
- Applying UML and Patterns: An Introduction to Object-Oriented Analysis and Design and Iterative Development, third edition (Craig Larman, Pearson)
- Protected Variation: The Importance of Being Closed (IEEE Software)
- On the Criteria To Be Used in Decomposing Systems into Modules (Communications of the ACM (copy at Eindhoven University of Technology))
- ast: abstract syntax trees (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress