Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/lock-the-customer-half-the-estate-notices && read --section="top" 0%
Architecture

Lock the Customer, Half the Estate Notices

Locking a customer flips a flag in three of the four modules that hold one. Wallets has Lock and Unlock methods on its Owner entity and nothing in the estate ever calls them - so the module that moves money is the module that never hears.

By Kumar Chandrachooda 15 Jan 2026 7 min read
One switch thrown, three of four lights change

“Lock this customer” is the operation every financial system has and nobody specifies. It means stop the money, which means every subsystem that can move money has to agree about it — and in an eventually-consistent estate, “agree” is a chain of events, each of which can quietly not be wired up.

Part 11 mapped a single Guid onto five representations with five refresh disciplines. This part follows one instruction through them, because it is the case where the disciplines diverge and you can name the row.

The chain, as far as it goes

An administrator calls the Users module:

user.State = state;
await _userRepository.UpdateAsync(user);
await _messageBroker.PublishAsync(new UserStateUpdated(user.Id, state.ToString().ToLowerInvariant()),
    cancellationToken);

(Users.Core\Commands\Handlers\UpdateUserStateHandler.cs, branch master.)

Customers picks it up and translates a string state into a domain operation and a new event:

IEvent integrationEvent;
switch (@event.State.ToLowerInvariant())
{
    case "active":
        customer.Unlock();
        integrationEvent = new CustomerUnlocked(customer.Id);
        break;
    case "locked":
        customer.Lock();
        integrationEvent = new CustomerLocked(customer.Id);
        break;
    default:
        _logger.LogWarning($"Received an unknown user state: '{@event.State}'.");
        return;
}

(Customers.Core\Events\External\Handlers\UserStateUpdatedHandler.cs.)

That is a well-written handler. It is one of the very few places in the estate with a default: arm that notices an unrecognised value instead of silently ignoring it, and it correctly does not publish anything it does not understand.

CustomerLocked and CustomerUnlocked then go out onto the bus. Searching src\**\*.cs on master for handlers of either returns exactly two files, both in Payments:

internal sealed class CustomerLockedHandler : IEventHandler<CustomerLocked>
{
    // ...
    customer.Lock();
    await _customerRepository.UpdateAsync(customer);
}

And that is the end of the chain. Three representations updated — users.Users.State, customers.Customers.IsActive, payments.Customers.IsActive. The fourth is not subscribed.

The two dead methods

