No Invented Data, No Lost Data: Building a Money Ledger in .NET
The Fintech Engineering Handbook hit the Hacker News front page with three rules for systems that touch money. Here is how those rules become concrete C#: a Money value type, integer minor units, a double-entry append-only ledger in EF Core, and balances you can actually trust.
Jean-Pierre Broeders
Freelance .NET Developer
No Invented Data, No Lost Data: Building a Money Ledger in .NET
The Fintech Engineering Handbook by Voytek Pitula made the Hacker News front page this week (HN discussion, 623 points, 215 comments). What I like about it is that it refuses to be a list of frameworks. It is built on three principles: no invented data, no lost data, no trust. Everything else — double-entry ledgers, idempotency, the outbox pattern, reconciliation — is downstream of those three sentences.
I have built and inherited money systems on .NET stacks, and the worst incidents I have seen never came from a clever distributed-systems problem. They came from a double somewhere, a balance column that was updated in place, and a "correction" UPDATE run by hand at 23:00. So let me take the handbook's principles and pin them to runnable C#, because the gap between "we store money" and "we store money correctly" is mostly a handful of design decisions you make on day one.
Principle one: do not let the runtime invent data
The first way you invent data is by representing money as a binary floating-point number. float and double cannot represent 0.10 exactly, so the moment you add a few of them you are off by a fraction of a cent, and that fraction compounds.
double total = 0.1 + 0.2;
Console.WriteLine(total); // 0.30000000000000004
Console.WriteLine(total == 0.3); // False
In .NET you have a good native escape hatch: decimal is a 128-bit base-10 type, so 0.1m + 0.2m is exactly 0.3m. That is genuinely better and fine for many line-of-business apps. But for a ledger I still prefer integer minor units — store amounts as a count of the smallest indivisible unit of the currency (cents for EUR, yen for JPY). The reasons are practical:
- Integers have no rounding behaviour at all during storage and transport. There is nothing to get wrong.
- Payment rails, card networks and most provider APIs already speak minor units. Matching their representation removes a conversion layer where bugs hide.
- Different currencies have different exponents — EUR and USD have 2 decimal places, JPY has 0, BHD has 3. An amount on its own is meaningless until you know its currency and its exponent.
That last point is the second way you invent data: an amount without a currency. A bare 2500 is not money; it could be €25.00 or ¥2500. So money should be a single value that always carries both. In modern C# a readonly record struct is the right tool — small, immutable, value-equal:
public readonly record struct Money
{
public long MinorUnits { get; }
public string Currency { get; } // ISO 4217, e.g. "EUR"
public Money(long minorUnits, string currency)
{
MinorUnits = minorUnits;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
public static Money operator +(Money a, Money b)
{
EnsureSameCurrency(a, b);
return new Money(checked(a.MinorUnits + b.MinorUnits), a.Currency);
}
public static Money operator -(Money a, Money b)
{
EnsureSameCurrency(a, b);
return new Money(checked(a.MinorUnits - b.MinorUnits), a.Currency);
}
private static void EnsureSameCurrency(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException(
$"Cannot combine {a.Currency} and {b.Currency}.");
}
}
Two details earn their keep here. The checked arithmetic turns a silent overflow into an exception — you want a long transfer to throw rather than wrap around into a negative balance. And EnsureSameCurrency makes "adding euros to yen" a compile-of-the-mind error: it is impossible to do by accident. Add euros to dollars in a decimal-based system and you get a number; here you get an exception with the two currencies named.
Rounding and splitting: where the cents disappear
The most common place real money goes missing is splitting an amount. Split €100.00 across three people naively and you get three times €33.33 = €99.99. One cent has evaporated, and over millions of transactions an auditor will find it. The fix is the largest-remainder method: divide, then distribute the leftover units one at a time.
public Money[] Split(int parts)
{
if (parts <= 0) throw new ArgumentOutOfRangeException(nameof(parts));
long baseAmount = MinorUnits / parts;
long remainder = MinorUnits - baseAmount * parts; // 0 <= remainder < parts
var result = new Money[parts];
for (int i = 0; i < parts; i++)
result[i] = new Money(baseAmount + (i < remainder ? 1 : 0), Currency);
return result;
}
Now €100.00 splits into 33.34, 33.33, 33.33 and the parts sum back to exactly the original. The same principle applies to percentage allocations and tax: compute on integers, distribute the remainder deterministically, and never let two independent Math.Round calls each nudge the value and disagree. When you genuinely must round a decimal, be explicit about the mode — MidpointRounding.ToEven (banker's rounding) is the financial default for a reason, because always rounding halves up introduces a systematic upward bias.
decimal vat = Math.Round(net * 0.21m, 2, MidpointRounding.ToEven);
Principle two: do not lose data — the append-only double-entry ledger
Here is the single most important architectural decision in the handbook, and the one I most often see skipped: do not store a balance as a mutable column. A Balance field that you UPDATE is a number with no memory. When it is wrong, you cannot tell why it is wrong, because the history that produced it was overwritten on every transaction.
The alternative is double-entry accounting, which is really event sourcing applied to money. You never store a balance — you store movements, and the balance is a function of the movements. Every transaction is a set of entries that must sum to zero per currency, so money is always conserved: it moves between accounts, it is never created or destroyed inside the system.
public sealed class LedgerEntry
{
public long Id { get; init; }
public Guid TransactionId { get; init; } // groups the entries of one transaction
public string Account { get; init; } = ""; // e.g. "user:42:available"
public long Amount { get; init; } // signed minor units
public string Currency { get; init; } = "";
public DateTime PostedAtUtc { get; init; }
public string Reason { get; init; } = ""; // human-readable audit context
}
A transfer of €25.00 from Alice to Bob is two rows sharing one TransactionId:
public async Task PostTransfer(string from, string to, Money amount, string reason)
{
var txId = Guid.NewGuid();
var now = DateTime.UtcNow;
var debit = new LedgerEntry { TransactionId = txId, Account = from,
Amount = -amount.MinorUnits, Currency = amount.Currency,
PostedAtUtc = now, Reason = reason };
var credit = new LedgerEntry { TransactionId = txId, Account = to,
Amount = +amount.MinorUnits, Currency = amount.Currency,
PostedAtUtc = now, Reason = reason };
// The invariant that makes it double-entry: entries net to zero.
if (debit.Amount + credit.Amount != 0)
throw new InvalidOperationException("Transaction does not balance.");
_db.LedgerEntries.AddRange(debit, credit);
await _db.SaveChangesAsync();
}
Because the rows are append-only, the balance is just a query:
public Task<long> BalanceMinorUnits(string account, string currency) =>
_db.LedgerEntries
.Where(e => e.Account == account && e.Currency == currency)
.SumAsync(e => e.Amount);
The trail is now the primary artifact. The balance can never silently drift away from it, because the balance is the sum of the trail. When someone asks "why does this account show €410.55?", the answer is a list of entries with reasons and timestamps, not a shrug.
Enforce append-only in EF Core
Append-only is a property you have to defend, because EF Core will happily let a future colleague mutate a tracked entity. The cheapest durable guard is to reject updates and deletes of ledger entries in SaveChanges itself:
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries<LedgerEntry>())
{
if (entry.State is EntityState.Modified or EntityState.Deleted)
throw new InvalidOperationException(
"Ledger entries are immutable; post a reversing entry instead.");
}
return await base.SaveChangesAsync(ct);
}
Pair it with a database-level guard for the things EF cannot see — REVOKE UPDATE, DELETE on the table for the application role, or a trigger. Defence in depth matters here precisely because the consequences are financial and the temptation to "just fix one row" is strongest under pressure.
So how do you correct a mistake? You do not edit history; you append to it. A wrong transfer is undone with a reversing transaction that posts the opposite entries with a reference back to the original. The original stays visible, the correction is auditable, and the net balance is right. That is the entire game: corrections are events, not edits.
Balances at scale: snapshots with optimistic concurrency
Summing every entry forever does not scale, and "no invented data" does not forbid caching — it forbids caching that can lie. The standard move is a materialized balance row protected by a concurrency token, recomputed atomically when you post:
public sealed class AccountBalance
{
public string Account { get; init; } = "";
public string Currency { get; init; } = "";
public long Balance { get; set; }
[Timestamp] public byte[] RowVersion { get; set; } = default!;
}
You update the snapshot in the same transaction as the entries, and let the [Timestamp] row version turn a concurrent double-post into a DbUpdateConcurrencyException you retry rather than a lost update. The snapshot is an optimization; the ledger remains the source of truth, and you can rebuild every snapshot from the entries at any time. If the two ever disagree, the ledger wins — and that disagreement is exactly what your reconciliation job should alarm on.
Principle three: trust nothing across a boundary
The third principle bites the moment money leaves your process. The network can fail after a provider charged a card but before you recorded it; a webhook can arrive twice or out of order; a retry can replay a request. I have written at length about building a real idempotency layer in .NET so I will not repeat it, but the ledger shape makes two of the defences natural:
- Idempotent posting. Put a unique constraint on
TransactionId(and on any external provider reference). A replayed instruction then collides at the database instead of double-charging — the constraint, not your code, is the source of truth about "already done". - Outbox, not fire-and-forget. When a ledger movement must trigger a side effect (an email, a downstream call), write the intent into an outbox table inside the same transaction as the entries, and let a separate worker deliver it. The side effect becomes durable before it is triggered, which is the only ordering that survives a crash.
And then reconciliation: periodically pull the provider's record of truth and compare it, entry by entry, to your ledger. Because your ledger is append-only and balanced, the comparison is mechanical, and the discrepancies it surfaces are real signals rather than noise.
The takeaway
The Fintech Engineering Handbook is worth reading in full, but its three rules survive translation into .NET cleanly. No invented data becomes a Money value type over integer minor units that always carries its currency, with checked arithmetic and remainder-aware splitting. No lost data becomes an append-only, double-entry ledger where the balance is a sum, immutability is enforced in SaveChanges and at the database, and corrections are reversing entries rather than edits. No trust becomes unique constraints, the outbox pattern and reconciliation at every boundary. None of this is exotic — it is mostly the discipline to make money a first-class type and the ledger the only source of truth. Do that on day one and the worst incidents in this domain simply stop being possible.
Source: "Fintech Engineering Handbook" by Voytek Pitula — Hacker News discussion. Code examples are my own.
