Low-Level Design (Object-Oriented Design) Module 1 – LLD rounds and a repeatable answer method
Finding entities and assigning responsibilities
Turn requirements into the classes of a low-level design: pick out candidate nouns, drop false ones, give each rule one owner and tell entities from values.
What you will learn
- Extract candidate classes from requirements and reject false candidates
- Use CRC cards to give each responsibility to one class
- Separate entities, value objects and services
Before you start
On this page
With the requirements numbered, the next question is which classes exist and which of them owns each rule. Two quick techniques answer it under a time limit: pull candidate classes out of the words of the requirements, then give every responsibility to exactly one class with CRC cards. A third step sorts the classes into entities, value objects and services, which decides how each one is compared, changed and stored.
Candidates come from the nouns
Here is a gym prompt after clarifying:
- R1 A member joins with a plan, monthly or yearly; the plan sets the price and the length.
- R2 A membership can be frozen once per term, for 7 to 30 days; the frozen days are added to its end.
- R3 At the front desk, a member checks in by scanning a card; check-in is refused while the membership is frozen or after it has ended.
- R4 Members book places in group classes such as yoga or spin; a class has a fixed number of places.
- R5 A booking can be cancelled until 2 hours before the class starts.
- R6 Renewing adds the plan’s length to the current end of the membership.
- R7 The receptionist sees the day’s check-ins on a screen.
Underline the nouns and you get the candidates: member, plan, price, length, membership, term, end, front desk, card, check-in, group class, yoga, spin, place, booking, receptionist, screen, and the gym itself. A candidate is not yet a class. Keep the ones that hold rules or state of their own, and drop the rest for a reason you can say out loud:
Kept as classes:
- member →
Member: has an identity that lasts while its details change. - membership →
Membership: has a lifecycle (active, frozen, ended, renewed). - plan →
Plan, a value: a price and a length that many memberships share. - group class →
GroupClass: has places and bookings, and rules about both. - front desk, check-in →
CheckInDesk, a service: its rule needs several objects (see below).
Kept as values or attributes:
- term, end → a
DateRangevalue insideMembership: two dates and the questions asked about them. - price, length → attributes of
Plan: they describe a plan and decide nothing on their own. - card → an attribute of
Member, which identifies the member at the desk. - place →
GroupClass.places: a count, not a thing with behaviour. - yoga, spin → values of
GroupClass.name: examples of a class, not kinds of class, so a new one needs no new code. - booking → for now, the list of members in a
GroupClass; it becomes a class once it has a lifecycle of its own (a waitlist, attendance).
Dropped:
- receptionist: an actor who uses the system; it becomes a class only if roles and permissions matter.
- screen: user interface, not part of the domain model.
- gym: the whole system; a class named after the system ends up doing everything.
The usual false candidates are synonyms (customer and member), attributes (colour, name, price), actors who only use the system, user-interface words (screen, button, form) and implementation words (list, database, table). The verbs are worth a second pass: join, freeze, check in, book, cancel and renew become the responsibilities of the next step.
Mind Map Maker Put the prompt in the middle and branch out every noun and verb before you sort them; the map exports to Markdown for your notes.One owner per responsibility: CRC cards
CRC cards were introduced in Kent Beck and Ward Cunningham’s OOPSLA paper of 1989, about teaching object-oriented thinking. Each card is an index card with three parts: the class name, its responsibilities as a few short verb phrases, and its collaborators, the objects it sends messages to or receives them from while doing its job. The paper drives a design forward by walking through scenarios with the cards: when a needed responsibility belongs to no card, give it to an existing card or write a new one. When a card gets crowded, first try to say the same thing in fewer words; if the object is still doing too much, hand some of its responsibilities to a new card.
In an interview, CRC is a five-minute step. A responsibility is something a class knows or does. Two rules of thumb decide the owner:
- Give a rule to the class that holds the data it needs. Whether a membership is active needs its period and
its freeze, so it is
Membership.is_active_on(), not a check in the desk that reads the dates out of it. - When no class can do it without reaching into others, a class is missing. Checking in needs the card index,
the membership and the day’s log. No entity holds all three, so the rule goes to a service,
CheckInDesk.
CRC cards for the gym, with an arrow to each collaborator
Text description of the diagram
The diagram shows six cards in two columns. Each card names a class, says whether it is an entity, a value object or a service, and lists its responsibilities. An arrow points from a class to a collaborator it sends messages to.
- CheckInDesk (service) checks a member in by card and lists a day's check-ins. It asks Membership whether it is active.
- Membership (entity) knows its plan and period, freezes once per term, renews and says whether it is active on a day. Its collaborators are Plan, DateRange and Member.
- DateRange (value) answers whether it contains a day and gives a copy extended by a number of days.
- GroupClass (entity) knows its places and books and cancels them. Its collaborator is Member.
- Member (entity) knows the member's name and card.
- Plan (value) holds a price and a length.
The cards turn straight into class skeletons. Each method carries the requirement it implements, and a short scenario at the end exercises the rules, including the refusals:
"""Classes from the gym's CRC cards: each responsibility lives in exactly one class."""
from dataclasses import dataclass, replace
from datetime import date, datetime, timedelta
class GymError(Exception):
"""A request that the gym's rules refuse."""
@dataclass(frozen=True)
class DateRange: # value object: two dates, and what can be asked about them
start: date
end: date
def contains(self, day):
return self.start <= day <= self.end
def extended(self, days):
return replace(self, end=self.end + timedelta(days=days))
@dataclass(frozen=True)
class Plan: # value object: shared by every membership on the plan
name: str
price: int # in paise
days: int
class Member: # entity: the same person whatever their details become
def __init__(self, member_id, name, card):
self.member_id, self.name, self.card = member_id, name, card
class Membership: # entity: knows its period and its freeze, decides whether it is active
def __init__(self, member, plan, start):
self.member, self.plan = member, plan
self.period = DateRange(start, start + timedelta(days=plan.days - 1))
self.freeze_range = None
def freeze(self, start, days): # R2
if self.freeze_range is not None:
raise GymError("already frozen once this term")
if not 7 <= days <= 30:
raise GymError("a freeze lasts 7 to 30 days")
self.freeze_range = DateRange(start, start + timedelta(days=days - 1))
self.period = self.period.extended(days)
def renew(self): # R6
self.period = self.period.extended(self.plan.days)
def is_active_on(self, day): # used by R3
frozen = self.freeze_range is not None and self.freeze_range.contains(day)
return self.period.contains(day) and not frozen
class GroupClass: # entity: knows its places and who has booked them
def __init__(self, name, starts_at, places):
self.name, self.starts_at, self.places = name, starts_at, places
self.booked = []
def book(self, member): # R4
if len(self.booked) >= self.places:
raise GymError(f"{self.name} is full")
self.booked.append(member)
def cancel(self, member, now): # R5
if self.starts_at - now < timedelta(hours=2):
raise GymError("too late to cancel")
self.booked.remove(member)
class CheckInDesk: # service: the rule needs the card index, a membership and the day's log
def __init__(self, memberships):
self.by_card = {m.member.card: m for m in memberships}
self.log = []
def check_in(self, card, day): # R3
membership = self.by_card.get(card)
if membership is None or not membership.is_active_on(day):
raise GymError(f"card {card} may not enter")
self.log.append((day, membership.member.name))
def check_ins_on(self, day): # R7
return [name for logged, name in self.log if logged == day]
def attempt(label, action):
try:
action()
print(f"{label}: ok")
except GymError as refusal:
print(f"{label}: refused, {refusal}")
first = date(2026, 1, 1)
def day(n):
"""Day n of the term; day(1) is its first day."""
return first + timedelta(days=n - 1)
asha, ravi = Member("M1", "Asha", "C-101"), Member("M2", "Ravi", "C-102")
monthly = Plan("monthly", 150_000, 30)
asha_plan, ravi_plan = Membership(asha, monthly, first), Membership(ravi, monthly, first)
desk = CheckInDesk([asha_plan, ravi_plan])
attempt("Asha checks in on day 1", lambda: desk.check_in("C-101", day(1)))
attempt("Asha freezes 7 days from day 10", lambda: asha_plan.freeze(day(10), 7))
attempt("Asha checks in on day 12", lambda: desk.check_in("C-101", day(12)))
attempt("Asha freezes again", lambda: asha_plan.freeze(day(20), 7))
print("Asha's term now ends on day", (asha_plan.period.end - first).days + 1)
yoga = GroupClass("Yoga", datetime(2026, 1, 3, 7, 0), places=1)
attempt("Asha books yoga", lambda: yoga.book(asha))
attempt("Ravi books yoga", lambda: yoga.book(ravi))
attempt("Asha cancels at 06:00", lambda: yoga.cancel(asha, datetime(2026, 1, 3, 6, 0)))
print("check-ins on day 1:", ", ".join(desk.check_ins_on(day(1)))) Output
Asha checks in on day 1: ok Asha freezes 7 days from day 10: ok Asha checks in on day 12: refused, card C-101 may not enter Asha freezes again: refused, already frozen once this term Asha's term now ends on day 37 Asha books yoga: ok Ravi books yoga: refused, Yoga is full Asha cancels at 06:00: refused, too late to cancel check-ins on day 1: Asha
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gym_model.py
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
Each refusal comes from the class that owns its rule: the desk refuses an entry because Membership.is_active_on()
says no, the second freeze is refused by Membership itself, and the full yoga class by GroupClass. Nothing
outside a class repeats a rule, which is what makes the follow-up questions cheap: “allow two freezes per term”
changes Membership, the class that owns the freeze rule, and nothing else.
Entities, value objects and services
Three of the building blocks of Eric Evans’s Domain-Driven Design name the kinds of object that a model like this one is made of:
- An entity is defined by its identity, which continues through a lifecycle while its attributes change. A member who changes phone number is the same member; two members called Asha are different members. An entity needs a way to tell instances apart, usually an id.
- A value object is defined only by its attributes. Treat it as immutable and give it no identity: an amount of money, a date range, a time slot. Martin Fowler’s short definition: value objects are equal when the values of their properties are equal.
- A service is an operation that is an important part of the domain but is not the natural job of any one
entity or value. It gets a name of its own, such as
CheckInDeskorFeeCalculator.
Python’s data classes make value objects cheap: @dataclass(frozen=True) generates equality by fields and refuses
any assignment to a field with FrozenInstanceError. An entity compares by its id instead, and because it defines
__eq__, it must define __hash__ too, or Python makes its instances unhashable. The second half of this example
shows the classic mistake with values: a mutable object shared by two owners.
from dataclasses import FrozenInstanceError, dataclass, replace
@dataclass(frozen=True)
class Money: # value object: equal when the values are equal, never changed in place
paise: int
currency: str = "INR"
class Booking: # entity: the same booking whenever the id is the same
def __init__(self, booking_id, member, slot):
self.booking_id, self.member, self.slot = booking_id, member, slot
def __eq__(self, other):
return isinstance(other, Booking) and other.booking_id == self.booking_id
def __hash__(self):
return hash(self.booking_id)
print("Money(50000) == Money(50000):", Money(50000) == Money(50000))
first = Booking("B1", "Asha", "Mon 07:00")
moved = Booking("B1", "Asha", "Tue 07:00") # B1 again, after it was moved to Tuesday
other = Booking("B2", "Asha", "Mon 07:00") # another booking with the same details
print("B1 before and after the move:", first == moved, "| B1 and B2:", first == other)
class Period: # the mistake: a value that can be changed in place
def __init__(self, start_day, end_day):
self.start_day, self.end_day = start_day, end_day
term = Period(1, 30)
asha_term = ravi_term = term # both memberships share one object
asha_term.end_day += 7 # Asha freezes for 7 days
print("mutable period, Ravi's last day:", ravi_term.end_day)
@dataclass(frozen=True)
class FixedPeriod: # the fix: a frozen value; a change makes a new object
start_day: int
end_day: int
term = FixedPeriod(1, 30)
asha_term = ravi_term = term
try:
asha_term.end_day += 7
except FrozenInstanceError as refusal:
print("frozen period refuses the change:", refusal)
asha_term = replace(asha_term, end_day=asha_term.end_day + 7)
print("frozen period, Asha's last day:", asha_term.end_day,
"| Ravi's last day:", ravi_term.end_day) Output
Money(50000) == Money(50000): True B1 before and after the move: True | B1 and B2: False mutable period, Ravi's last day: 37 frozen period refuses the change: cannot assign to field 'end_day' frozen period, Asha's last day: 37 | Ravi's last day: 30
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 values_and_entities.py
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
Both memberships pointed at one Period object, so freezing Asha gave Ravi seven extra days. Fowler names this an
aliasing bug and recommends exactly the fix shown: make value objects immutable, so that a change produces a new
object (dataclasses.replace()) and nobody else’s value moves.
Watch out for Manager and Handler classes
A class called GymManager, BookingHandler or OpdSystem is usually a god class: it ends up with join(),
freeze(), renew(), check_in(), book(), cancel(), refund() and report(), while the other classes become
bags of data. Every change touches it, and it cannot be tested in parts. Two signs to watch for while drawing CRC
cards: a card with more than about five responsibilities, and a card whose collaborators are all the other cards.
Move each rule to the class whose data it uses, and keep a coordinating service thin.
Where this goes next
For the basics of writing classes, cohesion and CRC cards, see the object-oriented programming track. Later in this track, the domain-modelling module returns to entities and value objects, adds aggregates and repositories, and shows how they map to tables.
Key takeaways
- Nouns give candidate classes; keep those with state or rules of their own, and drop synonyms, attributes, actors, user-interface and implementation words, each for a reason you can say.
- On CRC cards, every responsibility has exactly one owner: the class that holds the data the rule needs.
- When a rule needs several objects that no entity holds together, it belongs to a service.
- Entities are compared by identity; value objects by their values, and they should be immutable to avoid aliasing bugs.
- A Manager or Handler with a long list of methods is a god class in disguise: split it along the data.
Exercise
Exercise · Medium · Python
Give each requirement of an outpatient clinic one owner
A hospital's outpatient department (OPD) gives patients numbered tokens for a doctor's session. After clarifying, the requirements are:
- R1 A patient registers once with a name and a phone number and gets a patient number.
- R2 A session is one doctor, on one day, from a start time, in one department.
- R3 A patient books a token for a session; tokens are numbered 1, 2, 3 … in booking order.
- R4 A session has a fixed number of tokens; when they are all booked, booking is refused.
- R5 A token can be cancelled until it is called.
- R6 At the clinic, the front desk marks a token as arrived.
- R7 The doctor calls the next arrived token, in token order.
- R8 The doctor writes a prescription for the visit.
- R9 The fee depends on the department; a follow-up within 7 days of a visit to the same department is half price.
- R10 The front desk sees the arrived tokens of a session, in order.
Find the classes and give every requirement to the one class that owns it. Write the model in opd_model.py as the dictionary MODEL: each key is a class name, and each value says its "kind" ("entity", "value" or "service"), the requirements it "owns" and the other classes it sends messages to ("collaborators"). Patient is done for you.
The sample tests check that each requirement has exactly one owner, that no class owns more than five requirements, that every collaborator is another class of the model, and that no class has a god-class name such as OpdManager. They also check two choices that the lesson's rules of thumb decide: a rule goes to the class that has the data it needs, so the rules that need every token of a session (R3, R4 and R7) have one owner; and an operation that needs several objects no entity holds together belongs to a service. The tests cannot judge your other choices: compare them with the same two rules.
Starter code · opd_model.py
"""The class model of the prompt's outpatient department (OPD): who owns each requirement."""
MODEL = {
"Patient": {"kind": "entity", "owns": ["R1"], "collaborators": []},
# Add the other classes here.
} The sample tests · test_opd_model.py
from opd_model import MODEL
REQUIREMENTS = [f"R{n}" for n in range(1, 11)]
KINDS = {"entity", "value", "service"}
GOD_CLASS_WORDS = ("Manager", "Handler", "System", "Hospital", "Controller")
def owners_of(requirement):
return [name for name, card in MODEL.items() if requirement in card["owns"]]
def test_every_requirement_has_one_owner():
"""each requirement, R1 to R10, is owned by exactly one class"""
owners = {r: owners_of(r) for r in REQUIREMENTS}
assert {r: o for r, o in owners.items() if len(o) != 1} == {}
def test_only_known_requirements():
"""classes own only the requirements R1 to R10"""
unknown = sorted({r for card in MODEL.values() for r in card["owns"]} - set(REQUIREMENTS))
assert unknown == []
def test_no_class_owns_more_than_five():
"""no class owns more than five requirements"""
assert {name: len(card["owns"]) for name, card in MODEL.items() if len(card["owns"]) > 5} == {}
def test_kinds():
"""every class is an entity, a value or a service"""
assert {name: card["kind"] for name, card in MODEL.items() if card["kind"] not in KINDS} == {}
def test_collaborators_are_other_classes():
"""every collaborator is another class of the model"""
for name, card in MODEL.items():
for other in card["collaborators"]:
assert other in MODEL and other != name, f"{name} collaborates with {other!r}"
def test_no_god_class_names():
"""no class is named like a god class (Manager, Handler, System …)"""
assert [name for name in MODEL if any(word in name for word in GOD_CLASS_WORDS)] == []
def test_session_token_rules_have_one_owner():
"""R3, R4 and R7 all need every token of a session, so one class owns all three"""
owners = sorted({name for r in ("R3", "R4", "R7") for name in owners_of(r)})
assert len(owners) == 1, f"R3, R4 and R7 are owned by {owners or 'no class'}"
def test_fee_rule_belongs_to_a_service():
"""R9 needs a department's fee and the patient's earlier visits, so a service owns it"""
kinds = {name: MODEL[name]["kind"] for name in owners_of("R9")}
assert list(kinds.values()) == ["service"], f"R9 is owned by {kinds or 'no class'}" A hint
Start from the nouns: patient, session, token, visit, prescription, fee, department, doctor. Several requirements are about a session's tokens as a whole (numbering, the limit, the next arrived token, the arrived list), so they fit the class that holds all of a session's tokens. The fee rule needs a department's fee and the patient's earlier visits, which no single entity holds.
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
- A Laboratory For Teaching Object-Oriented Thinking (OOPSLA '89) (Kent Beck and Ward Cunningham)
- Domain-Driven Design Reference (Domain Language (Eric Evans))
- Value Object (Martin Fowler)
- dataclasses: Data Classes (Python Software Foundation)
- Data model: object.__hash__ (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress