One Event, Five Contracts
Every transition of Pacco's Order raises the same domain event - OrderStateChanged - and a mapper switches on aggregate status to mint five distinct integration events. A content-based router keyed on state, weighed honestly.
Availability, as part 3 showed, raises a precise domain event for every distinct fact: ReservationAdded, ReservationCanceled, ReservationReleased, ResourceCreated, ResourceDeleted — five events, five meanings. Walk three repositories over to Pacco.Services.Orders and the same estate, the same framework, the same authors make the opposite bet: every transition of the Order aggregate raises exactly one domain event, and a mapper downstream inspects the aggregate's status to decide what the rest of the world gets told. One private fact, five public contracts. This part reads the state machine, then the mapper, and then argues both sides of a genuinely contestable design.
The machine
Core/Entities/Order.cs defines a five-state lifecycle — New, Approved, Delivering, Completed, Canceled — with guarded transitions. Trimmed to the moving parts:
public void Approve()
{
if (Status != OrderStatus.New && Status != OrderStatus.Canceled)
{
throw new CannotChangeOrderStateException(Id, Status, OrderStatus.Approved);
}
Status = OrderStatus.Approved;
CancellationReason = string.Empty;
AddEvent(new OrderStateChanged(this));
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Completed || Status == OrderStatus.Canceled)
{
throw new CannotChangeOrderStateException(Id, Status, OrderStatus.Canceled);
}
Status = OrderStatus.Canceled;
CancellationReason = reason ?? string.Empty;
AddEvent(new OrderStateChanged(this));
}
Complete() demands Delivering; SetDelivering() demands Approved; the static factory Order.Create raises the same event at birth. The transition table is worth writing out, because two of its entries carry real domain intent:
| Transition | Allowed from | Note |
|---|---|---|
| Approve | New, Canceled | re-approval clears CancellationReason |
| SetDelivering | Approved | |
| Complete | Delivering | |
| Cancel | anything but Completed, Canceled | records a reason |
Approval from Canceled is not sloppiness — it is the expropriation economy from part 3 built into the lifecycle. An order whose reservation was bumped gets cancelled by the system, and the machine deliberately leaves the door open for a new reservation to resurrect it, wiping the stale reason as it does. Cancellation here is a revisitable state, not a tombstone.
But notice what every guarded method has in common: AddEvent(new OrderStateChanged(this)). Not OrderApproved. Not OrderCanceled with the reason. The domain event — Core/Events/OrderStateChanged.cs — is a single property, Order, a reference to the whole live aggregate. The event does not say what happened; it says something happened, and hands you the entire object to work out what.
The router
The working-out happens in Infrastructure/Services/EventMapper.cs:
case OrderStateChanged e:
switch (e.Order.Status)
{
case OrderStatus.New:
return new OrderCreated(e.Order.Id);
case OrderStatus.Approved:
return new OrderApproved(e.Order.Id);
case OrderStatus.Delivering:
return new OrderDelivering(e.Order.Id);
case OrderStatus.Completed:
return new OrderCompleted(e.Order.Id, e.Order.CustomerId);
case OrderStatus.Canceled:
return new OrderCanceled(e.Order.Id, e.Order.CancellationReason);
}
break;
In enterprise-integration vocabulary this is a content-based router — except the content it routes on is not the message, which is empty of meaning, but the current state of the aggregate attached to it. Five integration events are minted here, and their payloads are individually curated: OrderCompleted alone carries CustomerId, because its one known consumer, Customers, needs it to bump the loyalty count; OrderCanceled alone carries the reason. The public contracts are shaped per audience while the domain event stays a blank envelope. (The same mapper also handles the two parcel events — ParcelAdded and ParcelDeleted map to ParcelAddedToOrder and ParcelDeletedFromOrder — and anything unmatched falls through to return null.)
The case against
The orthodox objection writes itself, and it is worth writing well.
The domain event records the wrong thing. “Domain events should be named for what happened, in the past tense, in the language of the domain” — OrderStateChanged fails all three by design. It is a change-notification, not a fact. Availability's events read like a ledger; Orders' read like a database trigger.
The mapping is time-of-read, not time-of-raise. The event carries a live reference, and the mapper inspects Status when it maps. Today that gap is safe — every handler raises, persists, and maps within one method. But raise two transitions on one aggregate in one unit of work and both OrderStateChanged instances point at the same object; both would map to the final status, and the intermediate fact would vanish. The design is correct only under an unwritten one-transition-per-save discipline that nothing enforces.
The mapper must mirror the machine, by hand. Add a sixth status and the compiler will not remind you to extend the switch; the new transition maps to nothing. Which leads to the sharpest detail: unmatched events return null, and Orders' MapAll is a bare events.Select(Map) — no null filter. Handlers pass the result straight to PublishAsync. The null is not dropped early and politely; it flows toward the broker, and whether it surfaces as an exception or a silently skipped publish is up to plumbing the domain never sees. Availability's EventProcessor, by contrast, checks Map's result and continues past nulls — the pipeline age from part 2 handles the same hazard deliberately.
The case for
Now the fair fight, because there is one.
The machine stays immaculate. Look again at Approve(): guard, mutate, announce — three lines of essence. No transition method needs to know that approval is publicly interesting, or what the public calls it. The entire vocabulary of the outside world lives in one file, and renaming a public contract touches the mapper alone. That is a real separation: what happened is the machine's business; what to call it is the boundary's.
One event is one habit. Every transition ends AddEvent(new OrderStateChanged(this)) — impossible to get wrong, trivial to review. A future Suspend() written by a newcomer will announce itself correctly by reflex. Under the five-event school, the same newcomer must remember to define, raise, and map a new event type; forgetting is exactly as silent.
The payloads prove the point. OrderCompleted(id, customerId) and OrderCanceled(id, reason) show contracts tailored to consumers rather than mirroring domain internals — which is precisely what an anti-corruption seam is for. Part 9 will show Customers using the same seam to rename a fact for the outside world; Orders uses it to classify one. Same mechanism, two purposes.
The reason that rides away
Before the verdict, one honest-ledger entry that the mapper makes visible. OrderCanceled carries CancellationReason — and that integration event is the only place the reason survives. Infrastructure/Mongo/Documents/OrderDocument.cs persists the order's id, customer, vehicle, status, dates, price and parcels; there is no CancellationReason field (and, as part 2 flagged, no Version either). Rehydration hands the constructor no reason, and the constructor initialises it to string.Empty. So the sequence is: the machine records why the order died, the mapper broadcasts it once, the repository forgets it. Ask the Orders service five minutes later why order X is cancelled and it cannot tell you — the fact exists only in whatever consumer happened to log the event. A curated payload is a fine thing; a curated payload that is also the sole surviving record is an accidental system of record, and nobody signed up to be it.
My verdict, for what a source-reader's verdict is worth: the router is elegant and the domain event is under-specified. The design would keep every listed virtue if the event carried a snapshot — the status at raise time, the reason, the customer id — instead of a live aggregate reference. The switch could then route on the event rather than on mutable state, the two-transitions hazard would evaporate, and OrderStateChanged would at least be a fact about a moment. As shipped, it is a pointer wearing a past-tense name. And a rule of thumb falls out: map on what the event captured, never on what the aggregate currently says — the aggregate has moved on; the event should not.
Either way, the five public events are now on the bus — OrderApproved among them — and the estate's most interesting property is that nobody is waiting for them in any central place. The approval that raised it was itself a reaction to Availability's ResourceReserved; the cancellation path starts in an aggregate three services away. It is time to put the whole conversation on one page: a checkout with no conductor.