Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/fourteen-steps-and-nine-of-them-invisible && read --section="top" 0%
Architecture

Fourteen Steps and Nine of Them Invisible

Getting money into a wallet in Inflow takes five HTTP requests and nine in-process reactions nobody documents. Reconstructing that path from source is the only way to get it - and every failure along it returns 204 No Content.

By Kumar Chandrachooda 05 Feb 2026 6 min read
A staircase where most of the treads are drawn but not solid

Part 1 counted the requests: thirty-seven across five .rest files, nine of them returning a 2xx on a fresh clone. That is the symptom. This is the cause. Inflow is a virtual payments app, so the thing it exists to demonstrate is money arriving in a wallet — and there is no file in the repository, and no paragraph in the README, that tells you how to make that happen.

I reconstructed it from source. It is fourteen steps: five you type and nine you do not. Here is the whole thing, in order, on master.

The five things you type

# Request File Auth
1 POST /account/sign-up with role user Users.rest anonymous
2 PUT /customers/complete Customers.rest the user
3 PUT /customers/{customerId}/verify Customers.rest admin
4 POST /deposits Payments.rest the user
5 PUT /deposits/{depositId}/complete Payments.rest anonymous webhook

Five requests, spread across three files, alternating between two identities, and none of the three files says anything about the other two. Add the four authentication requests the sequence needs — sign up and sign in as the user, sign up and sign in as the admin — and you are at nine HTTP calls before a single unit of currency has moved.

Note what is not in that list. POST /customers, the third request in Customers.rest, appears nowhere in the path. Step 1 already created the customer, in a handler nobody told you about, which is where the nine invisible steps come in.

The nine things you don't

# Trigger Where it lands What it does
1 SignedUp Customers.SignedUpHandler Creates the Customer from the new user; publishes CustomerCreated
2 CustomerCompleted Payments.CustomerCompletedHandler Mirrors a local Customer copy inside Payments
3 CustomerCompleted Wallets.CustomerCompletedHandler Creates the IndividualOwner
4 CustomerVerified Payments.CustomerVerifiedHandler Verifies its copy, resolves a currency from nationality, creates the DepositAccount, publishes DepositAccountAdded
5 CustomerVerified Wallets.CustomerVerifiedHandler Verifies the owner
6 CustomerVerified SagaEventHandler Starts NewCustomerBonusFundsSaga, records VerifiedAt
7 DepositAccountAdded Wallets.DepositAccountAddedHandler Creates the Wallet; publishes WalletAdded, which the saga stores
8 DepositCompleted Wallets.DepositCompletedHandler Credits the wallet with the deposit; publishes FundsAdded
9 DepositCompleted The saga Publishes AddFunds for a ten-unit new-customer bonus; the resulting FundsAdded completes the saga

Read the table alongside the previous one and the shape is clear. Every request you type is a trigger, and everything that gives the system its meaning happens between the requests. A single PUT /customers/{customerId}/verify fans out into three handlers in three modules, one of which publishes another event that fans out again. From the caller's side it is a 204.

Step 1 is the one that most reliably confuses a first-time reader:

public async Task HandleAsync(SignedUp @event, CancellationToken cancellationToken = default)
{
    if (@event.Role is not ValidRole)
    {
        return;
    }

    var customer = new Customer(@event.UserId, @event.Email, _clock.CurrentDate());
    await _customerRepository.AddAsync(customer);
    _logger.LogInformation($"Created a new customer based on user with ID: '{@event.UserId}'.");
    await _messageBroker.PublishAsync(new CustomerCreated(customer.Id), cancellationToken);
}

Sign up as a user and a customer exists, with the same id as the user. That is a genuinely elegant piece of design — it is the whole point of the estate's event-driven integration, demonstrated in nine lines. It is also why POST /customers throws customer_already_exists for anyone who signs up first, and user_not_found for anyone who does not. The endpoint is not broken; it is a second, alternative route into the same state, staged so a reader can compare the synchronous path with the event-driven one. Nothing says so, so it reads as a bug.

