Engineering Atlas › Low-Level Design

Turning a problem statement into classes and links

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Start from use cases, not from the domain

"Design a gym booking system" invites you to list everything a gym contains: rooms, mats, lockers, towels. None of that matters until a use case touches it. Write five to eight use cases first, each as actor, verb and object ("member books a session", "member cancels up to 2 hours before start", "instructor sees the roster", "system promotes the first waitlisted member when a spot frees up"). How to approach OOD Interviews covers this step; this lesson goes deep on the next one, turning those sentences into a model.

Noun extraction, then the filter

Underline every noun phrase in the use cases. You will get twenty or so. Then run each through five questions, in this order:

  1. Is it outside the system? "Member" as a person is outside; "member account" with a credit balance is inside. Keep the inside version and name it for what the system stores.
  2. Is it an attribute of something else? "Start time", "capacity", "email" describe another noun. They become fields, or small value objects when they carry rules (a TimeSlot that must end after it starts).
  3. Is it a synonym? "Class", "session" and "slot" in the same prompt may be one thing, or "class" may be the recurring template (Spin, Tuesdays 7am) while "session" is one occurrence. Ask. That question often decides the design.
  4. Is it an implementation detail? "Database", "list", "screen" and "notification email" are mechanisms, not domain concepts.
  5. Does it hold changing state or enforce a rule? If neither, it is probably a value or nothing.

What survives is your candidate class list. Expect six to ten for an interview problem; twenty means you kept attributes.

Entity, value object or enum

For each survivor, ask: if every field changed, would it still be the same one? A member who changes name, email and plan is still the same member, so Member is an entity with an id. Two TimeSlot(07:00, 08:00) values are interchangeable, so it is a value object compared by its fields. BookingStatus (confirmed, waitlisted, cancelled, attended) is a closed set of names, so it is an enum.

The test matters because each kind behaves differently. Entities need a stable id, an equality rule based on that id, and usually a lifecycle (a state machine; see State Machine Diagram). Value objects should be immutable: you replace a TimeSlot, you never edit one, which removes a whole class of aliasing bugs. Enums give exhaustiveness checks. Classes and Objects and Enums cover the language mechanics.

Some nouns are entities in one system and values in another. A banknote is a value to a shop (any 10-euro note will do) and an entity to a central bank tracking serial numbers. Model what the use cases need.

Identity: natural or surrogate

An entity needs an identifier that never changes. Natural keys (an email, an ISBN, a licence plate) look tempting but change in practice: people change emails, plates are reissued. Use a surrogate id generated by the system, and store the natural key as a unique, editable attribute. In the code, equality and hashing for entities use the id only; for values, all fields.

Verbs that become classes

Now read the verbs. "Member books session" relates two entities, but the booking has its own data: when it was made, its status, its position on a waitlist, the credits it used. That makes it an association class: a class that represents the relationship itself. The pattern repeats everywhere: Enrollment between student and course, Loan between member and copy, Membership between user and team, Assignment between driver and ride.

A useful rule: every many-to-many relationship in a first draft hides an association class. Members book many sessions; sessions have many members. The Booking in the middle turns one many-to-many into two one-to-many relationships, and gives the history a home.

Relationships, direction and multiplicity