internal abstract class Owner
{
    public OwnerId Id { get; private set; }
    public OwnerName Name { get; private set; }
    public bool IsActive { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? VerifiedAt { get; private set; }

    // ...

    public void Verify(DateTime verifiedAt)
    {
        VerifiedAt = verifiedAt;
    }

    public bool Lock() => IsActive = false;

    public bool Unlock() => IsActive = true;
}

(Wallets.Core\Owners\Entities\Owner.cs.)

IsActive is a real column — 20211229212750_Wallets_Init.cs creates it as boolean, nullable: false on the Owners table — and it is set to true by the constructor. Lock() and Unlock() are written, compiled, shipped and never called by anything in the repository. Wallets has an external-events folder with handlers for CustomerCompleted, CustomerVerified, DepositAccountAdded, DepositCompleted and WithdrawalStarted. It has no handler for CustomerLocked or CustomerUnlocked, and WalletsModule.Use registers contracts for only the two events it does consume.

Lock a customer and the wallets.Owners row stays IsActive = true permanently. There is no reconciliation job, no periodic sync, no query that would surface the divergence. Two live methods on a live entity, reachable by nothing, guarding a column that will never change value.

To be fair — and then not

The immediate blast radius is smaller than it sounds, and the reason is worth reading carefully.

IsActive on Owner is not consulted anywhere either. A repository-wide grep for IsActive inside the Wallets module returns the property declaration, the constructor assignment, the two dead methods and the migration. No Wallets handler ever asks whether an owner is active. So the flag being stale changes nothing today, because the flag is not an input to any decision. The module is not acting on wrong data; it is not acting on the data at all.

That is genuinely mitigating, and in a teaching repository it is a defensible place to stop — the estate demonstrates the lock propagating across two module boundaries, which is the lesson, and the third hop would have been more of the same.

It is also exactly what makes the gap dangerous rather than merely untidy, because of what the other module does with its copy:

if (!customer.IsActive || !customer.IsVerified)
{
    throw new CustomerNotActiveException(command.CustomerId);
}

(Payments.Core\Deposits\Commands\Handlers\StartDepositHandler.cs; StartWithdrawalHandler is identical.)

So a locked customer cannot start a deposit and cannot start a withdrawal. Money cannot enter or leave the estate through Payments. Meanwhile POST /transfers/funds — the wallet-to-wallet transfer — is served by TransfersController, whose only check is class-level [Authorize] plus the handler's ownership test that the sending wallet's OwnerId matches the caller's identity. Nothing on that path reads any activity flag anywhere.

The result is a system where locking a customer stops them funding and withdrawing, and does not stop them moving money between wallets. Their bearer token remains valid — auth:expiry in appsettings.json is 07.00:00:00, seven days, and there is no revocation list, no refresh-token store, no token blacklist anywhere in the shared infrastructure. SignInHandler does check user.State != UserState.Active and refuses to issue a new token, which is the right check in the wrong place: it gates issuance, not use. A customer locked one minute after signing in keeps a working token for a week, and one of the three money-moving endpoints keeps honouring it.

I would not call this a vulnerability in a repository with no deployment. I would call it the clearest illustration in the estate of the real cost of N representations: the security decision is only as strong as the least-subscribed copy.

The vocabulary in the middle

The chain has one more soft joint, in the hop this article opened with.

Users publishes UserStateUpdated(user.Id, state.ToString().ToLowerInvariant()) — a UserState enum member, flattened to a lower-cased string at the boundary. Customers matches it against two string literals in a switch. So the vocabulary that decides whether a customer is locked is defined by an enum in one module, canonicalised by a ToLowerInvariant() call in that module's handler, and re-declared as case "active" and case "locked" in another module's handler.

Three places decide what the word is, and only one of them is a type. Add a UserState.Suspended in Users and Customers logs a warning and returns — which is the correct conservative behaviour, and also means the estate's response to a new state is to quietly do nothing while the administrator sees a successful 204. The default: arm is the estate's only near-miss detector, and it covers exactly one field.

The same coupling could have been a shared enum in the abstractions assembly, or an event per transition — UserLocked and UserUnlocked rather than one event carrying a state string — which is what Customers immediately converts it into anyway. The estate translates a state string into two typed events one hop later, having spent that hop stringly-typed for no gain.

Why the estate cannot see it

Every mechanism Inflow has for governing cross-module drift is aimed at the wrong thing.

Local contracts validate the shape of a message a module has declared it consumes. There is no mechanism — and, to be fair, no obvious mechanism to build — that flags an event with no consumer in a module that logically needs one. The estate has six declared messages with no consumer at all and one, FundsLocked, with neither producer nor consumer. CustomerLocked is not on that list; it has a consumer. It just has one fewer than the domain requires, and nothing distinguishes “deliberately not needed here” from “forgotten”.

Nor would a test catch it. Wallets is the only tested module, and its three test projects contain no test of any external-event handler except CustomerCompletedHandler. A missing handler has no code to test.

The prescriptions

Three, in ascending cost.

  1. Subscribe. A CustomerLockedHandler in Wallets is fifteen lines and mirrors the one Payments already has. Whether Owner.IsActive then gates transfers is a product decision — but the flag being right costs almost nothing, and a flag that is right is a flag a future feature can trust.
  2. Delete what you do not subscribe to. If Wallets deliberately does not care about locking, Lock(), Unlock() and IsActive should not exist on Owner. A dead method on a live entity is worse than an absent one: it reads as a capability the module has, and the next person to need it will call it and assume the state arrives from somewhere.
  3. Make the fan-out visible. The estate's routing is by type name — a message is delivered to every class with the same simple name in every loaded assembly — so the set of consumers for an event is computable at startup. A boot-time log line per published message type listing its subscribing modules would have made “CustomerLocked → Payments” visible on every single run, next to “CustomerVerified → Payments, Wallets, Saga”. That is the cheapest governance available for an in-process bus and the estate does not emit it.

The rule of thumb: in an estate with N copies of an entity, an instruction is only as effective as its least-subscribed copy — so count the subscribers of anything that means “stop”. Locks, holds, freezes, suspensions and closures are the events where a missing handler does not degrade a feature; it removes a control.

Next, a correlation key that crosses a module boundary as hand-written JSON in a text column: a saga held together by a string.