The Reservation That Bumps You
Availability's Resource aggregate enforces one real business rule - a higher-priority reservation evicts yours and announces it. The invariant, the struct value object behind it, and the day-integer it becomes on disk.
Every DDD tutorial promises you an invariant and then shows you Balance -= amount. Pacco's Availability service — the third and richest age from part 2 — has a real one: your confirmed reservation can be taken from you. Not by a bug. By design, by a customer with a higher priority who wants the same vehicle on the same date. The rule lives in one method of one aggregate, it emits compensating events as it works, and it turns cancellation from a failure into a normal business outcome that the rest of the estate must be able to absorb. This part reads that method line by line, then the deceptively small value object underneath it, and finally what both become in MongoDB.
Expropriation in twenty-four lines
Here is AddReservation, from Pacco.Services.Availability/src/Pacco.Services.Availability.Core/Entities/Resource.cs, in full:
public void AddReservation(Reservation reservation)
{
var hasCollidingReservation = _reservations.Any(HasTheSameReservationDate);
if (hasCollidingReservation)
{
var collidingReservation = _reservations.First(HasTheSameReservationDate);
if (collidingReservation.Priority >= reservation.Priority)
{
throw new CannotExpropriateReservationException(Id, reservation.DateTime.Date);
}
if (_reservations.Remove(collidingReservation))
{
AddEvent(new ReservationCanceled(this, collidingReservation));
}
}
if (_reservations.Add(reservation))
{
AddEvent(new ReservationAdded(this, reservation));
}
bool HasTheSameReservationDate(Reservation r) => r.DateTime.Date == reservation.DateTime.Date;
}
Read it slowly, because every line teaches something.
The collision test is date-only. HasTheSameReservationDate — a local function standing in for the private helper an older codebase would have written — compares DateTime.Date against DateTime.Date. A vehicle is booked by the day, and time-of-day never participates in the rule. Keep that; it echoes all the way down to the persistence format.
The incumbent wins ties. collidingReservation.Priority >= reservation.Priority throws — so to expropriate, the newcomer's priority must be strictly higher. Equal priority is not a coin flip; it is a defence of the sitting tenant. That is a business decision expressed in one comparison operator, and it is the kind of line a code review should read out loud.
Expropriation is two facts, not one. When the newcomer does win, the aggregate removes the incumbent and raises ReservationCanceled for it, then adds the winner and raises ReservationAdded. The method does not overwrite; it evicts and says so. The ReservationCanceled domain event is the seed of a compensation cascade — downstream, an order built on that reservation must be cancelled too, a choreography part 5 traces end to end. The same event does duty in Resource.Delete(), which raises ReservationCanceled for every live reservation before ResourceDeleted — deleting a vehicle apologises to every booking it strands.
The guards double as truth-tellers. Both mutations are wrapped in if (_reservations.Remove(...)) and if (_reservations.Add(...)) — events are raised only if the set actually changed. After the bugs we will meet later in this series, where events announce things that never happened, it is worth pausing on a method that structurally cannot lie: no removal, no ReservationCanceled; no insertion, no ReservationAdded.
The aggregate is equally careful about its own birth. Resource.Create(id, tags) — the static factory — constructs the resource and raises ResourceCreated; the public constructor, which the Mongo layer uses to rehydrate documents back into entities, raises nothing. Same initialisation, two doors: one for “this resource now exists as a business fact”, one for “this resource is being read back from disk”. Without that split, every load from the repository would re-announce a creation that happened months ago. It also tells you precisely what persistence philosophy you are in — state-based storage with event signalling, not event sourcing. Events here are exhaust, emitted once at the moment of change; the document is the truth, and nothing ever replays.
The failure mode is honest too: CannotExpropriateReservationException carries the code cannot_expropriate_reservation, surfacing to HTTP callers through the estate's error envelope. (What it surfaces to bus callers as is a stranger story — a failed reserve is announced as ReleaseResourceReservationRejected, a failed release — but that rot is part 7's subject.)
A struct with a split personality
The Reservation the aggregate juggles is not an entity. It is a struct value object — Core/ValueObjects/Reservation.cs, complete:
public struct Reservation : IEquatable<Reservation>
{
public DateTime DateTime { get; }
public int Priority { get; }
public Reservation(DateTime dateTime, int priority)
=> (DateTime, Priority) = (dateTime, priority);
public bool Equals(Reservation reservation)
=> Priority.Equals(reservation.Priority) && DateTime.Date.Equals(reservation.DateTime.Date);
public override bool Equals(object obj)
=> obj is Reservation reservation && Equals(reservation);
public override int GetHashCode()
=> DateTime.Date.GetHashCode();
}
Immutable, no identity, equality by value — the value-object checklist, ticked. But look at the asymmetry: Equals compares priority and date; GetHashCode hashes only the date. That is legal — the contract demands equal objects produce equal hashes, and they do; it merely permits unequal objects to share one. It is even arguably expressive: the hash says “reservations are bucketed by date”, the equality says “but they differ by priority”.
It is also load-bearing in a way the code never states. The aggregate stores reservations in a HashSet<Reservation>. Two same-date, different-priority reservations share a bucket but are unequal — so the set would happily hold both. Nothing in the collection enforces one-reservation-per-date; only AddReservation's collision scan does. The invariant every reader assumes lives in the data structure actually lives one level up, in the method — and any future code path that touches _reservations without going through AddReservation (rehydration, a hasty new feature) inherits the duty silently.
The struct-ness has a second quiet consequence. ReleaseResourceReservationHandler locates the reservation to release with:
var reservation = resource.Reservations.FirstOrDefault(r => r.DateTime.Date == command.DateTime.Date);
resource.ReleaseReservation(reservation);
FirstOrDefault on a sequence of structs cannot return null. No match yields default(Reservation) — year 0001, priority 0 — which ReleaseReservation then tries to Remove, fails (it was never in the set), and returns without raising anything. Releasing a reservation that does not exist is a silent success: no exception, no event, HTTP 200. With a class, that line would have thrown or forced a null check; the struct converts absence into a phantom value and the phantom into a no-op. If you take one rule of thumb from this section: when a value object is a struct, FirstOrDefault is a fact-inventing machine — every “not found” becomes a real-looking value.
Thirteen bits of date
The last stop is the disk. Infrastructure/Mongo/Documents/Extensions.cs persists each reservation as a document holding Priority and an int TimeStamp:
internal static int AsDaysSinceEpoch(this DateTime dateTime)
=> (dateTime - new DateTime()).Days;
internal static DateTime AsDateTime(this int daysSinceEpoch)
=> new DateTime().AddDays(daysSinceEpoch);
Days since new DateTime() — since the first of January, year 0001. Time-of-day is destroyed on the way in and never comes back; a reservation round-trips as a pure date wearing an integer. I like this more than I expected to. It is not an optimisation anyone needed — Mongo stores dates natively — but it is honest modelling in the persistence layer: the domain rule is date-granular, and the storage format makes storing anything finer impossible. The type system saying “don't even think about hours” more firmly than a comment ever could. The trade is that the epoch is implicit — an unadorned int named TimeStamp that a future maintainer could plausibly read as Unix seconds — and that only the surrounding conversion pair documents the truth.
The invariant as a system event
Step back and the three layers tell one story. A date-granular business rule (AddReservation), a date-granular value object (Equals/GetHashCode on .Date), a date-granular storage format (the day integer) — consistent from method to disk. And at the centre, a rule with real consequences: expropriation means the estate manufactures cancellations as part of normal operation. Somebody's approved order must un-approve because somebody else outranked them. The domain events raised here become integration events on the bus, an order in a different service cancels itself in response, and no coordinator anywhere holds the script.
How that script plays out without a conductor — who publishes what, who reacts, and what the compensation chain looks like when your booking is bumped — needs the whole conversation on the table. First, though, we need the other half of the duet: the order's own state machine, and the curious decision to announce all of it through one event with five contracts.