An Afternoon in March
One branch in Inflow carries five commits timestamped 11:56, 13:49, 14:11, 14:49 and 16:08 on a single Saturday. That is not a development history - it is a live teaching session committed as it happened, and it explains a defect on master.
Part 5 ended on a question. If Inflow's README is accurate mainly because it is too short to be wrong, and there are no ADRs, no docs/ folder and no decision log anywhere in the repository, then where did the author's reasoning go?
Some of it is on a CDN and some of it is behind a paywall. But a surprising amount of it is in the git history, on a branch nothing points at, and reading it feels like finding a lecture recording in a filing cabinet.
Inflow has twelve commits across all six branches. master has three: init, .NET6 upgrade, packages update. Every other branch has exactly one — a single squashed commit representing a variant. Except one.
47ab400 2022-03-19 16:08:58 +0100 completed flow origin/warsztaty
6c26356 2022-03-19 14:49:56 +0100 user updated
2c46fe8 2022-03-19 14:11:52 +0100 signed up
68a2c42 2022-03-19 13:49:22 +0100 signed up
490f7fd 2022-03-19 11:56:11 +0100 create customer
4b36951 2022-03-14 18:18:25 +0100 warsztaty
Five commits, four hours and twelve minutes, one Saturday. That is not a development history. It is a room.
Monday: setting the exercise
The branch starts five days earlier, on Monday 14 March at 18:18, with a commit whose subject is just the branch name. 4b36951 touches 26 files and is almost entirely deletions: −641 lines, +4.
It removes CreateCustomer and its handler, the SignedUp and UserStateUpdated external events and their handlers in Customers, CustomerVerified and its handler in Payments, DepositCompleted and its handler in Wallets, the module's contract registrations, the POST /customers action, the entire Saga implementation, and the three logging decorators. What is left is a modular monolith whose modules no longer talk to each other at all.
The +4 is four booleans in appsettings.json, flipped in the same commit:
"file": { "enabled": false },
"seq": { "enabled": false },
"messaging": { "useAsyncDispatcher": false },
"security": { "encryption": { "enabled": false } }
An instructor preparing a room full of people to clone and run this turned off file logging, turned off the Seq sink that docker-compose.yml does not start, turned off encryption, and — the interesting one — turned off asynchronous dispatch, so that a failing event handler would throw into the caller's request instead of being swallowed by a background service. That is precisely the right setting for a classroom, where an exception you can see beats a 204 you cannot.
None of those four flips ever reached master, which still ships all four the other way.
11:56 — the naive version
The first commit of the session adds a command and a handler, and nothing else:
public async Task HandleAsync(CreateCustomer command, CancellationToken cancellationToken = default)
{
_ = new Email(command.Email);
var customer = new Customer(Guid.NewGuid(), command.Email, _clock.CurrentDate());
await _customerRepository.AddAsync(customer);
_logger.LogInformation($"Created a customer with ID: '{customer.Id}'.");
}
A customer with a fresh random id and an email, and no relationship to anything else in the system. This is the version you write first so that the room can see what is wrong with it: the Users module already has an identity for this person, and this handler has just minted a second one.
13:49 — the synchronous fix, and an empty stub
After a gap of one hour fifty-three minutes — lunch, or a break, or a long explanation with no code in it — the handler is rewritten:
var user = await _userApiClient.GetAsync(command.Email);
if (user is null)
{
throw new UserNotFoundException(command.Email);
}
if (user.Role is not "user")
{
return;
}
var customerId = user.UserId;
The customer id is now the user id, fetched across the module boundary through IModuleClient. That is the synchronous half of Inflow's integration story, taught before the asynchronous half.
And in the same commit, this:
internal sealed class SignedUpHandler : IEventHandler<SignedUp>
{
public async Task HandleAsync(SignedUp @event, CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
}
}
An empty handler, committed. Nobody writes that file and commits it in a working session — you write the stub, then you fill it in. Committing the stub is what happens when the person at the keyboard says “so we create the handler, we implement the interface, and now…” and someone in the room asks a question.
14:11 — the body, and the switch
Twenty-two minutes later the body lands, and it is the same logic as the synchronous handler with the network call removed, because the event already carries what the API call had to go and fetch:
if (@event.Role is not "user")
{
return;
}
var customerId = @event.UserId;
The same commit flips one line back:
"messaging": { "useAsyncDispatcher": true }
The setting turned off on Monday to make the classroom debuggable is turned on at 14:11 on Saturday, at exactly the moment the lesson stops being about handlers and starts being about asynchronous handlers. A single boolean, moved twice, five days apart, marking the boundary of a topic.
14:49 — contracts
Thirty-eight minutes later the event grows an attribute and a companion:
[Message("users")]
internal record SignedUp(Guid UserId, string Email, string Role) : IEvent;
internal class SignedUpContract : Contract<SignedUp>
{
public SignedUpContract()
{
RequireAll();
}
}
This is the payoff of the whole architecture. Customers does not reference Users, so it re-declares Users' event as its own local type, and ContractRegistry.Validate() throws at startup if the two shapes have drifted. The commit registers both contracts in CustomersModule and adds the UserStateUpdated event and its handler alongside.
It also carries two live-coding fingerprints. The handler file is named UserStateUpdated.cs and sits in the Handlers folder next to the event it handles — on master the same file is UserStateUpdatedHandler.cs. And the registration reads:
.Register<SignedUpContract>()
.Register<UserStateUpdated>();
The second line registers the event type where it should register the contract type.
16:08 — the chain closes
Seventy-nine minutes later, the final commit adds CustomerVerified and its handler in Payments and DepositCompleted and its handler in Wallets, which is the last link of the fourteen-step path part 2 reconstructed. Its subject is completed flow, and it earns it: after this commit money can travel from a sign-up to a wallet.
It also contains a one-character correction:
- .Register<UserStateUpdated>();
+ .Register<UserStateUpdatedContract>();
Someone spotted the slip from 14:49, seventy-nine minutes later, and folded the fix into the last commit of the day rather than making a commit of its own. That is not how a maintainer works. It is how a person works when the session is nearly over and there is one more thing to get right before everyone leaves.
The lesson plan, recovered
Put the five commits in a column and a curriculum falls out:
| Time | What it teaches |
|---|---|
| 11:56 | Naive: create the entity locally, mint a new id |
| 13:49 | Synchronous cross-module integration: ask the other module who this is |
| 14:11 | Asynchronous integration: the event already told you, and the dispatcher goes async |
| 14:49 | Local contracts: re-declare the event and verify its shape at boot |
| 16:08 | The full chain: verification to deposit to wallet |
Naive, then synchronous, then asynchronous, then verified, then end-to-end. That is a well-built four-hour session, and none of it is written down as prose anywhere in the repository. It exists only as the sequence of five commits on a branch whose name appears in no document.
And here is the payoff, because it reaches back into master. The version of CreateCustomerHandler on master is the 13:49 version — the synchronous one, complete with its IUserApiClient call. On master the event-driven SignedUpHandler also exists and has already created the customer by the time anyone calls it. So POST /customers, the third request in Customers.rest, throws user_not_found on a clean database and customer_already_exists on a used one.
Part 1 recorded that as a request that cannot succeed. The workshop tells you what it actually is: teaching step three, shipped on the main branch alongside teaching step four, which supersedes it. The endpoint is not a bug and it is not dead code. It is the “before” half of a comparison, and the “after” half is SignedUpHandler sitting eight folders away with no comment connecting the two. The workshop branch is where that connection is drawn — twenty-two minutes apart, in commit order, on a branch created four months after master had already shipped both halves silently.
Nothing on master says any of that. The evidence exists, it is complete, and it is reachable only by someone who runs git log --all on a repository they were told to read the README of.
There is a second workshop branch. It was prepared two weeks earlier, for a different audience, from the same starting commit — and it starts from a different place and never converges. Next, two workshops that forked.