One Guid, Five Customers
A single Guid minted at sign-up becomes a user, a customer, a payments customer, a wallet owner and a saga id - four Postgres schemas, five field names, zero foreign keys and nothing that declares the equality.
The promise of a modular monolith is that modules do not share a database. Inflow keeps it: four PostgreSQL schemas — users, customers, payments, wallets — and not a single foreign key between them. No module can join to another's tables, and no module compiles against another's types.
What they share instead is one Guid. Part 10 closed the architecture arc; this part follows that Guid through the estate, because it is the most load-bearing fact in the system and it is written down nowhere.
Where it is minted
internal record SignUp([Required] [EmailAddress] string Email, [Required] string Password, string Role) : ICommand
{
public Guid UserId { get; init; } = Guid.NewGuid();
}
(src\Modules\Users\Inflow.Modules.Users.Core\Commands\SignUp.cs, branch master.)
One Guid.NewGuid() in an init-only property, generated when the command record is constructed by model binding. That value becomes users.Users.Id, and then it becomes everything else.
SignUpHandler publishes SignedUp. Customers.SignedUpHandler receives it and does new Customer(@event.UserId, @event.Email, now) — the customer's primary key is the user's primary key, assigned by argument position. When the customer completes and verifies, CustomerCompleted and CustomerVerified go out carrying CustomerId, which is the same value again. Payments builds a local Customer from it. Wallets builds an IndividualOwner from it. And when Payments creates a deposit account, Wallets creates a wallet:
var wallet = new Wallet(Guid.NewGuid(), @event.CustomerId, @event.Currency, _clock.CurrentDate());
(Wallets.Application\Wallets\Events\External\Handlers\DepositAccountAddedHandler.cs.)
The wallet gets a fresh id; its OwnerId is the customer id. The identity is renamed in one argument position, in one file, with no comment. From that line onward the estate's most-executed lookup works:
var wallet = await _walletRepository.GetAsync(@event.CustomerId, @event.Currency);
GetAsync(OwnerId ownerId, Currency currency) — a Payments-side customer id passed to a Wallets repository as an owner id, converted implicitly by OwnerId's Guid operator, and it finds the right row because the two are the same number. That call appears in DepositCompletedHandler and WithdrawalStartedHandler, which between them are the entry points for every deposit and every withdrawal in the system.
Five representations, five refresh disciplines
| # | Type | Schema | Holds | Created by | Refreshed by |
|---|---|---|---|---|---|
| 1 | Users.Core\Entities\User |
users |
Email, Password, Role, State | SignUpHandler |
own commands only |
| 2 | Customers.Core\Domain\Entities\Customer |
customers |
Email, Name, FullName, Address, Nationality, Identity, Notes, IsActive | SignedUpHandler or CreateCustomerHandler |
UserStateUpdatedHandler, own commands |
| 3 | Payments.Shared\Entities\Customer |
payments |
FullName, Nationality, IsActive, IsVerified | CustomerCompletedHandler |
verified, locked, unlocked |
| 4 | Wallets.Core\Owners\Entities\IndividualOwner |
wallets |
Name, FullName, IsActive, CreatedAt, VerifiedAt | CustomerCompletedHandler |
verified only |
| 5 | Saga correlation id | none | SagaId string |
NewCustomerBonusFundsSaga.ResolveId |
n/a |
Five objects, one identifier, and — the part that matters — five different subsets of the truth, each refreshed by a different set of events. This is not a defect; it is what modular ownership means. Payments does not need a customer's address, so Payments does not hold one. Wallets does not need an email. Each module keeps the projection of the customer that its own decisions require, and each keeps it in its own schema. That is the design working.
The costs arrive with the discipline column, and they are not symmetric.
Row 3 is missing a field its source has. Payments' local copy of the upstream event is CustomerCompleted(Guid CustomerId, string FullName, string Nationality); the Wallets copy of the same event is CustomerCompleted(Guid CustomerId, string Name, string FullName, string Nationality). Payments drops Name. That is legal by design — the contract mechanism validates consumer-declared properties, so a subset copy passes — and it is invisible: nothing in Payments records that a field was declined rather than never offered.
Row 4 never learns about locking at all. That is a large enough finding to get its own article; part 12 is it.
And nothing anywhere reconciles. Uniqueness is enforced independently per schema — users.Users.Email is unique, customers.Customers.Email and .Name are unique — and there is no process, job, query or startup check that would notice a row present in one schema and absent from another. If CustomerCompleted is dropped between modules, Payments has a customer and Wallets has no owner, forever, and the first symptom is a WalletNotFoundException on somebody's first deposit.
Which is not hypothetical, because the outbox ships disabled ("outbox": { "enabled": false } in appsettings.json) and the async dispatcher publishes onto an in-process channel. A message lost between modules is lost with no record.
The equality nobody declared
The estate has a mechanism for governing cross-module drift — local contracts, where a consumer re-declares an event and asserts at boot that the producer's type still matches. Four of the eighteen copied message types in the estate are covered by one, and Wallets' two customer events are among them.
Contracts compare property names and CLR types. Consider what the coupling here actually is:
- Users calls it
UserId. Customers calls itCustomerId. Wallets calls itOwnerId. - The assertion is not “these fields have the same shape”. It is "these differently-named fields hold the same value".
No structural contract can express that, because there is no shared name to match on and no shared type to compare — all three are Guid. The hardest coupling in a modular estate is the one no schema can hold, and this is the canonical example of it.
The place the equality comes closest to being written down is a switch expression in the Saga module:
public override SagaId ResolveId(object message, ISagaContext context)
=> message switch
{
CustomerVerified m => m.CustomerId.ToString(),
DepositCompleted m => m.CustomerId.ToString(),
WalletAdded m => m.OwnerId.ToString(),
FundsAdded m => m.OwnerId.ToString(),
_ => base.ResolveId(message, context)
};
(Saga.Api\Sagas\NewCustomerBonusFundsSaga.cs.)
Read that as a specification and it says: the customer id in Customers, the customer id in Payments and the owner id in Wallets are the same identity. It is the only place in the repository where all three names appear together, it is four lines of a switch in a module that neither owns nor consumes any of them, and it carries no comment. Delete the Saga module and the estate's only written statement of its central invariant goes with it.
There is one more echo of the assumption in the type system, and it is odd enough to note. WalletId and TransferId both declare:
public static implicit operator WalletId(OwnerId id) => id.Value;
An owner id silently becomes a wallet id, or a transfer id. Strongly-typed identifiers exist precisely to make that assignment a compile error; these three types opt back out for reasons no call site in the module explains.
What I would do instead
I would not merge the representations. Five projections of a customer, each owned by the module that needs it, is the right answer and the reason the boundaries hold at all.
I would make the equality explicit and testable, three cheap ways:
- One name for the identity across the estate. If it is a
CustomerIdin Payments, it is aCustomerIdin Wallets too, even though Wallets calls the entity anOwner. The rename buys nothing and costs the only clue a reader gets. - A shared identity type in the abstractions assembly —
CustomerId : TypeId, referenced by all four modules. It is the one thing a modular monolith may legitimately share, because it is the one thing the modules genuinely agree on, and it turns “sameGuid, different names” into a compile-time fact. - A boot-time or test-time assertion in the spirit of the contract registry: a single integration test that signs up, completes, verifies and then asserts that the same
Guidis the primary key in all four schemas. Twenty lines, and it converts the estate's most load-bearing assumption from folklore into a failing build.
The rule of thumb: if a value crosses a module boundary and changes name, the boundary has an invariant that no tool is checking. Rename it back, or write the assertion.
Next, the representation that never gets the message: lock the customer, half the estate notices.