Skip to content

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.

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.book is 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:

  1. the commands and queries;
  2. three invariants, including what must remain unchanged on rejection;
  3. the state each invariant needs to inspect;
  4. the smallest owner that can keep that state consistent;
  5. one value that should be immutable;
  6. 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.