While you are in that handler, look at the last line. CustomerCreated is published and no handler in the estate consumes it. I searched every IEventHandler<> on master: the type appears exactly twice in the repository, at its own declaration and at that PublishAsync. It is a dead event, and it is dead in a way that costs nothing and teaches nothing.

Three identifiers, and where they are not

Assume you have the path. You still need three values to walk it, and the API hands you one.

The customer id is the user id, which the sign-in response returns in its body — Users.rest binds it correctly with {{sign_in.response.body.$.id}} before throwing it away later. Fine.

The deposit id is the problem. StartDeposit generates it server-side:

internal record StartDeposit(Guid AccountId, Guid CustomerId, string Currency, decimal Amount) : ICommand
{
    public Guid DepositId { get; init; } = Guid.NewGuid();
}

POST /deposits returns 204 No Content. No body, no Location header, no id. The only ways to learn the deposit id you must put into step 5 are to call GET /deposits afterwards and read it out, or to find the line the handler logged. Payments.rest does neither — it hardcodes @depositId = 00000000-0000-0000-0000-000000000001 and completes a deposit that does not exist.

There is a fossil here worth naming, because it shows the shape this API was once meant to have. appsettings.json configures CORS with "exposedHeaders": ["Resource-ID"]. Nothing in 496 C# files ever writes a Resource-ID header; the string appears in that one JSON array and nowhere else. The shared framework Inflow vendors clearly came from a host that returned created-resource identifiers in a header, and the configuration came with it while the behaviour did not.

The wallet id you never need for the happy path, which is fortunate, because Wallets.rest sets @walletId and @receiverWalletId to the same placeholder GUID and then posts a transfer between them.

Every failure on this path is a 204

Now the part that turns an onboarding problem into an operational one. appsettings.json ships with:

"messaging": {
  "useAsyncDispatcher": true
},
"outbox": {
  "enabled": false,
  "interval": "00:00:01"
}

InMemoryMessageBroker reads both. The outbox is off, so messages skip it; the async dispatcher is on, so instead of awaiting the handlers inline, the broker drops each message onto a System.Threading.Channels queue and returns. A BackgroundService drains that queue:

await foreach (var envelope in _messageChannel.Reader.ReadAllAsync(stoppingToken))
{
    try
    {
        _contextAccessor.Context ??= envelope.MessageContext.Context;
        await _moduleClient.PublishAsync(envelope.Message, stoppingToken);
    }
    catch (Exception exception)
    {
        _logger.LogError(exception, exception.Message);
    }
}

Any exception thrown by any of the nine invisible steps is caught there, written to a log, and dropped. The HTTP request that triggered it has already returned 204 No Content. If step 4 throws customer_not_found because you verified a customer Payments never mirrored, the verify request still succeeds. You get a wallet-less account and a green response.

This is not carelessness — it is the correct behaviour for an in-process pub/sub bus that must not let one subscriber's failure fail the publisher, and it is exactly why the outbox sitting inert next to it exists. Sixteen files of outbox and inbox machinery are implemented, complete, and shipped behind "enabled": false. Turn it on and every one of those nine steps becomes a durable, retryable record. The deliberateness is unmistakable, and it is written down nowhere, which is part 10.

Why no file could have contained this

Look at the two tables again and ask which .rest file the path belongs in. Steps 1 and the four auth calls are Users. Steps 2 and 3 are Customers. Steps 4 and 5 are Payments. The thing that makes the whole sequence worth demonstrating — a wallet that fills itself — belongs to Wallets, which contributes no request to the path at all and only observes the result.

The .rest files are organised by module because everything in this repository is organised by module. That is the architecture working as designed. The consequence is that the estate's central demonstration is the one thing its documentation structure cannot express, and the only artefact that could have carried it — a root-level walkthrough — is instead five unauthenticated GETs.

One of those five is not like the others. GET /payments does not fail because it lacks a credential; it fails because the route was never there. Next, the route that never existed, and why it is the best evidence in the repository that the README is telling the truth.