Geen verzonnen data, geen verloren data: een geld-ledger bouwen in .NET

De Fintech Engineering Handbook haalde de voorpagina van Hacker News met drie regels voor systemen die geld aanraken. Zo worden die regels concrete C#: een Money-value type, integer minor units, een append-only double-entry ledger in EF Core en saldi die je echt kunt vertrouwen.

Jean-Pierre Broeders

Freelance .NET Developer

29 juni 202612 min. leestijd
Geen verzonnen data, geen verloren data: een geld-ledger bouwen in .NET

Geen verzonnen data, geen verloren data: een geld-ledger bouwen in .NET

De Fintech Engineering Handbook van Voytek Pitula stond deze week op de voorpagina van Hacker News (HN-discussie, 623 punten, 215 reacties). Wat me eraan bevalt: het weigert een lijstje frameworks te zijn. Het rust op drie principes — geen verzonnen data, geen verloren data, geen vertrouwen. Al het andere (double-entry ledgers, idempotentie, het outbox-patroon, reconciliatie) volgt uit die drie zinnen.

Ik heb geldsystemen op .NET-stacks gebouwd én geërfd, en de ergste incidenten die ik heb gezien kwamen nooit uit een knap distributed-systems-probleem. Ze kwamen uit een double ergens, een saldokolom die in-place werd geüpdatet, en een handmatige "correctie"-UPDATE om 23:00 uur. Laat ik de principes uit het handboek daarom vastpinnen op draaibare C#, want het gat tussen "we slaan geld op" en "we slaan geld correct op" is vooral een handvol ontwerpkeuzes die je op dag één maakt.

Principe één: laat de runtime geen data verzinnen

De eerste manier waarop je data verzint, is geld als binair floating-point getal weergeven. float en double kunnen 0.10 niet exact voorstellen, dus zodra je er een paar optelt zit je een fractie van een cent ernaast — en die fractie stapelt op.

double total = 0.1 + 0.2;
Console.WriteLine(total);          // 0.30000000000000004
Console.WriteLine(total == 0.3);   // False

In .NET heb je een goede nooduitgang: decimal is een 128-bits type met basis 10, dus 0.1m + 0.2m is exact 0.3m. Dat is echt beter en prima voor veel line-of-business-apps. Maar voor een ledger geef ik nog steeds de voorkeur aan integer minor units — sla bedragen op als een aantal van de kleinste ondeelbare eenheid van de valuta (centen voor EUR, yen voor JPY). De redenen zijn praktisch:

  • Integers hebben geen enkel afrondgedrag tijdens opslag en transport. Er valt niets fout te doen.
  • Betaalrails, kaartnetwerken en de meeste provider-API's spreken al in minor units. Hun representatie overnemen schrapt een conversielaag waarin bugs zich verstoppen.
  • Verschillende valuta hebben verschillende exponenten — EUR en USD hebben 2 decimalen, JPY heeft er 0, BHD heeft er 3. Een bedrag op zichzelf is betekenisloos tot je de valuta én de exponent kent.

Dat laatste is meteen de tweede manier om data te verzinnen: een bedrag zonder valuta. Een kale 2500 is geen geld; het kan €25,00 of ¥2500 zijn. Geld hoort dus één waarde te zijn die beide altijd meedraagt. In moderne C# is een readonly record struct daarvoor het juiste gereedschap — klein, immutable, met waarde-gelijkheid:

public readonly record struct Money
{
    public long MinorUnits { get; }
    public string Currency { get; }   // ISO 4217, bijv. "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(
                $"Kan {a.Currency} en {b.Currency} niet combineren.");
    }
}

Twee details verdienen hier hun plek. De checked-rekenkunde verandert een stille overflow in een exceptie — je wilt dat een grote overboeking gooit in plaats van rond te lopen naar een negatief saldo. En EnsureSameCurrency maakt "euro's bij yen optellen" tot een denkfout die je niet per ongeluk kúnt maken: in een decimal-systeem krijg je gewoon een getal, hier krijg je een exceptie met beide valuta erin benoemd.

