The Wallet That Adds Itself Up
Inflow's Wallet has no balance column - the balance is a fold over the transfer ledger, computed the same way on the write side and the read side. It is the best decision in the module, and it costs more than it looks.
Almost every account balance I have had to debug in production was wrong in the same way. There was a Balance column, there was a ledger table, and at some point in the system's life the two stopped agreeing — a failed job, a manual correction, a race between two writers, a migration that backfilled one and not the other. The reconciliation script that follows is a rite of passage, and the bug it fixes was designed in on day one, the moment somebody decided the balance was a thing you store.
Part 1 set up the estate. This part goes to the single class that makes the whole Wallets module worth reading, and to the decision inside it that quietly makes that class of bug impossible.
Four lines that replace a column
src\Modules\Wallets\Inflow.Modules.Wallets.Core\Wallets\Entities\Wallet.cs is seventy lines long. This is the last method in it:
public Amount CurrentAmount()
=> _transfers.OfType<IncomingTransfer>().Sum(x => x.Amount)
- _transfers.OfType<OutgoingTransfer>().Sum(x => x.Amount);
(Inflow, branch master.)
That is the balance. Not a cached field, not a projection, not a column — a fold over the aggregate's own child collection, recomputed from scratch every single time anyone asks. And the schema backs it up. Here is the whole Wallets table from the module's only migration:
migrationBuilder.CreateTable(
name: "Wallets",
schema: "wallets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OwnerId = table.Column<Guid>(type: "uuid", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp without time zone", nullable: false),
Version = table.Column<int>(type: "integer", nullable: false)
},
// ...
(20211229212750_Wallets_Init.cs.)
Five columns, and none of them is Balance. The balance and the ledger cannot disagree, because there is only one of them. Everything a wallet is worth lives in wallets.Transfers, one row per movement, discriminated into IncomingTransfer and OutgoingTransfer by a Type column, and the number a caller sees is derived on demand. This is event sourcing's single best property — state is a function of history, so history is the only thing that can be wrong — obtained inside an entirely ordinary relational model with no event store, no snapshots, and no framework.
I want to be unambiguous about this before I start complicating it. In a teaching repository about modular monoliths, where the balance could easily have been a decimal property with a += in two handlers, someone chose the shape that removes an entire failure class. It is the best decision in the module.
The same expression on both sides of CQRS
What makes it more than an aesthetic choice is that the derivation is used everywhere, not just on the read path.
On the write side it is the guard:
public OutgoingTransfer DeductFunds(TransferId transferId, Amount amount, DateTime createdAt,
TransferName name = null, TransferMetadata metadata = null)
{
if (amount <= 0)
{
throw new InvalidTransferAmountException(amount);
}
if (CurrentAmount() < amount)
{
throw new InsufficientWalletFundsException(Id);
}
// ...
}
Read that middle condition carefully, because it is the only real money invariant in the entire estate. “You may not take out more than the ledger says is there” is checked against the fold, not against a stored figure — so there is no window in which a stale balance authorises an overdraft. The invariant is enforced against the same source of truth that the customer is looking at.
On the read side, the mapper that builds the details DTO calls the identical method:
public static WalletDetailsDto AsDetailsDto(this Wallet wallet)
{
var dto = wallet.Map<WalletDetailsDto>();
dto.Amount = wallet.CurrentAmount();
dto.Transfers = wallet.Transfers.Select(x => x.AsDto())
.OrderByDescending(x => x.CreatedAt)
.ToList();
return dto;
}
(Wallets.Application\Wallets\Queries\Handlers\Extensions.cs.)
One expression, two sides of the CQRS split, no possibility of drift. In most estates I have read, the query side reimplements the calculation — usually as SQL, usually subtly differently, usually with a rounding rule the domain does not have — and the two answers diverge under exactly the conditions nobody tested. Here the read model asks the aggregate. It costs a materialisation, and it buys the guarantee that the number on the screen is the number the guard used.
What the fold actually costs
Now the bill, because it is larger than the four lines suggest and none of it is visible from the handler.
Every write loads the entire history. The DeductFunds guard needs a fully hydrated _transfers, and nothing in Wallet says so — the requirement is silent and satisfied one layer away:
public Task<Wallet> GetAsync(WalletId id)
=> _wallets
.Include(x => x.Transfers)
.SingleOrDefaultAsync(x => x.Id == id);
(Wallets.Infrastructure\EF\Repositories\WalletRepository.cs.)
Both GetAsync overloads Include the transfers unconditionally. Add funds, deduct funds, or complete a deposit, and the wallet arrives with every movement it has ever made attached. A wallet with fifty thousand transfers pays fifty thousand rows to append one — and the aggregate cannot tell you that, because from inside Wallet.cs the collection simply exists.
Every write also rewrites the entire history. The update side is three lines:
public async Task UpdateAsync(Wallet wallet)
{
_wallets.Update(wallet);
await _context.SaveChangesAsync();
}
DbSet.Update marks the whole tracked graph as Modified, not just the entity you handed it. One new transfer against a wallet with N history rows produces one INSERT and N redundant UPDATE Transfers statements, every one of them writing values identical to the ones already there. TransferFunds — part 4 — does this to both wallets in the same request.
And the read side quietly declines to pay. GET /wallets/{walletId} returns WalletDetailsDto, which has an Amount. GET /wallets returns WalletDto, which does not — no amount property at all — and the storage implementation behind the browse does not Include transfers. So the balance simply does not exist at list level. That is a defensible performance decision hiding inside what looks like a DTO-inheritance detail, and it gets a full part of its own in the read side the layers lost.
One total, one owner
There is a structural consequence worth naming, because it is the reason the decision holds up across the whole estate rather than just inside one class.
In all five modules there is exactly one place where a total exists, and it is derived. Payments has no balance concept at all: a Deposit has an Amount, a Withdrawal has an Amount, and nothing anywhere sums them. The payments schema records individual movements into and out of the system and never aggregates. Customers and Users hold no money at all. So the question “how much does this customer have” has one answer, computed by one expression, in one module — and the modules that could have kept a convenient cached copy of it did not.
That is the modular-monolith version of a single source of truth, and it is harder to hold than it looks. The tempting shortcut in every estate like this is for Payments to keep a running total so its own screens are fast, at which point you have two numbers and a reconciliation job. Inflow does not take it. Whether that was a decision or an absence of pressure is, as usual, unrecorded — there is no comment anywhere saying “Payments deliberately holds no balance” — but the result is right.
The derivation also makes the definition cheap to change. Adding a third transfer type — a hold, a fee, a reversal — means one more clause in one expression, and every historical balance recomputes correctly the moment the code deploys. The stored-column version of the same feature is a backfill migration, and backfill migrations on money are how weekends get spent.
The version of this that scales
If you like the property and not the price — and the property really is worth wanting — the standard escape is to keep the fold as the definition and add a materialised balance as a cache with a proof. Something like:
// Illustrative, not Inflow's code.
public Amount CurrentAmount() => _cachedAmount ?? Fold();
public void Rebuild()
{
var folded = Fold();
if (_cachedAmount is not null && _cachedAmount != folded)
{
throw new BalanceDriftException(Id, _cachedAmount, folded);
}
_cachedAmount = folded;
}
private Amount Fold()
=> _transfers.OfType<IncomingTransfer>().Sum(x => x.Amount)
- _transfers.OfType<OutgoingTransfer>().Sum(x => x.Amount);
The stored number becomes an assertion rather than a second source of truth: a nightly job folds the ledger, compares, and screams if they differ. You get the constant-time read and you keep the property that drift is detectable rather than silent. The other move — cutting the history the fold has to traverse with periodic closing balances, so that the ledger is only ever summed from the last checkpoint — is the same idea with a bounded working set.
Inflow does neither, and for a teaching repository that is the right call: the four-line version teaches the idea, and the caching version teaches cache invalidation. The lesson is not “always fold” — it is that a balance which is derived cannot drift, so if you must store one, store it as a claim you can check.
There is one more thing the fold does, though, and it is not a cost — it is a landmine. Sum returns a decimal; the subtraction returns a decimal; the method's return type is Amount. Something has to happen at that boundary, and what happens is a constructor with a range check in it. Next, a million is where the wallet breaks.