Money Without a Currency
Inflow models money as an Amount that wraps a bare decimal and a Currency that lives beside it on the entity. The two never travel together, nothing rounds, and the column is unconstrained numeric.
Money is the standard worked example for value objects, and it is the standard example because it is the one where the naive version fails visibly. decimal balance plus string currency on the same class looks harmless until somebody writes a.Balance + b.Balance for two accounts in different currencies, and the compiler — which is the only reviewer that reads every line — has nothing to say about it.
Part 7 compared two concurrency strategies in one estate. This part is about the type that carries every figure in it.
Two types that never meet
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 Amount Zero => new(0);
public static implicit operator Amount(decimal value) => new(value);
public static implicit operator decimal(Amount value) => value.Value;
public static Amount operator +(Amount a, Amount b) => a.Value + b.Value;
public static Amount operator -(Amount a, Amount b) => a.Value - b.Value;
// comparison operators, equality, ToString
}
(src\Shared\Inflow.Shared.Abstractions\Kernel\ValueObjects\Amount.cs, branch master.)
Currency is a separate class in the same folder: a string of exactly three characters, upper-cased, checked against a HashSet of { "PLN", "EUR", "GBP" }, with implicit conversions to and from string.
Neither type knows the other exists. Every entity that holds money holds both, side by side: Wallet has Currency, Transfer has Currency and Amount, Deposit has both, DepositAccount has Currency. The pairing is a convention held by six classes rather than a fact held by one.
So the arithmetic operators are unguarded by construction. Amount + Amount adds two decimals and asks no questions, because there is nothing on either operand to ask about. The canonical Money type from the pattern literature exists precisely to make new Money(10, "GBP") + new Money(10, "PLN") a runtime failure rather than a plausible 20; this Amount cannot express the objection.
The reason it does not blow up
Here is the fairness paragraph, and it is a substantial one, because the estate is saved by a structural decision rather than by luck.
A wallet is mono-currency, and the database enforces it.
builder.HasIndex(x => new { x.OwnerId, x.Currency }).IsUnique();
(WalletConfiguration; DepositAccountConfiguration and WithdrawalAccountConfiguration carry the same shape on (CustomerId, Currency).)
One wallet per owner per currency. And every transfer inherits the wallet's currency at creation — AddFunds and DeductFunds both construct the transfer with Currency, the wallet's own property, never with anything supplied by the caller. So the collection that CurrentAmount() folds over is homogeneous by construction, and the sum of a mono-currency ledger is a well-defined number even when the type cannot say so.
The application layer holds the line at the boundaries too. TransferFundsHandler refuses if the command's currency differs from either wallet. Six browse handlers validate a query-string currency with the same discarded-construction idiom, _ = new Currency(query.Currency);, which throws before the expression tree is built. And there is no currency conversion anywhere in the repository — no rate table, no rate provider, no ExchangeRate type — so the system cannot silently convert, because it has no notion of a rate to convert at. CurrencyResolver maps a nationality to a currency once, at account creation:
public Currency GetForNationality(Nationality nationality)
=> nationality.Value switch
{
"PL" => "PLN",
"DE" => "EUR",
"FR" => "EUR",
"ES" => "EUR",
"GB" => "GBP",
_ => "EUR"
};
A five-country world with a default, which for a teaching estate is an entirely reasonable simplification — though it is worth noticing that a KYC field captured in the Customers module decides the currency of a wallet in the Wallets module, through two switch statements in two modules with no shared reference data.
The invariant that keeps this model sound is “one currency per wallet”, it is enforced by a unique index, and it is not visible from Amount. That is the honest summary: the design is safe, and the safety is somewhere other than the type that would tell you about it.
What the column actually is
The type is one half of the story; the column is the other, and the column is more permissive than the type.
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
(20211229212750_Wallets_Init.cs; 20211229212535_Payments_Init.cs is identical on both.)
numeric with no precision and no scale. In PostgreSQL that is the arbitrary-precision variant — up to 131,072 digits before the decimal point and 16,383 after — so the storage will faithfully keep whatever the application hands it. And nothing in either module rounds. There is no Math.Round, no scale on the value object, no HasPrecision in any configuration. new Amount(0.00499m) is legal, persists exactly, and folds into balances forever.
Two consequences, in ascending order of how much they would annoy an accountant:
- Sub-cent balances are representable and unstated. Nothing ever produces one today, because every amount comes from a request body or from another module's event. Nothing prevents one either.
- The displayed balance is whatever
decimal.ToStringdoes.WalletDetailsDto.AmountandTransferDto.Amountare both baredecimal, serialised straight to JSON withCurrencyas a sibling string. The client is handed a number and a code and left to pair them.
Currency as text is the smaller sin but the more avoidable one: the value object already guarantees exactly three characters, and the column could have been character(3). Compare Name and Metadata on the same table, which correctly carry character varying(100) and (1000) from their HasMaxLength calls. The mapping strategy knows how to propagate a constraint; Currency just never had one declared.
There is a third consequence at the wire, and it is the one that reaches a browser. decimal serialised by System.Text.Json becomes a JSON number, and JavaScript reads every JSON number as an IEEE 754 double. A .NET decimal has 28–29 significant digits; a double has about 15. For the balances this system will ever hold that is harmless — but the reason it is harmless is the one-million cap from part 3, which is not a rule anyone wrote down about serialisation. The money type that survives contact with a browser is a string, or a minor-unit integer, and this one is neither.
One more edge, since the conversion operators keep producing them. Every other value object in the estate writes its string operator defensively — TransferName, TransferMetadata and FullName all read value is null ? null : new T(value). Currency does not:
public static implicit operator Currency(string value) => new(value);
public static implicit operator string(Currency value) => value.Value;
A null string becomes an InvalidCurrencyException on the way in, which is defensible. A null Currency becomes a NullReferenceException on the way out, which is not — and since the converter that reads rows from PostgreSQL is x => new Currency(x), a row written under a wider allow-list becomes unreadable rather than merely invalid the day the set {PLN, EUR, GBP} changes. Widening an allow-list is safe; narrowing one is a data migration nobody will remember to write.
What I would change, and what I would leave
Leave the range check's intent — a sanity cap on a movement is good. Leave the mono-currency wallet; it is the decision that makes everything else work.
Change three things. Put the currency inside the amount, so Money is one type, the operators can refuse a mismatch, and the six classes stop maintaining a convention. Declare a scale — numeric(19,4) in the column and a rounding rule in the constructor, chosen deliberately rather than inherited from decimal. And make the range check scope-aware, per part 3, so that a cap meant for one payment stops being applied to a running total.
The rule of thumb: if your money type has no unit, the unit is being enforced by something you are not looking at. In this estate it happens to be a unique index, and the index is doing an excellent job. That is a good outcome and a fragile one, because the next feature to arrive — a multi-currency wallet, a fee in a different currency, anything — removes the index and takes the invariant with it, and no type will complain.
That is the domain model done. The next four parts turn outward, to the two modules' architectures and what they actually buy. First, onion versus folder.