The Tests That Document the Bug
Inflow has nine tests, all in one module, and the most interesting one asserts a defect as if it were the specification. The honest retrospective on five modules, four architectures and what the estate actually teaches.
A test suite is a specification that runs. That is its great virtue and its quiet hazard: whatever the code did on the day the assertion was written becomes the thing the code is required to keep doing, whether or not anyone decided it. Part 14 finished the code. This part reads the tests, and then closes the series.
Nine tests, one module
The whole suite, on master:
| Project | Class | Facts |
|---|---|---|
Wallets.Tests.Unit |
Entities\WalletTests |
2 |
Wallets.Tests.Unit |
Commands\AddFundsHandlerTests |
1 |
Wallets.Tests.Unit |
Commands\TransferFundsHandlerTests |
1 |
Wallets.Tests.Integration |
Commands\AddFundsHandlerTests |
1 |
Wallets.Tests.Integration |
Commands\TransferFundsHandlerTests |
1 |
Wallets.Tests.Integration |
Events\CustomerCompletedHandlerTests |
1 |
Wallets.Tests.EndToEnd |
Controllers\TransfersControllerTests |
2 |
Nine facts, all in Wallets. Payments — the fattest module, 88 files, the deposit and withdrawal flows, the webhook — has none. Customers, Users and Saga have none.
What is there is well-built. The three projects are a real pyramid: a pure domain test with no doubles at all, a handler test with NSubstitute doubles, an integration test against real PostgreSQL through the real repositories, and an end-to-end test that boots the entire Bootstrapper through WebApplicationFactory<Startup> and posts JSON at a route. Someone knew what a test pyramid was and built one. The integration test for TransferFundsHandler is genuinely thorough — it loads both wallets back out of the database and asserts both balances and both published events, which is more than most handler tests I read in production code.
And every single one of the nine is a happy path.
The assertion that recorded the defect
[Fact]
public void given_incoming_and_outgoing_transfers_wallet_amount_should_be_properly_calculated()
{
// ... add 1000, deduct 200 ...
wallet.CurrentAmount().ShouldBe(expectedAmount);
wallet.Transfers.Count().ShouldBe(2);
wallet.Version.ShouldBe(2);
}
(Wallets.Tests.Unit\Entities\WalletTests.cs.)
Two mutations, Version 2. The other fact in the same file performs one mutation and asserts Version 2 as well. As part 7 traced, that is the _versionIncremented latch in AggregateRoot<T> — the counter moves at most once per in-memory instance.
Somebody wrote that assertion, saw 2 where 3 was the arithmetically obvious answer, and wrote ShouldBe(2). From that moment the latch stopped being a behaviour and became a requirement. A future contributor who removes the flag now breaks a green test and, reasonably, puts the flag back.
This is the single most instructive thing in the repository, and it has nothing to do with modular monoliths. A test written to match observed behaviour is a photograph, not a specification. The defence is cheap and almost nobody does it: write the assertion you expect first, and when it fails, decide whether the code or the expectation is wrong before you change either. The habit of adjusting the number until the bar goes green converts every surprise into a permanent contract.
What the tests do not touch
Not one negative path exists anywhere in the suite. Not one.
- No test for
InsufficientWalletFundsException— the only real money invariant in the estate. - No test for a currency mismatch on either the command or the wallet.
- No test for the compensating branch in
WithdrawalStartedHandler. - No test for the one-million cap from part 3, on a single amount or on a balance.
- No test that a transfer's two legs can be joined (part 5), which two extra lines in an existing integration test would have caught.
The unit-level TransferFundsHandlerTests is the weakest of the nine: it asserts only Received() interaction calls, and its message assertion is PublishAsync(Arg.Any<IMessage[]>(), Arg.Any<CancellationToken>()) — it never checks that the receiver gained the money or the sender lost it. Its integration sibling does check both, which is the right division of labour; the unit test simply is not asserting anything a refactor could break.
The intent to do more is recorded in the source and abandoned. Three assembly names appear in InternalsVisibleTo attributes and nowhere else: Inflow.Modules.Wallets.Tests.Contract (in all three Wallets projects) and Inflow.Modules.Payments.Tests.Integration and ...Tests.Unit. A contract-test project for the module with contracts, and any tests at all for Payments. The plan is in the code; the projects are not.
One mechanical hazard worth flagging for anyone who runs the suite. DbHelper.GetOptions<T> takes a useRandomDatabaseIdentifier parameter that defaults to true, but it only fires when postgres:connectionString is empty — and appsettings.test.json sets it to Host=localhost;Database=inflow-test;.... The branch that would have appended a unique identifier to a configured connection string is commented out in the source. So all three integration test classes share one physical database, and TestDbContext.Dispose calls EnsureDeleted(). Only WebApiTestBase carries [Collection("tests")]; the integration classes carry no collection attribute, so xUnit runs them in parallel by default. Three classes creating and dropping one database concurrently is a suite that fails for reasons unrelated to the code under test.
What the estate gets right
Being fair is the whole job in a retrospective on someone else's teaching code, so here is the credit column, and it is not short.
The boundaries genuinely hold. Five modules, zero project references between them, four PostgreSQL schemas, no cross-schema foreign keys. Whatever else is true, the headline claim is real and verifiable in the project files.
The derived balance is the best idea in the repository. Wallet.CurrentAmount() obtains event sourcing's one indispensable property — state is a function of history, so history is the only thing that can be wrong — inside an entirely ordinary relational model, and uses the same expression on the write-side guard and the read-side DTO. Part 2.
TransferFunds breaks the aggregate rule for a good reason and in the right order. Debit and credit as two adjacent statements with no branch between them; the failure-prone side first. Part 4.
Payments' concurrency token is the better of the two. ProcessedAt — a natural business timestamp — cannot be latched, needs no base class, is self-describing in the table, and gives you webhook idempotency for free. Part 7.
The persistence mapping is clean. Value objects, strongly-typed ids and two discriminated hierarchies all reach PostgreSQL through converters and configuration classes, with the domain project holding zero EF knowledge and exactly one compromise in it. Part 6.
And the compile-time ban is real. One ProjectReference in one .csproj makes infrastructure in the domain a build error forever. That is a stronger control than any review process, and it is the one thing the four-layer split unambiguously buys. Part 9.
What I would not copy
The absence of transactions. AddTransactionalDecorators() is declared once and called nowhere, so nothing in this estate runs in a database transaction while PostgresUnitOfWork, WalletsUnitOfWork, PaymentsUnitOfWork and UnitOfWorkTypeRegistry all exist and are wired. A fully-built, correctly-registered abstraction with no call site reads as working code forever. If you take one operational lesson from the series, take this: an unused registration is invisible in review and undetectable at runtime — the only defence is a test that asserts the behaviour, not the wiring.
Range checks that escape their scope. Amount's cap is right for a payment and wrong for a running total, and an implicit conversion operator is what carries it from one to the other.
Correlation by convention. A referenceId that points at its own row; a "withdrawal" literal declared three times in three assemblies; a JSON blob written by string interpolation on one side and deserialised on the other. Every cross-module join key in this estate is a value nothing validates.
N representations with N refresh disciplines. Five copies of a customer is right; four different subscription sets is how Owner.Lock() ends up a dead method on a live entity. Part 12.
The fair verdict
This is a free teaching repository, MIT-licensed, with no CI, no deployment and no hosted instance, last touched in 2022. It was never operated, and almost everything sharp in this series is the kind of thing that operating a system finds for you. Judged as what it is — a companion repo for a course on modular monoliths — it does its job: the module boundaries are real, the messaging spine works, and the deliberate contrast between a four-project onion and a one-project feature slice is a better teaching device than any diagram.
Judged as a model to copy wholesale, it has one systemic weakness, and it is the same weakness in every part of this series. The estate is full of correct mechanisms that are not switched on. Contracts that validate four of eighteen copied types. An outbox shipped disabled. Transactional decorators never registered. Policies declared and never applied. Exceptions declared and never thrown. FundsLocked with neither producer nor consumer. Even the .rest file has a @transferId variable it declares and never uses, sending GET /transfers/{{walletId}} instead. Seven intent comments across roughly 190 files means there is no way to tell a staged simplification from debt — and the two are indistinguishable to every future reader, including the author.
So the closing rule of thumb, earned across fifteen parts: in a modular estate, the risk is not the boundaries you draw, it is the mechanisms you build and never turn on. Draw fewer, wire them all, and write down which ones you deliberately left dark.
Three companion series continue from here. The Framework Underneath reads the shared mini-framework these modules sit on — the message broker, the dispatchers, the outbox and the contract registry. One Module Leaves the Process follows the microservices branch, where one module is extracted behind RabbitMQ. The Repo Is the Lesson reads the repository itself as an artefact: its requests, its history and its claims.