Correlation by Natural Key
Availability's ResourceReserved event carries no order id, no correlation id, no saga id - just a resource and a date. Orders finds the right order anyway, by treating (vehicleId, deliveryDate) as the correlation key, and the expropriation flow shows exactly when that key stops being unique.
Part 5 reconstructed Pacco's reservation conversation end-to-end and found no conductor anywhere: Orders reacts to Availability, Availability reacts to commands, and the order approves itself when the right event arrives. This part asks the question that reconstruction quietly skipped over. When ResourceReserved lands on the bus, how does Orders know which order it belongs to? The event doesn't say. There is no order id in it, no reply-to address, no business correlation identifier of any kind. And yet the right order gets approved — most of the time.
An event with no return address
Here is the entire contract, from Pacco.Services.Availability.Application/Events/ResourceReserved.cs:
[Contract]
public class ResourceReserved : IEvent
{
public Guid ResourceId { get; }
public DateTime DateTime { get; }
public ResourceReserved(Guid resourceId, DateTime dateTime)
=> (ResourceId, DateTime) = (resourceId, dateTime);
}
Two properties. A resource and a moment. Availability publishes it from its EventMapper when the Resource aggregate raises ReservationAdded — the expropriation machinery we read in part 3. Nothing in the event acknowledges that an order exists; Availability's bounded context genuinely does not know what an order is. It reserves “resources” for dates, full stop.
Orders subscribes to it with its own copied class — Pacco.Services.Orders.Application/Events/External/ResourceReserved.cs, same two properties, decorated [Message("availability")] to bind against Availability's exchange. And then the handler does the thing this article is about:
public async Task HandleAsync(ResourceReserved @event)
{
var order = await _orderRepository.GetAsync(@event.ResourceId, @event.DateTime);
if (order is null)
{
throw new OrderForReservedVehicleNotFoundException(@event.ResourceId, @event.DateTime);
}
order.Approve();
await _orderRepository.UpdateAsync(order);
var events = _eventMapper.MapAll(order.Events);
await _messageBroker.PublishAsync(events.ToArray());
}
That GetAsync overload is not a lookup by id. In OrderMongoRepository:
public async Task<Order> GetAsync(Guid vehicleId, DateTime deliveryDate)
{
var order = await _repository.GetAsync(o => o.VehicleId == vehicleId &&
o.DeliveryDate == deliveryDate.Date);
return order?.AsEntity();
}
The correlation key is the pair (vehicleId, deliveryDate). Orders takes Availability's ResourceId, renames it vehicleId at the method boundary, and queries its own collection for the order that was waiting on exactly that vehicle for exactly that date. The event never named the order; the natural key found it anyway.
The pun that holds two contexts together
Notice the quiet translation in that repository signature. Availability publishes a ResourceId; Orders receives it into a parameter called vehicleId. Neither side is wrong. Availability models generic reservable resources — the Resource aggregate carries tags, not vehicle metadata — while Orders knows that, in this system, the only resources anyone reserves are vehicles. The same GUID is a resource in one ubiquitous language and a vehicle in the other, and the pun is the context map: an unwritten agreement that these two id spaces are the same space.
The date half of the key needs its own agreement, and the source honours it on both sides. Orders normalises on the way in — SetDeliveryDate stores deliveryDate.Date, discarding the time — and the repository compares against deliveryDate.Date again on the way out. Availability, as we saw in part 3, is date-granular by construction: the Reservation value object collides on DateTime.Date, and reservations are even persisted as whole days. Both contexts independently truncate to midnight, so the key matches. Change either side to hour-granular booking and the correlation silently breaks — not with an error, but with a null lookup and a rejected event.
It is worth being precise about what Pacco does have, because the estate is not naive about correlation in general. Every message that crosses the bus carries a message id, a correlation id and an originating-message id in its AMQP properties, threaded through the shared MessageBroker class, plus the forwarded Saga header. But those identify the conversation for tracing and saga coordination — none of them identifies the order. The business-level correlation, the thing that lets a handler find its aggregate, rides entirely on the natural key.
Where the key stops being unique
A natural key is a fine correlation identifier under one condition: it maps to at most one in-flight aggregate. So when does (vehicleId, deliveryDate) stop doing that?
You do not need to invent a scenario. The expropriation rule from part 3 manufactures the collision as part of its normal behaviour. Walk it through:
- Customer A's order is assigned vehicle V for date D.
ReserveResourcesucceeds;ResourceReserved(V, D)comes back; order A is approved. Order A's document now holdsVehicleId = V, DeliveryDate = D. - Customer B — higher priority, the VIP from Pricing's loyalty ladder — assigns the same vehicle for the same date. Order B's document now also holds
VehicleId = V, DeliveryDate = D. - B's
ReserveResourcetriggers the expropriation: the aggregate removes A's reservation (raisingReservationCanceled) and adds B's (raisingReservationAdded). Two integration events leave Availability:ResourceReservationCanceled(V, D)andResourceReserved(V, D). - Orders now handles two events whose only addressing information is (V, D) — a key that currently matches two order documents.
The cancellation handler runs the same lookup as the reservation handler and calls order.Cancel($"Reservation canceled at: {@event.DateTime}") on whatever it finds. Mongo's GetAsync with a predicate returns the first match in whatever order the server produces; nothing in the query says “the approved one” or “the older one”. If it picks order A, the flow works as designed — the bumped order is cancelled, and the follow-up ResourceReserved approves B. If it picks order B, the fresh order is cancelled the moment it was created, and the approval then lands on A — which is already approved, so Approve() on an order whose status permits re-approval quietly re-approves it. The system doesn't crash. It just approves and cancels the wrong customers, deterministically undetectably.
There is a second failure shape hiding in the same handler: the miss. If no order matches — the order was deleted mid-flight, or the natural key drifted — the handler throws OrderForReservedVehicleNotFoundException, which Orders' exception-to-message mapper turns into an OrderForReservedVehicleNotFound rejected event carrying, of course, only the vehicle id and the date. The reply to “which order was this?” is answered with the same key that failed to find one. And because this scan runs on every reservation event, it is worth one sentence of estate context: (VehicleId, DeliveryDate) is one of the unindexed hot-path queries examined in the index audit, so the correlation lookup is also a collection scan.
What I'd take from it
Reading this flow changed how I think about choreography contracts, because the design is genuinely elegant right up to the boundary of its own invariant. The lesson generalises into three rules I now apply:
- A natural key is a contract — write it down as one. The uniqueness of (vehicle, date) is enforced by Availability's aggregate, consumed by Orders' repository, and documented nowhere. If a key is doing correlation work across a context boundary, it deserves the same visibility as a schema: name it, state the invariant, and state which side owns it.
- Check the invariant against your own compensation paths. Pacco's key is unique among reservations but not among orders, and it is precisely the compensation flow — expropriation — that creates the second order sharing the key. The failure lives in the gap between what the upstream guarantees and what the downstream assumes.
- When the fact is about two aggregates, carry both ids.
ResourceReservedcould carry the reserving customer's id — theReserveResourcecommand already has it — without Availability learning anything about orders. One extra GUID would collapse the ambiguity entirely. To be fair to the authors, there is a real principle behind leaving it out: Availability publishing order ids would be a dependency arrow pointing the wrong way, and for a teaching estate the pure two-field event makes the choreography legible. The cost is that correlation becomes probabilistic exactly when the domain gets interesting.
The deeper theme is one this series keeps circling: in choreography, the contract is not just the event schema — it is the set of assumptions each consumer makes about what the schema implies. Orders assumes one order per vehicle-date. Availability never promised that.
And what happens when the answer to a command is not an event but a refusal? Availability, Orders, Parcels and Customers all have a second, darker vocabulary for that — rejected events — and reading those mappers turned up the strangest contract in the estate: a failed reserve announced to the world as a failed release. Next, rejected events and how they rot.