A Million Is Where the Wallet Breaks
Inflow's Amount value object rejects anything above one million, and the wallet balance is an Amount. Two legal credits of six hundred thousand leave a wallet that can still take money in and can never be read or spent again.
A range check is the cheapest domain rule there is. One line in a constructor, no dependencies, no configuration, and it catches the fat-finger transfer of ten million before it reaches the ledger. It is also the rule most likely to be written once, for one purpose, and then inherited by a second purpose that has different arithmetic — and that is a bug you cannot see by reading either piece of code on its own.
Part 2 showed the best decision in Inflow's Wallets module: the balance is a fold over the transfer ledger rather than a stored column. This part is about what happens when that fold crosses a type boundary.
The check
Amount lives in Inflow.Shared.Abstractions, shared by every module in the estate. The whole rule is four lines:
public class Amount : IEquatable<Amount>
{
public decimal Value { get; }
public Amount(decimal value)
{
if (value is < 0 or > 1000000)
{
throw new InvalidAmountException(value);
}
Value = value;
}
public static implicit operator Amount(decimal value) => new(value);
public static implicit operator decimal(Amount value) => value.Value;
// ...
}
(src\Shared\Inflow.Shared.Abstractions\Kernel\ValueObjects\Amount.cs, branch master.)
As a rule about a payment, this is fine and I would happily ship it. No negative transfers, no absurd ones, no configuration to get wrong. In a teaching estate whose demo requests move a few hundred euros, it is exactly the right amount of ceremony.
The problem is the two implicit conversion operators underneath it, and where they are used.
The line that walks through the ceiling
Here is the fold again, with its signature:
public Amount CurrentAmount()
=> _transfers.OfType<IncomingTransfer>().Sum(x => x.Amount)
- _transfers.OfType<OutgoingTransfer>().Sum(x => x.Amount);
Trace the types, because the bug is entirely in the types and nowhere in the logic.
x.Amountis anAmount.Enumerable.Sumhas no overload for it, so the compiler appliesimplicit operator decimal(Amount)and picksSum(Func<T, decimal>). Both sums aredecimal.- The subtraction is
decimal - decimal, so the expression's type isdecimal. - The method's declared return type is
Amount. So the compiler appliesimplicit operator Amount(decimal)— which runs the constructor, and therefore the range check, on the running total.
Amount's guard was written to validate a single movement. The conversion operator quietly enlists it to validate an accumulator. And nothing anywhere caps the accumulator: AddFunds checks only amount <= 0 before appending, so a wallet will accept credits forever.
public IncomingTransfer AddFunds(TransferId transferId, Amount amount, DateTime createdAt,
TransferName name = null, TransferMetadata metadata = null)
{
if (amount <= 0)
{
throw new InvalidTransferAmountException(amount);
}
// ... appends and returns; CurrentAmount() is never consulted
}
Two credits of 600,000 are individually legal, and their sum is not.
What the wallet does next
Once _transfers sums above one million, every call to CurrentAmount() throws InvalidAmountException. Follow that through the three places the fold is used:
GET /wallets/{walletId} throws. The details mapper does dto.Amount = wallet.CurrentAmount(), so the query handler faults. The estate's exception middleware maps any InflowException to a 400 Bad Request with an error code derived from the class name, so the customer's own wallet answers every read with {"errors":[{"code":"invalid_amount","message":"Amount: '1200000' is invalid."}]}. Not a 500. Not an alert. A validation error, on a GET, blaming the caller for a number the caller never sent.
DeductFunds throws. Its guard is if (CurrentAmount() < amount), and the fold now throws before the comparison happens. The customer cannot withdraw, cannot transfer out, cannot reduce the balance back under the ceiling. The one operation that would repair the state is the one the state has disabled.
TransferFunds throws, for the same reason — its first act is a DeductFunds on the sender.
And AddFunds still works, because it never calls the fold. The wallet is a one-way valve: money goes in, nothing comes out, and the balance is unreadable. Getting there through the public API takes two requests.
To be fair about reachability: POST /transfers/incoming — the endpoint that maps directly to AddFunds — carries [Authorize("transfers")], so it is not an anonymous path. But the deposit route is not the only way in. A completed deposit is turned into a credit by DepositCompletedHandler, and the webhook that completes a deposit (PUT /deposits/{depositId}/complete) carries no [Authorize] attribute at all, annotated // Acting as a webhook for 3rd party payments service. Two deposits of 600,000 land in the same place.
Why a unit test would not have caught it
WalletTests has two facts. One adds 1,000 and asserts the transfer's properties; the other adds 1,000, deducts 200, and asserts the balance is 800. Both are correct, both pass, and neither goes near the edge — because the edge is not in Wallet, it is in a value object one assembly away that neither test mentions.
This is the shape of the defect worth taking away: the bug is not in either class, it is in the sentence the two classes form together. Amount says “no single value above a million”. Wallet says "the balance is an Amount". Both statements are reasonable. Their conjunction says “no wallet may ever hold more than a million”, which nobody wrote down, and which is enforced not by a rejection at the point of overflow but by a permanent, silent disabling of the aggregate afterwards.
The estate does not acknowledge it anywhere. There is no comment, no TODO, no exception type named for it. And the same conversion pattern appears elsewhere: TransferConfiguration maps the column with .HasConversion(x => x.Value, x => new Amount(x)), so the domain constructor also runs on every row read back from PostgreSQL. A row written under a more permissive cap becomes unreadable rather than merely invalid the day the cap tightens.
How you would find out
Consider what this looks like from an operations desk, because the shape of the alert is part of the lesson.
The customer's wallet returns HTTP 400 with the code invalid_amount. A 400 is, by every default dashboard convention, a client error — it lands in the bucket you deliberately do not page on, next to malformed request bodies and missing query parameters. Nothing about the response says the aggregate is unreadable; the message quotes a number the client never sent, so even a support engineer reading the raw response has to know the internals to interpret it. There is no 500, no unhandled-exception log, no failed health check.
And the withdrawal path makes it stranger still. WithdrawalStartedHandler in Wallets wraps its deduction in an unfiltered catch (Exception) and publishes DeductFundsRejected on any failure — so an over-cap wallet does not surface as an error at all. Payments receives a rejection, marks the withdrawal Rejected, and tells the customer their withdrawal was declined. A type-system landmine is reported to the customer as a business decision. That handler gets its own treatment in part 13; the point here is that the estate has a path which converts this defect into a plausible-sounding refusal.
The same ceiling sits behind Amount's arithmetic operators too — operator + and operator - both return Amount, so any expression that combines two amounts runs the constructor on the result. Nothing in either module uses them today. They are the same trap with a different trigger.
Three ways out, in ascending honesty
- Cap the accumulator where it accumulates.
if (CurrentAmount() + amount > Ceiling) throw new WalletLimitExceededException(Id)insideAddFunds. This is the smallest change and the most misleading, because it makes the ceiling a business rule by accident. - Split the type. A
TransferAmountwith the range check for movements, and a plainBalancewith only a non-negativity rule for totals. Two invariants, two types, no conversion operator smuggling one into the other. This is what the model actually means. - Drop the implicit conversions. They are what let a
decimalbecome a validatedAmountat a place nobody chose.Amount.From(value)at explicit call sites would have made the constructor's arrival at the end ofCurrentAmount()visible in the source.
My preference is (2) plus (3), and my rule of thumb after finding this one: a value object with a range check has an implicit scope, and an implicit conversion operator is how that scope escapes. If a type can be produced by arithmetic on other instances of itself, its constructor is validating something other than what its author had in mind.
There is a second, subtler consequence of the same guard, worth a sentence before we move on: CurrentAmount() cannot return a negative number either — it throws. Today that is unreachable, because DeductFunds refuses to overdraw. It is only unreachable as long as that one guard holds.
Next, the method that violates the aggregate rule on purpose and gets away with it: two aggregates, one method.