For each pair of classes that a use case connects, decide three things.

  1. Kind. Composition (the part dies with the whole: a session's bookings), aggregation or plain association (a session refers to an instructor who exists independently), or a dependency used only inside a method (a pricing function). Association, Aggregation and Composition each have a lesson.
  2. Multiplicity. One session has 0 to 20 confirmed bookings and 0 to n waitlisted ones; one booking has exactly one member.
  3. Direction. Who needs to navigate to whom? If no use case asks "which bookings does this instructor have?", the instructor does not hold a list. Every bidirectional link is two references to keep consistent; add the second direction only when a use case needs it, and prefer a query over a stored back-reference.

Aggregates: ownership decided by invariants

The most useful question in modelling is "which objects must change together to keep a rule true?" For the gym: a session never has more confirmed bookings than its capacity, and when a booking is cancelled, the first waitlisted booking is promoted. Both rules involve the session, its bookings and its waitlist, and nothing else. Those three form one aggregate, with Session as the root: all changes go through session.book(member_id, now) and session.cancel(booking_id, now), which lets the root check the rules in one place.

Three rules follow, from Eric Evans' domain-driven design:

Roles, not subclasses

"An instructor can also be a member" tempts a Person base class with Member and Instructor subclasses. Then one human needs two objects, or multiple inheritance. Model roles as separate entities that refer to an Account (or just share a person id): MemberProfile(account_id, credits) and InstructorProfile(account_id, certifications). Inheritance is for "is a kind of" that never changes; a role someone can gain or lose is composition. Composing Objects Principle covers why.

Time and history

If a use case asks "what was the price when this was booked?", the price cannot be a single mutable field on the class. Either copy it into the booking (a snapshot, the usual answer) or model PriceSchedule entries with validity ranges. Ask about history early; it changes the model.

Working with an AI assistant

Ask an assistant to "model a gym booking system" and you get fifteen classes including GymManager, Locker and Towel, every relation bidirectional, and Person with three subclasses. Use it to brainstorm nouns, then run the filter yourself, and ask it to justify each aggregate boundary by naming the invariant. If it cannot name one, the boundary is a guess.

The same shape elsewhere

"Who owns what, and what dies with it" is the same question in many systems. Kubernetes objects carry metadata.ownerReferences: deleting a Deployment garbage-collects its ReplicaSets and their Pods, while a Pod that only mounts a ConfigMap is untouched. Relational schemas use ON DELETE CASCADE for composition and plain foreign keys for association. Filesystems use directories as owners and symlinks as references. The practice task checks exactly these ownership rules on a model.

Down the stack

Layer 1The contract

A model makes testable promises. Every entity has an id that never changes and defines equality. Every value is immutable and equal by fields. Every invariant is enforced by exactly one aggregate root, so no sequence of public calls on the roots can break it. Every object is reachable from exactly one root through ownership, and references across aggregates go by id to roots only. A violation of any of these is a bug you can write a test for: two owners for one object, a reference to another aggregate's internals, a value with an edit method.

Layer 2The mechanism

In memory, ownership is a tree of strong references from the root, and cross-aggregate links are ids resolved through a repository (a map from id to root; see Repository Pattern). ORMs encode the same choices: JPA's @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) means composition, a plain @ManyToOne is association, and Hibernate's identity map guarantees one in-memory object per row id inside a session. In SQL, composition is a child table whose foreign key has ON DELETE CASCADE, association is a foreign key without it, and an association class is a join table with extra columns (booking(id, session_id, member_id, status, created_at)). Event Storming, a workshop technique, finds aggregates by grouping commands and events around the rule that accepts or rejects them, which is the invariant test done on sticky notes.

Layer 3The machine

Aggregate boundaries become lock and transaction boundaries. Booking the last spot is one SELECT ... FOR UPDATE on the session row, or a version check on it: Postgres row locks live in the tuple header (xmax), so locking one session costs nothing for the other 299 sessions that day. Make Gym the root and every booking serialises on one row. In memory, an object reference is an 8-byte pointer; an id reference is a hash lookup, about 20 to 100 ns depending on whether it misses cache. That cost is why designs hold pointers inside an aggregate and ids across them. Object overhead is real at scale: a Python object with a __dict__ costs over 100 bytes, a Java object header is 12 to 16 bytes, a Go struct has none, so one million bookings as small objects is roughly 50 to 200 MB depending on language. Bidirectional pointer graphs also defeat garbage collection in subtle ways: one long-lived reference to a booking keeps its whole session and waitlist alive.

Why it's built this way

  1. Requirements change data in predictable clusters (a booking changes with its session), so models group data by what changes together.
  2. Rules must be checked where all the data is visible at once, so each rule gets one owner, the aggregate root.
  3. Checking a rule while others change the same data needs a lock or version, and locks are cheap only when narrow, so aggregates are kept small.
  4. Pointers across aggregates would let code skip the root and its checks, so cross-aggregate links are ids that must be resolved through a repository.
  5. Ids outlive every other field, so identity is a surrogate the system controls rather than a natural key the world can change.

Diagram

flowchart TD
  U["Use case sentences"] --> N["Noun phrases"]
  N --> O{"Outside system?"}
  O -- "yes" --> X["Drop or rename"]
  O -- "no" --> A{"Describes another noun?"}
  A -- "yes" --> F["Field or value object"]
  A -- "no" --> I{"Same one after all fields change?"}
  I -- "yes" --> E["Entity with id"]
  I -- "no, closed set" --> EN["Enum"]
  I -- "no" --> V["Value object"]
  U --> VB["Verbs with data"] --> AC["Association class"]
  E --> G{"Must change together for a rule?"}
  AC --> G
  G -- "yes" --> R["Same aggregate, one root"]
  G -- "no" --> REF["Separate aggregates, reference by id"]

Worked example

Prompt: a gym chain with 12 studios runs about 300 sessions a day, 20 spots each, with a waitlist. Members buy credit packs; a session costs 1 credit, refunded if cancelled at least 2 hours before start.

Nouns found: gym, studio, class, session, instructor, member, credit pack, credit, booking, waitlist, spot, start time, capacity, roster, reminder email. Filter: gym is the system itself (drop); spot is a count, not a thing with identity (capacity field); start time and capacity are attributes; roster is a query over bookings; reminder email is a mechanism; credit is a number. Class versus session: the interviewer confirms classes recur weekly, so ClassTemplate (Spin, Tuesday 07:00, 20 spots) generates Sessions.

Survivors: Studio, ClassTemplate, Session, Instructor, MemberAccount, Booking (association class), CreditPack. Values: TimeSlot, Credits. Enum: BookingStatus.

Aggregates by invariant. "Confirmed bookings never exceed capacity" and "promote the first waitlisted on cancel" both live in Session with its bookings. "Credits never go negative" lives in MemberAccount with its packs. Booking crosses both: session.book must also debit 1 credit. Options: merge (a member-plus-session aggregate is nonsense), or debit first in MemberAccount, then book in Session, and refund the credit if the session rejects the booking. The team picks the second, with the refund triggered by a BookingRejected event. At 300 sessions a day and peak rushes of perhaps 50 bookings a second when popular sessions open, per-session locking is never contended for more than microseconds.

References: Booking holds member_id, not a MemberAccount pointer. Session holds instructor_id and template_id. Instructor holds no list of sessions; "my schedule" is a query.

Trade-offs

OptionStrengthsWeaknessesChoose when
Large aggregateEvery rule checked in one transactionContention; big loads; slow writesRules truly span the whole cluster and writes are rare
Small aggregates plus eventsNarrow locks; independent scalingCross-aggregate rules become eventual; needs compensationRules are mostly local; high write rates
Object references everywhereEasy navigation in codeCallers bypass roots; huge object graphs load at onceSmall, single-process models with few rules
Id references across aggregatesEnforces boundaries; easy to persist and shardLookups through a repository; more codeAny model that will be persisted or shared
Subclass per roleSimple when roles never changeOne person in two roles breaks itKinds are fixed for an object's lifetime
Role objects by compositionRoles added and removed at runtimeMore objects and idsPeople or accounts gain and lose roles

Pitfalls in production

Leadership lens

Check yourself

  1. Is a postal address an entity or a value object in an online shop? When would that change?

    Show answer

    Usually a value: two identical addresses are interchangeable and an order snapshots one. It becomes an entity when the system tracks it over time with its own lifecycle, such as a saved address book entry that users edit and that orders reference.

  2. Why do many-to-many relationships in a first draft usually hide a class?

    Show answer

    The relationship tends to have its own data and lifecycle (when it started, its status, its terms), and that data belongs to neither side, so it needs its own association class.

  3. Booking a session must also debit the member's credits, and they are separate aggregates. What are the options?

    Show answer

    Merge them into one aggregate (rarely sensible), or change one aggregate per operation and coordinate with an event and a compensating step such as refunding the credit if the booking is rejected.

  4. Why should code outside an aggregate hold only the root's id?

    Show answer

    A direct pointer to an internal object lets callers change it without the root's checks, which breaks invariants; ids force every change through the root and also make persistence and sharding straightforward.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →