Same Fact, Two Names
Inside the Customers service a registration completes - and the rest of the estate hears that a customer was created. Three lines of EventMapper perform the best ubiquitous-language translation in Pacco, sitting on top of a lifecycle state machine with one guarded transition and four administrative jumps.
Part 8 watched two services disagree about one fact because an event lied about what happened. This part is the mirror image, and it is the most quietly instructive thing I found reading the Customers service: two services describing the same fact with two different names — and both being right. Inside Pacco.Services.Customers, a domain event fires called CustomerRegistrationCompleted. What the rest of the estate receives on the bus is called CustomerCreated. That renaming is not sloppiness or drift. It is a deliberate act of translation at a context boundary, and it is done in three lines.
A lifecycle with one door and four trapdoors
To see why the translation is right, you need the state machine underneath it. The Customer aggregate carries a State enum — Unknown, Valid, Incomplete, Suspicious, Locked — and a customer is born incomplete: when Identity publishes SignedUp (from the identity exchange), Customers' handler checks the role is "user" and creates the aggregate through a constructor that hard-codes State.Incomplete, an empty name and an empty address. At this point the customer exists inside the Customers context — a row, an email, nothing more.
There is exactly one guarded transition out of that state:
public void CompleteRegistration(string fullName, string address)
{
if (string.IsNullOrWhiteSpace(fullName))
{
throw new InvalidCustomerFullNameException(Id, fullName);
}
if (string.IsNullOrWhiteSpace(address))
{
throw new InvalidCustomerAddressException(Id, address);
}
if (State != State.Incomplete)
{
throw new CannotChangeCustomerStateException(Id, State);
}
FullName = fullName;
Address = address;
State = State.Valid;
AddEvent(new CustomerRegistrationCompleted(this));
}
Everything else is a trapdoor. SetValid, SetIncomplete, Lock and MarkAsSuspicious all funnel into one private method that jumps to the target state unguarded:
private void SetState(State state)
{
var previousState = State;
State = state;
AddEvent(new CustomerStateChanged(this, previousState));
}
The asymmetry is a design statement worth naming. Registration is a business transition — it has invariants, prerequisites and a precise domain event of its own. The other four are administrative transitions — an operator or fraud process moving a customer between Valid, Locked and Suspicious — and the aggregate deliberately refuses to encode a legality matrix for them. Any state can jump to any state, because the domain truth is “an administrator said so.” Pacco even exposes them as route segments: PUT customers/{customerId}/state/{state}, with the handler parsing the string via Enum.TryParse(ignoreCase: true) and then switching to the specific intent method — a stringly-typed surface converted into typed intent at the first opportunity, which is the correct place to stop strings.
And notice what CustomerStateChanged carries: the customer and the previous state. The integration event preserves it — {CustomerId, CurrentState, PreviousState}, both lowercased strings. That one extra property is the difference between consumers knowing a customer is locked and knowing a customer became locked from valid — states versus transitions. Most event designs I inherit carry only the destination and force every consumer to keep shadow history to detect movement. Three cheers for the second field.
Those lowercased strings are also a live contract, not just telemetry. Availability's fraud gate — the IsValid check the next part returns to — compares exactly this machine's output, as a string, case-insensitively, over HTTP. The enum itself is private to Customers; what the rest of the estate consumes is its ToLowerInvariant() shadow, on the bus and at the /state endpoint alike. That is a workable wire format, but it means renaming a state is a cross-service breaking change no compiler will ever see: the state machine's names are as published as its events.
The three lines that translate
Now the gem. Customers' EventMapper, Pacco.Services.Customers.Infrastructure/Services/EventMapper.cs:
public IEvent Map(IDomainEvent @event)
{
switch (@event)
{
case CustomerRegistrationCompleted e: return new Application.Events.CustomerCreated(e.Customer.Id);
case CustomerBecameVip e: return new Application.Events.CustomerBecameVip(e.Customer.Id);
case CustomerStateChanged e:
return new Application.Events.CustomerStateChanged(e.Customer.Id,
e.Customer.State.ToString().ToLowerInvariant(), e.PreviousState.ToString().ToLowerInvariant());
}
return null;
}
The second and third cases keep their names across the boundary. The first one changes it: CustomerRegistrationCompleted becomes CustomerCreated. One fact, two names, each correct in its own language.
Inside the Customers context, “created” happened days ago, at SignedUp — there is a persisted aggregate with an email and a created-at timestamp to prove it. What just happened is that a registration completed: the incomplete record acquired a name, an address and validity. The domain event says exactly that, in the tense and vocabulary of the customer-lifecycle domain.
But from every other context's point of view, that history is noise. Orders cannot create an order for an incomplete customer; Parcels will not accept a parcel from one; nothing anywhere in the estate consumes half-registered customers. To the outside world, a customer that cannot yet act does not meaningfully exist — so the moment registration completes is, externally, the moment of creation. The integration event says that, in the tense and vocabulary of everyone else. The subscribers we met across this series — Orders' and Parcels' id-only replicas, Availability's deliberately empty handler — all bind [Message("customers")] classes named CustomerCreated, and none of them has to know that registration is even a concept.
This is ubiquitous-language discipline applied where it is hardest and matters most: not in class naming within a module, but at the published boundary between contexts. The mapper is functioning as an anti-corruption layer in the outbound direction — protecting consumers from the producer's internal vocabulary, rather than the usual inbound direction of protecting yourself from theirs. It also buys concrete evolutionary freedom: Customers can split registration into three steps, add email verification, rename the whole flow — and as long as “the moment a customer becomes usable” still maps to CustomerCreated, no consumer changes. The internal name is a fact about the domain; the external name is a promise about meaning.
It is worth saying plainly, because part 4 showed the other school: Orders collapses its whole lifecycle into one coarse OrderStateChanged and lets the mapper fan it out by status, so its mapper is doing routing. Customers' mapper is doing translation — same IEventMapper shape, same three-folder layout, entirely different semantic job. The estate's mappers are where its bounded contexts actually negotiate with each other.
The honest ledger
Reading around the gem, the same service shows the usual small fractures, and two of them rhyme with earlier parts:
- A silent mutation.
AddCompletedOrder— called when Orders'OrderCompletedevent arrives, feeding the VIP policy's twenty-order threshold — adds to the_completedOrdersset and raises no domain event at all. State accretes invisibly between the named transitions; any consumer counting a customer's completed orders must go ask (which, as the next part shows, Pricing does in the most expensive way available). - A duplicated guard, slightly off.
CompleteCustomerRegistrationHandlerpre-checkscustomer.State is State.Validand throwsCustomerAlreadyRegisteredException, while the aggregate's own guard isState != State.Incomplete. For aValidcustomer the handler answers “already registered”; for aLockedorSuspiciousone it falls through to the aggregate's genericCannotChangeCustomerStateException. Two authorities for one rule, agreeing on the middle and diverging at the ends. - The boundary translation has no negative twin. Part 7 found Customers' exception mapper sending
CustomerNotFoundExceptionunconditionally toCompleteCustomerRegistrationRejected— so the service that best translates its positive vocabulary across the boundary translates its refusals onto the wrong channel. And theSignedUpHandlerexceptions (InvalidRoleException, duplicate creation) appear in no mapper at all: the front door of the whole lifecycle has no rejection story.
None of this dulls the central lesson; if anything the contrast sharpens it. The translation cost three lines and a moment of thought about what “created” means to whom. The fractures are all places where nobody spent that moment.
Name your integration events for what consumers mean, not for what your domain did. The test I now apply to every published event: read its name aloud from the subscriber's seat. If the name only makes sense with your context's internal history in mind, you are exporting your vocabulary instead of translating it.
So other services learn that a customer exists from CustomerCreated. What they do with that knowledge turns out to be the estate's best natural experiment: Orders and Parcels squirrel away an id, Availability throws the event away and phones home instead, and Pricing pulls the customer's entire record across the wire to count one number. Three consistency stances, one upstream — next: three ways to borrow another service's data.