A Checkout With No Conductor
Pacco's flagship flow reconstructed from source - a vehicle is assigned, a reservation lands, an order approves itself, and when a VIP bumps the booking the cancellation ripples through three services with no process manager in sight.
We now have all the instruments on stage. The order state machine that announces five contracts through one event (part 4); the reservation aggregate that can evict a booking and apologise by event (part 3). This part plays the piece: Pacco's flagship flow, reconstructed from source, from “assign this vehicle to my order” to an approved order — and then the darker second movement, where a higher-priority customer takes the vehicle and the approval unwinds across three services. The remarkable property of both movements is what is missing. Inside the domain services there is no process manager, no workflow engine, no saga class with the script. Each service knows only its own events and reactions; the checkout emerges.
Movement one — assignment to approval
Step 1: the enrichment. It starts with a command, AssignVehicleToOrder, handled in Pacco.Services.Orders/src/Pacco.Services.Orders.Application/Commands/Handlers/AssignVehicleToOrderHandler.cs. Trimmed to its spine:
var vehicle = await _vehiclesServiceClient.GetAsync(command.VehicleId);
if (vehicle is null)
{
throw new VehicleNotFoundException(command.VehicleId);
}
var pricing = await _pricingServiceClient.GetOrderPriceAsync(order.CustomerId, vehicle.PricePerService);
order.SetVehicle(command.VehicleId);
order.SetTotalPrice(pricing.OrderDiscountPrice);
order.SetDeliveryDate(command.DeliveryDate);
await _orderRepository.UpdateAsync(order);
await _messageBroker.PublishAsync(new VehicleAssignedToOrder(command.OrderId, command.VehicleId));
Two synchronous HTTP hops inside a message handler — Vehicles for the price per service, Pricing for the loyalty-discounted total — then three setters, a save, and a publish. Note that VehicleAssignedToOrder is published directly; no domain event mediates it, and SetVehicle is a naked setter even though a CanAssignVehicle intent property exists on the aggregate. The one guard that does consult it is odd company for its siblings: if (!order.CanAssignVehicle) return; — a silent no-op in a handler where every other failed precondition throws. The synchronous chain and its availability arithmetic get their own part later (one request, four services); today it is simply the overture. The handler's last line is the first note on the bus: VehicleAssignedToOrder, carrying an order id and a vehicle id, with the delivery date already recorded on the order.
Step 2: the reservation request. Somebody must now ask Availability to reserve that vehicle for that date. None of the domain services does — Orders publishes its event and stops. In the estate's demo topology the reaction comes from outside the domain entirely: OrderMaker, the estate's Chronicle-based driver-client, hears VehicleAssignedToOrder and publishes ReserveResource(vehicleId, customerId, date, priority) to the availability exchange; that machine — and the Saga header it stamps on everything — belongs to the companion craft series in the saga that rides a header. What matters here is the contract's shape: the resource id is the vehicle id. Availability does not know about vehicles — it reserves generic “resources” — and no one translates the identifier at the boundary. The pun is the integration.
Step 3: the invariant. ReserveResourceHandler in Availability checks the customer's state over HTTP (a synchronous existence-and-validity gate mid-command), then hands the aggregate a new value object:
var reservation = new Reservation(command.DateTime, command.Priority);
resource.AddReservation(reservation);
await _repository.UpdateAsync(resource);
await _eventProcessor.ProcessAsync(resource.Events);
AddReservation is part 3's expropriation method. On the happy path there is no incumbent on that date: the reservation is added, ReservationAdded is raised, and the EventProcessor pipeline maps it — via the switch expression in Availability's EventMapper — to the integration event ResourceReserved(resourceId, dateTime) on the availability exchange.
Step 4: the self-approval. Orders subscribes to ResourceReserved and re-declares it locally with [Message("availability")] — a copied contract, two properties, no shared assembly. The handler:
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());
}
Look at the lookup. ResourceReserved carries no order id, no correlation token — only a resource id and a date. Orders finds the order by GetAsync(vehicleId, deliveryDate), a Mongo query on VehicleId == resourceId && DeliveryDate == dateTime.Date. The natural key is the correlation, the vehicle id doubles as the resource id, and the whole conversation balances on the assumption that one vehicle-plus-date matches exactly one order. Then Approve() — the state machine from part 4 fires, OrderStateChanged maps to OrderApproved, and the checkout is done. Four steps, three services (plus the driver), and the decision to approve was never made anywhere; it fell out of a subscription.
Movement two — the bump
Now replay step 3 with an incumbent. A VIP wants the same vehicle on the same date, with a higher priority. AddReservation evicts the sitting reservation and raises two domain events: ReservationCanceled for the victim, ReservationAdded for the winner. The pipeline maps them to ResourceReservationCanceled(resourceId, dateTime) and ResourceReserved(resourceId, dateTime) — and both go out. The winner's checkout proceeds through step 4 as before. The victim's unwinds:
order.Cancel($"Reservation canceled at: {@event.DateTime}");
Orders' ResourceReservationCanceledHandler does the same natural-key lookup and cancels the order, recording an interpolated reason string. The state machine maps that to OrderCanceled(orderId, reason), and one more ripple goes out: Parcels hears OrderCanceled and detaches the order's parcel so it can be re-shipped — singular deliberately, because its handler fetches exactly one parcel of a potentially many-parcel order, a cross-context disagreement that part 8 dissects in full. Meanwhile the cancelled order is not a corpse: part 4's machine allows Approve() from Canceled, so a fresh reservation on a new date can resurrect it.
There is a failure branch worth one honest sentence. If the newcomer's priority is not strictly higher, AddReservation throws, and Availability's exception-to-message mapper announces the failed reserve as ReleaseResourceReservationRejected — a rejection wearing the wrong verb, whose decay is part 7's whole subject.
What the score never wrote down
The full conversation, one line per note:
AssignVehicleToOrder→ Orders enriches via Vehicles + Pricing →VehicleAssignedToOrder- (driver) reacts →
ReserveResource→ Availability AddReservationupholds the invariant →ResourceReserved(and, on a bump,ResourceReservationCanceledfor the victim)- Orders on
ResourceReserved→Approve()→OrderApproved - Orders on
ResourceReservationCanceled→Cancel(reason)→OrderCanceled→ Parcels detaches
Three properties of this design deserve naming, because they are the choreography argument in miniature.
Compensation is not an error path — it is inventory. ResourceReservationCanceled is a first-class event with a subscriber and a business meaning, manufactured during normal operation. The estate treats “your booking was taken” the way it treats “your booking succeeded”: as a fact to publish. Systems that model compensation as exception handling never achieve this; Pacco gets it by making the expropriation rule emit its own undo.
Every service's world is one hop wide. Orders knows vehicles and reservations-as-events; Availability knows resources and customer validity; Parcels knows order cancellations. No participant could draw the sequence diagram above — I had to reconstruct it by reading five repositories, and that is the honest cost of conductor-less design: the flow exists only in the union of the subscriptions. When it works, nothing is a bottleneck and nothing is a single point of scripting. When it breaks — a natural-key collision, a rejection on the wrong channel — there is no coordinator's log to read, only the bus.
The bus is at-least-once, and the machine is the deduplicator — crudely. Deliver ResourceReserved twice and the second Approve() runs against an already-Approved order: the state machine throws CannotChangeOrderStateException. That is duplicate protection of a sort — the redundant approval cannot happen — but look up that exception in Orders' ExceptionToMessageMapper and it is absent; the match falls through to null, so no rejected event is published and the broker is left holding a poisoned message. The guard that protects the domain simultaneously starves the messaging layer of an answer. A missed lookup, by contrast, is handled deliberately: OrderForReservedVehicleNotFoundException maps to its own OrderForReservedVehicleNotFound event. The estate knew reactions could fail; it just did not finish the matrix.
The correlation is a business fact, not a token. No message in the flow carries an order id past step 2. The thread that stitches steps 3–5 back to the right order is (vehicleId, date) — a natural key that is also the reservation's business identity. That is either elegant or terrifying, and deciding which requires its own examination: what exactly breaks if two orders ever share a vehicle and a date, and what the estate could have carried instead.
That examination is next: correlation by natural key.