Skip to content
Kumar Chandrachooda
Microservices

Who Creates the Customer?

In DShop a customer is born in two steps - a record on sign-up, then a completed profile. Following both across the monolith and the swarm shows how a consistency model quietly rewrites a user flow.

By Kumar Chandrachooda 15 Aug 2025 5 min read
A user icon splitting into a faint record and a filled-in profile

“Who creates the customer?” sounds like it should have one answer. Follow it through DShop and it has at least two, because a DShop customer is born in two stages that different parts of the system own — and the boundary between them moves when you move from the monolith to the swarm. Chasing that one question is the cleanest way I've found to see how a change in consistency model reaches all the way up and rewrites what a user actually experiences at sign-up. It also happens to be a place where the tidy summary — “the monolith does it in-process, the services make it the client's job” — is not quite what the code says, and the gap is instructive.

Stage one: the record appears on its own

In the monolith, signing up runs three lines:

public async Task SignUpAsync(Guid id, string email, string password, string role)
{
    var user = await _userFactory.CreateAsync(id, email, password, role);
    await _userRepository.CreateAsync(user);
    await _eventDispatcher.DispatchAsync(user.Events.ToArray());
}

The UserFactory checks the email is unique, hashes the password, defaults the role to User, and raises a SignedUp domain event on the aggregate. The third line dispatches that event — and here is the thing the in-process dispatcher makes invisible: a SignedUpHandler, in a completely different part of the codebase, is listening:

public class SignedUpHandler : IEventHandler<SignedUp>
{
    public async Task HandleAsync(SignedUp @event)
        => await _customersService.CreateAsync(@event.UserId, @event.Email);
}

CustomersService.CreateAsync writes a bare Customer(id, email) to Mongo. So the moment you sign up, a customer record materialises — not because the sign-up code created it, but because a handler reacted to the sign-up event. Identity does not know Customers exists; it just announces what happened and something downstream cares.

This is the crucial point people skip: the customer record is created by an event handler in both builds. The distributed Customers service has the exact same class, reacting to the exact same event:

public async Task HandleAsync(SignedUp @event, ICorrelationContext context)
{
    var customer = new Customer(@event.UserId, @event.Email);
    await _customersRepository.AddAsync(customer);
}

Byte for byte the same idea. What differs is only the road the SignedUp event travels. In the monolith it is dispatched synchronously, in-process, inside the sign-up request — by the time SignUpAsync returns, the customer already exists. In the swarm, Identity publishes SignedUp to RabbitMQ and returns; the Customers service picks it up off a queue some milliseconds later. Same handler, same effect, but now the customer record trails the sign-up by an eventual-consistency gap. Nobody made customer creation “a client responsibility” — that framing is wrong. The server still creates it. It just creates it later, somewhere else, and without a transaction binding the two.

Stage two: completing the customer

The bare record is not a usable customer — it has an email and nothing else, and no cart. Turning it into one is a second, deliberate step, and this is the one the client drives. It is a CreateCustomer command carrying a profile, and in both builds the handler completes the record and creates the cart:

public async Task HandleAsync(CreateCustomer command, ICorrelationContext context)
{
    var customer = await _customersRepository.GetAsync(command.Id);
    if (customer.Completed)
        throw new DShopException(Codes.CustomerAlreadyCompleted, /* ... */);

    customer.Complete(command.FirstName, command.LastName, command.Address, command.Country);
    await _customersRepository.UpdateAsync(customer);
    var cart = new Cart(command.Id);
    await _cartsRepository.AddAsync(cart);
    await _busPublisher.PublishAsync(new CustomerCreated(/* ... */), context);
}

Now watch the seam. GetAsync(command.Id) assumes the bare record from stage one already exists — it dereferences customer.Completed with no null check. In the monolith that assumption is free: stage one ran synchronously inside the sign-up request, so by the time anyone can send CreateCustomer, the record is guaranteed to be there. In the swarm the same code is a race. If the frontend fires CreateCustomer before the SignedUp event has floated across the bus and been handled, GetAsync returns null and the handler NREs. The identical handler is safe in one build and racy in the other, and nothing in the handler changed — only the world underneath it did.

And stage two does not end when the record is completed — it announces itself. The distributed CreateCustomerHandler publishes a CustomerCreated event after saving:

await _busPublisher.PublishAsync(new CustomerCreated(command.Id, customer.Email,
    command.FirstName, command.LastName, command.Address, command.Country), context);

That event fans out across the estate — it is how other services learn a real customer now exists, without ever querying the Customers service. So a single sign-up ripples outward as two waves: SignedUp creates the bare record, and later CustomerCreated broadcasts the completed one. The monolith needs neither broadcast, because everything that cares about a customer can just read the one Mongo. Decomposition turned two in-process method calls into two published events and a chain of downstream reactions — the same customer, now assembled by a small distributed conversation instead of a single transaction.

The frontend automates the gap away

Here is where the consistency model reaches the user. In the monolith, the two stages are two explicit requests — you can read them in the sample .rest file: POST /sign-up, then POST /customers with a name and address. The user completes their own profile.

The Blazor frontend refuses to make the user do that, and papers over the whole thing in a layout component that runs on every page load:

public async Task OnInit()
{
    var accessToken = await _authService.GetAccessTokenAsync();
    if (accessToken is null)
    {
        var identity = _identityService.CreateDefaultIdentity();
        await _identityService.SignUpAsync(identity);
        var tokens = await _identityService.SignInAsync(identity);
        await _authService.SetAccessTokenAsync(tokens.AccessToken);
        await _customersService.CreateDefaultAsync();
    }
}

If there is no token, the app invents an identity, signs it up, signs it in, stashes the token, and calls CreateDefaultAsync — the client-side stage two, with a canned profile. Both stages, automated, so a first-time visitor is silently a fully-formed customer before they click anything. (What that “default identity” actually is — a mailinator.com address and a hard-coded password — is alarming enough to be its own part. For now, only the flow matters.)

And notice: SignUpAsync, SignInAsync, CreateDefaultAsync are chained with await, one after another, on the client. That sequencing is the frontend hand-building the ordering the monolith got for free. It works because it is sequential — sign-up completes, then sign-in, then create-default — so the SignedUp event has (usually) been handled by the time CreateDefaultAsync fires. The client is doing distributed-systems choreography and calling it a page-load hook.

The lesson the two builds teach together

Put the whole trace next to itself:

Monolith Nine services
Record created by SignedUpHandler (in-process) SignedUpHandler (over the bus)
Timing of the record synchronous, guaranteed eventual, racy
Profile completed by client, as a second request client, or the Blazor app automates it
GetAsync after create always safe safe only if the event won the race

The decomposition did not change who creates the customer — an event handler does, in both. It changed when, and “when” turned out to be load-bearing. A handler that was correct by construction became correct-if-you're-lucky, and the frontend had to grow a login-shaped ritual to hide the seam. When you split a monolith, the code that moves is rarely the code that breaks; the code that breaks is the code that quietly depended on the old timing.

Next, the part of the monolith where the timing didn't matter because the path never ran at all: the buy path that never worked.