Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot

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 DateRange value inside Membership: 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:

  1. 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.
  2. 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.
Six CRC cards of the gym model: CheckInDesk asks Membership; Membership uses Plan, DateRange and Member; GroupClass uses Member.CheckInDesk(service)· checks a memberin by card· lists a day'scheck-insMembership(entity)· knows its planand period· freezes onceper term· renews· says if it isactive on a dayDateRange(value)· contains a day?· extended byn daysGroupClass(entity)· knows its places· books andcancels themMember(entity)· knows nameand cardPlan(value)· price and lengthis it active?

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:

The gym's CRC cards as Python classes Python · gym/gym_model.py
"""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

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 CheckInDesk or FeeCalculator.

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.

Value objects, entities and an aliasing bug Python · values/values_and_entities.py
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

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.

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.

  1. Question 1 of 6 "Members book places in group classes such as yoga or spin." Which word should not become a class?

    Choose one answer.

    Show the answer to question 1

    Answer: Yoga

    Yoga and spin are examples of group classes, values of GroupClass's name. Making them classes would mean new code for every new kind of class the gym offers.

  2. Question 2 of 6 What do the three parts of a CRC card hold?

    Choose one answer.

    Show the answer to question 2

    Answer: The class name, its responsibilities and its collaborators

    Beck and Cunningham's cards name the class, list its responsibilities as short phrases, and list the collaborators: the objects it sends messages to, or receives them from, while it does its job.

  3. Question 3 of 6 Money(50000, "INR"): an amount and a currency. What kind of object should it be?

    Choose one answer.

    Show the answer to question 3

    Answer: A value object, equal to any other Money with the same amount and currency, and never changed in place

    An amount has no identity: five hundred rupees are five hundred rupees. Equality by value and immutability are what make value objects safe to share.

  4. Question 4 of 6 What does values_and_entities.py print?

    What does this program print? Choose one answer.

    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)
    Show the answer to question 4

    Answer: it prints

    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

    Money compares by value; Booking compares by id, so B1 stays B1 after a move and B2 is a different booking. The mutable Period is one object shared by both memberships, so Asha's freeze moves Ravi's end too. The frozen period refuses the change, and replace() gives Asha a new object while Ravi keeps the old one.

  5. Question 5 of 6 A class GymManager has join(), freeze(), renew(), check_in(), book(), cancel(), refund() and report(). What is the main problem?

    Choose one answer.

    Show the answer to question 5

    Answer: It owns rules that belong to Membership, GroupClass and a check-in service, so every change touches it

    A Manager class with many unrelated responsibilities is a god class: it knows everything and every other class becomes a bag of data. Move each rule to the class whose data it needs.

  6. Question 6 of 6 Checking in needs the card index of the members, the member's membership and the day's log. Where should the check-in rule live?

    Choose one answer.

    Show the answer to question 6

    Answer: In a service, CheckInDesk, which uses those objects

    No single entity holds all three pieces, so forcing the rule into one of them would make it reach into the others. An operation that belongs to no single entity is what a service is for.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.