Afronden en splitsen: waar de centen verdwijnen

De vaakst voorkomende plek waar echt geld verdwijnt, is het splitsen van een bedrag. Splits €100,00 naïef over drie personen en je krijgt driemaal €33,33 = €99,99. Eén cent is verdampt, en over miljoenen transacties vindt een accountant die terug. De oplossing is de largest-remainder-methode: deel eerst, en verdeel de rest daarna eenheid voor eenheid.

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;
}

Nu splitst €100,00 in 33,34, 33,33, 33,33 en tellen de delen exact op tot het origineel. Hetzelfde principe geldt voor percentages en btw: reken op integers, verdeel de rest deterministisch, en laat nooit twee losse Math.Round-aanroepen elk de waarde een tikje verschuiven en het oneens worden. Als je echt een decimal moet afronden, wees expliciet over de modus — MidpointRounding.ToEven (bankiersafronding) is niet voor niets de financiële standaard, want halve waarden altijd omhoog afronden geeft een systematische opwaartse bias.

decimal btw = Math.Round(netto * 0.21m, 2, MidpointRounding.ToEven);

Principe twee: verlies geen data — de append-only double-entry ledger

Hier zit de belangrijkste architectuurkeuze uit het handboek, en degene die ik het vaakst overgeslagen zie: sla een saldo niet op als een muteerbare kolom. Een Saldo-veld dat je UPDATE't is een getal zonder geheugen. Als het fout is, kun je niet zien waarom, want de geschiedenis die het opleverde werd bij elke transactie overschreven.

Het alternatief is dubbel boekhouden, wat eigenlijk event sourcing toegepast op geld is. Je slaat nooit een saldo op — je slaat bewegingen op, en het saldo is een functie van die bewegingen. Elke transactie is een set boekingen die per valuta tot nul moet sommeren, zodat geld altijd behouden blijft: het verplaatst zich tussen rekeningen, het wordt binnen het systeem nooit gecreëerd of vernietigd.

public sealed class LedgerEntry
{
    public long Id { get; init; }
    public Guid TransactionId { get; init; }   // groepeert de boekingen van één transactie
    public string Account { get; init; } = "";  // bijv. "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; } = "";    // leesbare audit-context
}

Een overboeking van €25,00 van Alice naar Bob is twee rijen die één TransactionId delen:

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 };

    // De invariant die het double-entry maakt: boekingen sommeren tot nul.
    if (debit.Amount + credit.Amount != 0)
        throw new InvalidOperationException("Transactie is niet in balans.");

    _db.LedgerEntries.AddRange(debit, credit);
    await _db.SaveChangesAsync();
}

Omdat de rijen append-only zijn, is het saldo simpelweg een query:

public Task<long> BalanceMinorUnits(string account, string currency) =>
    _db.LedgerEntries
       .Where(e => e.Account == account && e.Currency == currency)
       .SumAsync(e => e.Amount);

Het spoor is nu het primaire artefact. Het saldo kan er nooit stilletjes van afdrijven, want het saldo is de som van het spoor. Als iemand vraagt "waarom staat hier €410,55?", is het antwoord een lijst boekingen met redenen en timestamps, geen schouderophalen.

Dwing append-only af in EF Core

Append-only is een eigenschap die je moet verdedigen, want EF Core laat een toekomstige collega vrolijk een getrackte entiteit muteren. De goedkoopste duurzame bewaking is updates en deletes van ledger-boekingen weigeren in SaveChanges zelf:

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-boekingen zijn immutable; boek in plaats daarvan een tegenboeking.");
    }
    return await base.SaveChangesAsync(ct);
}

Combineer het met een bewaking op databaseniveau voor wat EF niet ziet — REVOKE UPDATE, DELETE op de tabel voor de applicatierol, of een trigger. Defence in depth telt hier juist omdat de gevolgen financieel zijn en de verleiding om "even één rijtje te fixen" het sterkst is onder druk.

