Senior Engineering Interview Handbook / Chapter 52
Domain and Object Modeling
Learn domain and object modeling through a meeting-room system whose overlap rules reveal entities, immutable values, aggregates, services, composition, and justified extension points.
Preparing audio…
Audio edition
Domain and Object Modeling
Page tools
The double booking hiding in a class diagram
Suppose the prompt is small:
Build an in-memory meeting-room service. Users can book and cancel rooms, and they can ask which rooms are free for a time range.
It is easy to spend the opening minutes drawing User, Room, Booking,
Building, repositories for each, and a service above them. The diagram may
look complete while leaving the difficult question unanswered: which object
can guarantee that two active bookings never occupy the same room at the same
time?
That question is the beginning of the model. A useful object model assigns each rule to the smallest owner capable of keeping it true. Class names come later.
Write the behavior first:
book(room_id, user_id, start, end) -> booking_id or conflict
cancel(booking_id) -> cancelled booking or conflict
available_rooms(start, end) -> rooms
Now state what must survive every command:
- an active booking never overlaps another active booking for the same room;
- a time range always has
start < end; - cancellation preserves booking identity but removes its claim on the room;
- a rejected command leaves the schedule unchanged;
- availability is derived from bookings rather than stored as a second truth.
The first invariant carries most of the design. If overlap checking lives in
BookingService, but another code path can append directly to a room’s list,
the service does not own the rule. If every Booking checks only itself, no
booking can see the competing bookings. The state that must agree has not yet
been given one boundary.
Make invalid time unrepresentable
Before dealing with rooms, give the interval arithmetic a home:
TimeRange(start, end)
construct:
reject unless start < end
overlaps(other):
return start < other.end and other.start < end
TimeRange is a value object. Two ranges with the same endpoints mean the
same thing; neither needs an ID. Once constructed, its endpoints should not
change. Otherwise a booking can enter a schedule with a valid range and become
invalid later through a setter.
The strict inequalities encode a policy: a meeting ending at 10:00 does not overlap one beginning at 10:00. Naming that boundary now prevents two methods from quietly adopting different rules.
Immutability also changes how rescheduling should look. This is dangerous:
booking.range.start = new_start
booking.range.end = new_end
Between those assignments, the booking may be invalid. More importantly, the
booking has moved without asking the schedule whether its new range collides.
A reschedule command should propose a new TimeRange to the owner of the
overlap invariant. The old range remains intact if the proposal is rejected.
Use primitives until they stop being simple. A room ID can remain a string if it has no parsing or validation beyond being present. A concept earns a value object when validity, equality, arithmetic, normalization, or formatting needs one reliable implementation.
Identity belongs to the booking
A booking changes from active to cancelled, yet callers still refer to the same booking. That makes it an entity:
Booking(id, room_id, user_id, range, status)
cancel():
if status == cancelled:
return already_cancelled
status = cancelled
Entity equality follows identity, not a comparison of every field. Two
in-memory copies with ID b-17 represent the same booking even if one copy is
stale. That fact matters when commands are retried, records are deduplicated,
or a caller asks to cancel an object fetched earlier.
Not every noun deserves this treatment. TimeRange has no lifecycle to
follow. A room feature such as video is data, not a tiny entity. IDs are not
decoration; they assert that the system must distinguish and track something
through change.
The entity can protect its own lifecycle, but it still cannot protect the room’s schedule by itself. Cancellation changes both the booking’s state and which ranges block the room. Those facts must remain consistent together.
Put the overlap rule around the state it needs
For an in-memory solution, a RoomSchedule can own all bookings for one room:
RoomSchedule(room_id)
bookings_by_id
book(booking_id, user_id, range):
if any active booking overlaps range:
return conflict
booking = Booking(booking_id, room_id, user_id, range, active)
bookings_by_id[booking_id] = booking
return booking
cancel(booking_id):
booking = bookings_by_id.get(booking_id)
if booking is missing:
return not_found
return booking.cancel()
is_available(range):
return no active booking overlaps range
This is an aggregate: a consistency boundary around state that must be
considered together. Callers may ask it to book or cancel, but they cannot
append to bookings_by_id or edit a booking’s range directly. Each command
either leaves the schedule valid or rejects without partial mutation.
Trace a few commands through it:
book(room-a, Ada, 09:00-10:00) -> b-1 active
book(room-a, Lin, 09:30-10:30) -> conflict; schedule unchanged
book(room-a, Lin, 10:00-10:30) -> b-2 active
cancel(b-1) -> b-1 cancelled
book(room-a, Sam, 09:30-09:50) -> b-3 active
The second command is the important one. A test should compare the schedule before and after the rejection, not merely assert that an exception occurred. Failure atomicity is part of the invariant.
The boundary is chosen by consistency, not by grammatical ownership. A User
participates in many bookings, but putting every booking inside User would
make a room’s overlap rule span several user objects. A Building contains
rooms in ordinary speech, but one giant building aggregate would serialize
unrelated bookings and make every room change load the whole building. One
schedule per room is the smaller honest boundary for this prompt.
Let the service remain thin
The public operation still needs lookup and coordination. That is enough work for a service:
BookingService
rooms_by_id
schedules_by_room
bookings_to_room
book(room_id, user_id, range):
require room_id exists
booking_id = ids.next()
result = schedules_by_room[room_id].book(booking_id, user_id, range)
if result is a booking:
bookings_to_room[booking_id] = room_id
return result
cancel(booking_id):
room_id = bookings_to_room.get(booking_id)
if room_id is missing:
return not_found
return schedules_by_room[room_id].cancel(booking_id)
available_rooms(range):
return rooms whose schedules report available(range)
The service owns lookup, ID generation, and traversal across schedules. It does not reimplement overlap or lifecycle rules. This division gives a useful test: if deleting the service would also delete the domain rule, too much of the model lives in orchestration.
A service is not the automatic home for every verb. TimeRange.overlaps
belongs with the value whose mathematics it expresses. Booking.cancel
belongs with the entity whose lifecycle changes. RoomSchedule.book belongs
with the state that must remain mutually consistent. The service handles the
work that has no single natural domain owner.
At the edge of the system, an API or command handler may reject malformed timestamps and missing fields. The schedule must still reject an overlap. Future jobs, tests, and service methods may reach the model without passing through that first boundary. Chapter 53 takes up the caller-facing contract; the domain rule remains inside.
Make change pay for abstraction
The initial prompt has one allocation rule: the caller chooses a room. An
AllocationPolicy interface, policy factory, and registry would add names
without protecting anything.
Now change the requirement:
Callers may ask the system to choose any available room with the required capacity and features. Prefer the smallest suitable room, but some offices prefer the nearest room.
Variation has arrived. Keep rooms composed from independent facts:
Room(id, capacity, features, floor)
MeetingNeed(attendees, required_features, preferred_floor)
RoomSuitability
accepts(room, need)
RoomRanking
SmallestSuitable
NearestSuitable
Capacity, video equipment, accessibility, and location can vary
independently. Subclasses such as VideoRoom, AccessibleVideoRoom, and
LargeAccessibleVideoRoom would encode combinations in a type tree. A room
composed from properties can acquire a feature without changing its identity
or multiplying classes.
The two ranking algorithms justify a policy boundary because the operation
now has competing rules. Suitability filters candidates; ranking chooses
among them. Neither policy is allowed to book directly. The chosen room still
passes through RoomSchedule.book, where the overlap invariant lives.
Inheritance is useful when substitution is both real and stable: callers can use every subtype through one contract without inspecting which subtype they received. Different parser tokens or executable command types may satisfy that condition. Sharing a noun is not enough, and using inheritance to encode several independent feature axes rarely survives the next requirement.
Another change exposes a different pressure:
A booking request may contain four weekly occurrences and must create all four or none.
The range generator can produce four immutable TimeRange values. The
schedule must check all four against existing bookings and against one another
before inserting any of them. A loop that books one occurrence at a time and
stops on the third conflict violates the new all-or-nothing invariant. The
changed rule, not a preference for patterns, tells you to widen the aggregate
command.
Carry the boundary into storage and concurrency
An in-memory aggregate proves where the rule belongs; it does not make the
rule safe across processes. Two threads can both observe an available range
and then insert overlapping bookings unless RoomSchedule.book is protected
by a lock or serialized executor. Two application instances require the
durable store to enforce an equivalent atomic check-and-insert protocol.
That production boundary should sharpen the explanation rather than inflate the exercise:
RoomSchedule.bookis the consistency operation. In this version it is atomic under one in-process lock per room. A persistent version must retain the same check-and-insert boundary through a transaction, exclusion constraint, or other storage mechanism appropriate to the database.
Do not add a repository merely to make the diagram look layered. Add one when persistence is in scope or when a store boundary makes the code testable. Even then, a repository cannot rescue a model that lets callers bypass the invariant.
Practice the ownership decision
Take one prompt—inventory reservation, expense sharing, parking allocation, a game, or an LRU cache—and spend ten minutes without drawing a class diagram. Write only:
- the commands and queries;
- three invariants, including what must remain unchanged on rejection;
- the state each invariant needs to inspect;
- the smallest owner that can keep that state consistent;
- one value that should be immutable;
- one explicit non-goal.
Then implement the command that protects the hardest invariant. Write one success test and one rejection test that proves state did not partially change.
Finally, draw a requirement change at random: expiration, a second allocation rule, recurring commands, refunds, another eviction rule, or concurrent callers. Before editing the model, say which rule changed and whether it requires a wider command, a new policy, or no new abstraction at all.
Review the result with concrete questions:
- Can any caller mutate invariant-bearing state without passing its owner?
- Is each entity tracked because its identity matters through change?
- Is each value valid after construction and safe from piecemeal mutation?
- Does the service coordinate, or has it become the only place rules live?
- Does every interface have at least two real implementations or another boundary it genuinely protects?
- Can you name the atomic operation a concurrent or persistent version must preserve?
Field reference
Start with behavior
commands, queries, failures
invariants after success and rejection
state each invariant must see together
Choose owners
entity: identity persists through change
value: equality is by contents; valid and usually immutable
aggregate: state changes together to preserve an invariant
service: lookup or coordination with no single natural owner
policy: more than one real rule behind a stable operation
Resist ceremony
compose independent properties instead of multiplying subtypes
keep primitives while their rules are trivial
add stores, factories, and policies only when a boundary earns them
Prove the model
run one successful state transition
reject one conflicting transition without partial mutation
apply one changed requirement
name the atomic boundary for concurrency or persistence
An object model is finished enough when its important failures have nowhere to hide. You can point to the state that must agree, the operation allowed to change it, and the test that would catch a broken promise. Everything else must earn its place by making that account clearer or the next real change safer.
Continue reading
Full table of contents