Hoe corrigeer je dan een fout? Je bewerkt de geschiedenis niet; je voegt eraan toe. Een foute overboeking draai je terug met een tegentransactie die de tegengestelde boekingen plaatst, met een verwijzing terug naar het origineel. Het origineel blijft zichtbaar, de correctie is auditeerbaar, en het nettosaldo klopt. Dat is het hele spel: correcties zijn gebeurtenissen, geen bewerkingen.

Saldi op schaal: snapshots met optimistic concurrency

Eeuwig elke boeking optellen schaalt niet, en "geen verzonnen data" verbiedt cachen niet — het verbiedt cachen dat kan liegen. De standaardzet is een gematerialiseerde saldorij beschermd door een concurrency-token, die je atomair herberekent bij het boeken:

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!;
}

Je werkt de snapshot bij in dezelfde transactie als de boekingen, en laat de [Timestamp]-rowversie een gelijktijdige dubbele boeking veranderen in een DbUpdateConcurrencyException die je herprobeert, in plaats van een lost update. De snapshot is een optimalisatie; de ledger blijft de bron van waarheid, en je kunt elke snapshot op elk moment uit de boekingen herbouwen. Als ze ooit van elkaar verschillen, wint de ledger — en juist dat verschil hoort je reconciliatie-job te alarmeren.

Principe drie: vertrouw niets over een grens heen

Het derde principe bijt op het moment dat geld je proces verlaat. Het netwerk kan falen nadat een provider een kaart heeft belast maar voordat jij het vastlegde; een webhook kan dubbel of out-of-order arriveren; een retry kan een verzoek opnieuw afspelen. Ik heb uitgebreid geschreven over een echte idempotentielaag bouwen in .NET, dus dat herhaal ik niet, maar de ledger-vorm maakt twee verdedigingen vanzelfsprekend:

  • Idempotent boeken. Zet een unieke constraint op TransactionId (en op elke externe provider-referentie). Een opnieuw afgespeelde instructie botst dan op de database in plaats van dubbel te belasten — de constraint, niet je code, is de bron van waarheid over "al gedaan".
  • Outbox, geen fire-and-forget. Als een ledger-beweging een neveneffect moet triggeren (een e-mail, een downstream-call), schrijf de intentie dan in een outbox-tabel binnen dezelfde transactie als de boekingen, en laat een aparte worker hem afleveren. Het neveneffect wordt duurzaam vóór het wordt getriggerd, en dat is de enige volgorde die een crash overleeft.

En dan reconciliatie: trek periodiek de waarheid van de provider op en vergelijk die, boeking voor boeking, met je ledger. Omdat je ledger append-only en in balans is, is de vergelijking mechanisch, en de verschillen die ze blootlegt zijn echte signalen in plaats van ruis.

De conclusie

De Fintech Engineering Handbook is het waard om volledig te lezen, maar de drie regels overleven de vertaling naar .NET moeiteloos. Geen verzonnen data wordt een Money-value type over integer minor units dat zijn valuta altijd meedraagt, met checked-rekenkunde en rest-bewust splitsen. Geen verloren data wordt een append-only, double-entry ledger waar het saldo een som is, immutability wordt afgedwongen in SaveChanges én op de database, en correcties tegenboekingen zijn in plaats van bewerkingen. Geen vertrouwen wordt unieke constraints, het outbox-patroon en reconciliatie op elke grens. Niets hiervan is exotisch — het is vooral de discipline om geld een first-class type te maken en de ledger de enige bron van waarheid. Doe dat op dag één en de ergste incidenten in dit domein zijn simpelweg niet meer mogelijk.

Bron: "Fintech Engineering Handbook" van Voytek Pitula — Hacker News-discussie. Codevoorbeelden zijn van mijzelf.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op