Learnixo
Back to blog
System Designadvanced

System Design: Core Banking Platform in .NET — Double-Entry, Event Sourcing, and Regulatory Compliance

Design a production banking system in .NET: double-entry accounting, ACID transaction guarantees, event sourcing for audit trails, optimistic concurrency for account balances, idempotent transfers, and regulatory reporting.

Asma Hafeez KhanMay 26, 202618 min read
C#.NETBankingEvent SourcingACIDDouble-EntrySystem DesignCase StudyConcurrency
Share:𝕏

System Design: Core Banking Platform in .NET — Double-Entry, Event Sourcing, and Regulatory Compliance

System: Core retail banking — current accounts, savings, and internal ledger
Stack: ASP.NET Core 9, EF Core 9, PostgreSQL 16, MassTransit, Azure Service Bus
Architecture: CQRS with event sourcing on the write side, denormalized projections on the read side
Regulatory context: PSD2 (EU), AML audit requirements, SOX-aligned audit trail

This case study walks through the real decisions made when re-platforming a legacy monolith banking core onto a modern .NET 9 stack. Every choice in this domain carries legal weight: a wrong balance is not a UX bug, it is a regulatory incident. The architecture must be correct by construction, not by convention.


System Overview

A core banking platform has two hard invariants that override every other engineering preference:

  1. Money is never created or destroyed. Every credit must have a matching debit somewhere in the system.
  2. Every financial event must be permanently recorded. No row in the ledger is ever updated or deleted.

These two constraints push you toward double-entry bookkeeping and append-only event sourcing before you write a single line of code. Everything else — balance caching, CQRS split, regulatory read models — follows from these two rules.

High-level write path:
  POST /transfers
    → IdempotencyMiddleware (check + lock idempotency key)
    → TransferCommandHandler
    → TransferSaga (orchestrate debit + credit + notify)
    → LedgerRepository (append LedgerEntry rows, NEVER update)
    → DomainEventPublisher (publish to Azure Service Bus)

High-level read path:
  GET /accounts/{id}/balance
    → AccountProjectionRepository (read from denormalized projections table)
    → Returns cached balance updated by projection worker

Regulatory path:
  AML Worker subscribes to LedgerEntryCreated events
  SOX Audit Worker appends to immutable audit store (Azure Immutable Blob)

Data Model

The Double-Entry Ledger

The core insight of double-entry bookkeeping is that every transaction touches at least two accounts. A transfer of £500 from Alice to Bob creates exactly two ledger entries: a debit on Alice's account and a credit on Bob's account. The sum of all ledger entries across all accounts in the system always equals zero.

C#
// LedgerEntry.cs — append-only, never mutated after creation
public sealed class LedgerEntry
{
    public Guid Id { get; private set; }
    public Guid TransactionId { get; private set; }   // groups the debit+credit pair
    public Guid AccountId { get; private set; }
    public EntryType EntryType { get; private set; }  // Debit | Credit
    public decimal Amount { get; private set; }        // always positive
    public string Currency { get; private set; }
    public string Narrative { get; private set; }
    public DateTimeOffset CreatedAt { get; private set; }
    public Guid CreatedByUserId { get; private set; }
    public Guid IdempotencyKey { get; private set; }

    // EF Core requires a parameterless constructor, kept private
    private LedgerEntry() { }

    public static LedgerEntry CreateDebit(
        Guid transactionId,
        Guid accountId,
        decimal amount,
        string currency,
        string narrative,
        Guid createdByUserId,
        Guid idempotencyKey)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        return new LedgerEntry
        {
            Id = Guid.NewGuid(),
            TransactionId = transactionId,
            AccountId = accountId,
            EntryType = EntryType.Debit,
            Amount = amount,
            Currency = currency,
            Narrative = narrative,
            CreatedAt = DateTimeOffset.UtcNow,
            CreatedByUserId = createdByUserId,
            IdempotencyKey = idempotencyKey
        };
    }

    public static LedgerEntry CreateCredit(/* same parameters */)
    { /* mirror of CreateDebit */ }
}

public enum EntryType { Debit, Credit }

Account Aggregate

The account holds metadata and the optimistic concurrency version stamp. Balance is a projection — it is NOT stored here as a source of truth.

C#
// Account.cs
public sealed class Account
{
    public Guid Id { get; private set; }
    public string AccountNumber { get; private set; }
    public Guid OwnerId { get; private set; }
    public AccountStatus Status { get; private set; }
    public string Currency { get; private set; }
    public decimal OverdraftLimit { get; private set; }

    // Optimistic concurrency: EF Core uses this as the row version token
    public uint Version { get; private set; }

    // Denormalized balance kept in sync by projection worker
    // Used for overdraft checks on the write path (with row lock)
    public decimal CurrentBalance { get; private set; }

    private readonly List<DomainEvent> _domainEvents = new();
    public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents;

    private Account() { }

    public static Account Open(Guid ownerId, string currency, decimal overdraftLimit = 0m)
    {
        var account = new Account
        {
            Id = Guid.NewGuid(),
            AccountNumber = GenerateAccountNumber(),
            OwnerId = ownerId,
            Status = AccountStatus.Active,
            Currency = currency,
            OverdraftLimit = overdraftLimit,
            CurrentBalance = 0m,
            Version = 0
        };
        account._domainEvents.Add(new AccountOpenedEvent(account.Id, ownerId, currency));
        return account;
    }

    // Called by the projection worker after appending ledger entries.
    // This is NOT called on the write path before ledger entries are committed.
    public void ApplyBalance(decimal newBalance)
    {
        CurrentBalance = newBalance;
    }

    public bool CanDebit(decimal amount) =>
        Status == AccountStatus.Active &&
        (CurrentBalance - amount) >= -OverdraftLimit;

    public void ClearDomainEvents() => _domainEvents.Clear();

    private static string GenerateAccountNumber() =>
        $"GB{Random.Shared.NextInt64(10_000_000_000_000L, 99_999_999_999_999L)}";
}

EF Core Configuration

C#
// LedgerEntryConfiguration.cs
public sealed class LedgerEntryConfiguration : IEntityTypeConfiguration<LedgerEntry>
{
    public void Configure(EntityTypeBuilder<LedgerEntry> builder)
    {
        builder.ToTable("ledger_entries");
        builder.HasKey(e => e.Id);

        builder.Property(e => e.Amount)
            .HasPrecision(18, 4)
            .IsRequired();

        builder.Property(e => e.Currency)
            .HasMaxLength(3)
            .IsFixedLength()
            .IsRequired();

        builder.Property(e => e.EntryType)
            .HasConversion<string>()
            .HasMaxLength(6)
            .IsRequired();

        // Composite index for balance calculation queries
        builder.HasIndex(e => new { e.AccountId, e.CreatedAt });

        // Idempotency key must be unique per account
        builder.HasIndex(e => e.IdempotencyKey).IsUnique();

        // Prevent accidental updates via EF change tracker
        // All navigation properties are excluded from update tracking
    }
}

// AccountConfiguration.cs
public sealed class AccountConfiguration : IEntityTypeConfiguration<Account>
{
    public void Configure(EntityTypeBuilder<Account> builder)
    {
        builder.ToTable("accounts");
        builder.HasKey(a => a.Id);

        // EF Core 9 row version for optimistic concurrency
        builder.Property(a => a.Version)
            .IsRowVersion()
            .IsConcurrencyToken();

        builder.Property(a => a.CurrentBalance)
            .HasPrecision(18, 4);

        builder.Property(a => a.OverdraftLimit)
            .HasPrecision(18, 4);
    }
}

Key Design Decisions

Decision 1: Balance-by-Ledger-Sum vs Stored Balance

The first decision every banking engineer faces: should the account balance be the sum of all ledger entries, or a stored field?

Balance-by-sum is the purest double-entry approach. Query:

SQL
SELECT
    SUM(CASE WHEN entry_type = 'Credit' THEN amount ELSE -amount END)
FROM ledger_entries
WHERE account_id = @accountId

This is always correct, but it is O(n) in ledger rows. For an account with 10 years of daily transactions, that is over 3,650 rows per query.

Stored balance is fast — a single column read — but it creates a second source of truth that can drift from the ledger if a bug or hardware failure causes a partial write.

What we chose: A hybrid. The stored balance is a denormalized projection updated by a background worker after every committed ledger event. The write path does NOT trust the stored balance for correctness — it acquires a row-level lock and re-computes the balance from the ledger for overdraft checks on high-value transfers. For low-value transfers below a configurable threshold, we trust the projection within a 500ms staleness window.

This is a deliberate trade-off: we accept that in a disaster recovery scenario, re-running all ledger events rebuilds the stored balance exactly, because the ledger is the single source of truth.

Decision 2: Idempotency Key Scope

Idempotency keys must be scoped correctly. A naive implementation scopes the key to the entire transfer request. But a transfer that partially succeeds — debit committed, credit message lost — must be resumable without re-debiting.

We scope idempotency keys to individual saga steps, not to the saga as a whole. Each step (debit, credit, notification) gets its own derived key computed as HMAC-SHA256(sagaId + stepName). This lets the saga retry individual steps safely without risk of double-debit.

C#
// IdempotencyKeyDeriver.cs
public static class IdempotencyKeyDeriver
{
    public static Guid DeriveStepKey(Guid sagaId, string stepName)
    {
        var input = System.Text.Encoding.UTF8.GetBytes($"{sagaId}:{stepName}");
        var hash = System.Security.Cryptography.SHA256.HashData(input);
        // Take the first 16 bytes and construct a deterministic Guid
        return new Guid(hash[..16]);
    }
}

Decision 3: Why Eventual Consistency Is Illegal for Balances

A common distributed systems instinct is to relax consistency for availability. For banking balances, this is legally and financially prohibited.

If account balance reads are eventually consistent, a user can submit two simultaneous withdrawals that individually pass the overdraft check but together would violate it. The window between the read and the write — even a 10ms window — is sufficient for a double-spend attack in a high-throughput system.

The solution is pessimistic locking (SELECT FOR UPDATE in PostgreSQL) on the account row during the debit phase, within the same database transaction that appends the ledger entries. This serializes concurrent debits on the same account at the database level.

C#
// AccountRepository.cs
public async Task<Account> LockForDebitAsync(Guid accountId, CancellationToken ct)
{
    // EF Core 9 raw SQL for SELECT FOR UPDATE (no EF abstraction exists)
    return await _context.Accounts
        .FromSqlRaw(
            "SELECT * FROM accounts WHERE id = {0} FOR UPDATE NOWAIT",
            accountId)
        .SingleAsync(ct);
}

NOWAIT means the query fails immediately if another transaction holds the lock, rather than queuing. We catch the resulting exception and return HTTP 409 Conflict to the caller, who retries with exponential backoff. This is preferable to a long lock queue that causes timeout cascades under load.


Challenges and Solutions

Challenge 1: The Saga Failure Modes

A transfer saga has three steps: debit source, credit destination, notify. What happens when step 2 fails after step 1 has committed?

Option A: Compensating transaction. Roll back the debit by creating a credit entry on the source account. This is the textbook answer, but it creates confusion in audit trails — a debit followed immediately by a compensating credit for the same amount looks like fraud to AML systems.

Option B: Forward recovery. Persist saga state to a database table. If the saga fails, a background worker resumes it from the last successful step. The saga never rolls back a committed ledger entry. Instead, it retries forward until the credit is posted.

We chose forward recovery. Regulatory auditors expect to see every debit entry matched by a credit, even if there was a delay. A compensating transaction creates an entry that does not correspond to any real-world money movement.

The saga state table is the key:

C#
// TransferSagaState.cs — persisted to PostgreSQL
public sealed class TransferSagaState
{
    public Guid SagaId { get; set; }
    public Guid SourceAccountId { get; set; }
    public Guid DestinationAccountId { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; }
    public SagaStep CurrentStep { get; set; }
    public SagaStatus Status { get; set; }
    public Guid IdempotencyKey { get; set; }
    public int RetryCount { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset? CompletedAt { get; set; }
    public string? FailureReason { get; set; }

    // Derived step keys for idempotent retries
    public Guid DebitStepKey => IdempotencyKeyDeriver.DeriveStepKey(SagaId, "debit");
    public Guid CreditStepKey => IdempotencyKeyDeriver.DeriveStepKey(SagaId, "credit");
    public Guid NotifyStepKey => IdempotencyKeyDeriver.DeriveStepKey(SagaId, "notify");
}

public enum SagaStep { Debit, Credit, Notify, Complete }
public enum SagaStatus { InProgress, Completed, Failed }

Challenge 2: Regulatory Read Models Without Coupling

AML (Anti-Money Laundering) and SOX reporting require read models that look very different from the operational model. AML needs to query by counterparty, geographic region, and transaction pattern. SOX needs an immutable audit log with cryptographic signatures.

We solved this by making the event stream the integration boundary. The write side emits LedgerEntryCreated domain events. Separate workers, running in separate processes, consume these events and maintain their own denormalized stores.

The critical property: these workers can be shut down and rebuilt from the beginning of the event log. The operational database is never queried for reporting. This means a reporting query that scans 10 million rows does not hold locks on the operational schema.

C#
// LedgerEntryCreatedEvent.cs — the integration event (not EF entity)
public sealed record LedgerEntryCreatedEvent
{
    public Guid EventId { get; init; } = Guid.NewGuid();
    public Guid LedgerEntryId { get; init; }
    public Guid TransactionId { get; init; }
    public Guid AccountId { get; init; }
    public EntryType EntryType { get; init; }
    public decimal Amount { get; init; }
    public string Currency { get; init; }
    public string Narrative { get; init; }
    public DateTimeOffset OccurredAt { get; init; }
    public Guid IdempotencyKey { get; init; }
}

Challenge 3: Idempotency Middleware Under High Load

The idempotency middleware must check-and-set atomically. A naive implementation reads the key, finds it absent, then writes it — but two concurrent requests with the same key can both pass the read check before either writes.

We use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING combined with RETURNING id to make the check-and-set atomic. If the insert returns no rows, the key already existed, and we return the cached response.

C#
// IdempotencyMiddleware.cs
public sealed class IdempotencyMiddleware(
    IIdempotencyStore store,
    ILogger<IdempotencyMiddleware> logger) : IMiddleware
{
    private const string IdempotencyKeyHeader = "Idempotency-Key";

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        if (!context.Request.Headers.TryGetValue(IdempotencyKeyHeader, out var keyValue))
        {
            await next(context);
            return;
        }

        if (!Guid.TryParse(keyValue, out var idempotencyKey))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new { error = "Invalid Idempotency-Key format" });
            return;
        }

        var cached = await store.GetAsync(idempotencyKey, context.RequestAborted);
        if (cached is not null)
        {
            logger.LogInformation("Returning cached response for idempotency key {Key}", idempotencyKey);
            context.Response.StatusCode = cached.StatusCode;
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsync(cached.Body, context.RequestAborted);
            return;
        }

        // Buffer the response so we can cache it
        var originalBody = context.Response.Body;
        using var buffer = new MemoryStream();
        context.Response.Body = buffer;

        await next(context);

        buffer.Seek(0, SeekOrigin.Begin);
        var responseBody = await new StreamReader(buffer).ReadToEndAsync(context.RequestAborted);

        // Only cache successful responses
        if (context.Response.StatusCode is >= 200 and < 300)
        {
            await store.SetAsync(idempotencyKey, new IdempotencyRecord(
                context.Response.StatusCode,
                responseBody,
                DateTimeOffset.UtcNow.AddHours(24)),
                context.RequestAborted);
        }

        buffer.Seek(0, SeekOrigin.Begin);
        await buffer.CopyToAsync(originalBody, context.RequestAborted);
        context.Response.Body = originalBody;
    }
}

.NET Implementation

Transfer Command Handler

C#
// TransferCommandHandler.cs
public sealed class TransferCommandHandler(
    IAccountRepository accountRepo,
    ILedgerRepository ledgerRepo,
    ISagaRepository sagaRepo,
    IDomainEventPublisher eventPublisher,
    BankingDbContext dbContext)
{
    public async Task<TransferResult> HandleAsync(
        TransferCommand command,
        CancellationToken ct)
    {
        // 1. Load and lock source account — SELECT FOR UPDATE NOWAIT
        Account source;
        try
        {
            source = await accountRepo.LockForDebitAsync(command.SourceAccountId, ct);
        }
        catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.LockNotAvailable)
        {
            return TransferResult.Conflict("Account is locked by a concurrent transfer. Retry.");
        }

        // 2. Business rule: can the account cover this debit?
        if (!source.CanDebit(command.Amount))
        {
            return TransferResult.InsufficientFunds();
        }

        // 3. Create saga state (persisted before any ledger entries)
        var saga = new TransferSagaState
        {
            SagaId = Guid.NewGuid(),
            SourceAccountId = command.SourceAccountId,
            DestinationAccountId = command.DestinationAccountId,
            Amount = command.Amount,
            Currency = command.Currency,
            CurrentStep = SagaStep.Debit,
            Status = SagaStatus.InProgress,
            IdempotencyKey = command.IdempotencyKey,
            RetryCount = 0,
            CreatedAt = DateTimeOffset.UtcNow
        };

        // 4. Build the debit and credit entries
        var debitEntry = LedgerEntry.CreateDebit(
            transactionId: saga.SagaId,
            accountId: command.SourceAccountId,
            amount: command.Amount,
            currency: command.Currency,
            narrative: $"Transfer to {command.DestinationAccountId}",
            createdByUserId: command.InitiatorUserId,
            idempotencyKey: saga.DebitStepKey);

        var creditEntry = LedgerEntry.CreateCredit(
            transactionId: saga.SagaId,
            accountId: command.DestinationAccountId,
            amount: command.Amount,
            currency: command.Currency,
            narrative: $"Transfer from {command.SourceAccountId}",
            createdByUserId: command.InitiatorUserId,
            idempotencyKey: saga.CreditStepKey);

        // 5. Commit saga state + both ledger entries in one ACID transaction
        await using var tx = await dbContext.Database.BeginTransactionAsync(
            System.Data.IsolationLevel.ReadCommitted, ct);

        sagaRepo.Add(saga);
        ledgerRepo.Add(debitEntry);
        ledgerRepo.Add(creditEntry);

        try
        {
            await dbContext.SaveChangesAsync(ct);
            await tx.CommitAsync(ct);
        }
        catch (DbUpdateConcurrencyException)
        {
            await tx.RollbackAsync(ct);
            return TransferResult.Conflict("Concurrent modification detected. Retry.");
        }

        // 6. Publish integration events (after commit — at-least-once delivery)
        await eventPublisher.PublishAsync(new LedgerEntryCreatedEvent
        {
            LedgerEntryId = debitEntry.Id,
            TransactionId = saga.SagaId,
            AccountId = debitEntry.AccountId,
            EntryType = debitEntry.EntryType,
            Amount = debitEntry.Amount,
            Currency = debitEntry.Currency,
            Narrative = debitEntry.Narrative,
            OccurredAt = debitEntry.CreatedAt,
            IdempotencyKey = debitEntry.IdempotencyKey
        }, ct);

        await eventPublisher.PublishAsync(new LedgerEntryCreatedEvent
        {
            LedgerEntryId = creditEntry.Id,
            TransactionId = saga.SagaId,
            AccountId = creditEntry.AccountId,
            EntryType = creditEntry.EntryType,
            Amount = creditEntry.Amount,
            Currency = creditEntry.Currency,
            Narrative = creditEntry.Narrative,
            OccurredAt = creditEntry.CreatedAt,
            IdempotencyKey = creditEntry.IdempotencyKey
        }, ct);

        return TransferResult.Success(saga.SagaId);
    }
}

Minimal API Endpoint

C#
// Program.cs / TransferEndpoints.cs
app.MapPost("/api/v1/transfers", async (
    [FromBody] TransferRequest request,
    [FromServices] TransferCommandHandler handler,
    [FromServices] IValidator<TransferRequest> validator,
    HttpContext httpContext,
    CancellationToken ct) =>
{
    var validation = await validator.ValidateAsync(request, ct);
    if (!validation.IsValid)
        return Results.ValidationProblem(validation.ToDictionary());

    // Idempotency key is extracted by middleware; also available here via context
    if (!httpContext.Items.TryGetValue("IdempotencyKey", out var keyObj)
        || keyObj is not Guid idempotencyKey)
    {
        return Results.BadRequest(new { error = "Idempotency-Key header required" });
    }

    var command = new TransferCommand(
        SourceAccountId: request.SourceAccountId,
        DestinationAccountId: request.DestinationAccountId,
        Amount: request.Amount,
        Currency: request.Currency,
        InitiatorUserId: httpContext.GetUserId(),
        IdempotencyKey: idempotencyKey);

    var result = await handler.HandleAsync(command, ct);

    return result.Status switch
    {
        TransferStatus.Success        => Results.Accepted($"/api/v1/transfers/{result.SagaId}", result),
        TransferStatus.InsufficientFunds => Results.UnprocessableEntity(new { error = result.Message }),
        TransferStatus.Conflict       => Results.Conflict(new { error = result.Message }),
        _                             => Results.Problem("Unexpected error")
    };
})
.WithName("CreateTransfer")
.WithOpenApi()
.RequireAuthorization("BankingUser");

Account Balance Projection Worker

C#
// BalanceProjectionWorker.cs
public sealed class BalanceProjectionWorker(
    IServiceScopeFactory scopeFactory,
    ILogger<BalanceProjectionWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var processor = scope.ServiceProvider
                    .GetRequiredService<ILedgerEventProcessor>();
                await processor.ProcessPendingEventsAsync(stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Balance projection worker error — will retry");
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            }

            await Task.Delay(TimeSpan.FromMilliseconds(200), stoppingToken);
        }
    }
}

// ILedgerEventProcessor implementation
public sealed class LedgerEventProcessor(BankingDbContext context) : ILedgerEventProcessor
{
    public async Task ProcessPendingEventsAsync(CancellationToken ct)
    {
        // Find ledger entries not yet reflected in account balance projection
        var unprojectedEntries = await context.LedgerEntries
            .Where(e => !context.BalanceProjectionCheckpoints
                .Any(c => c.LedgerEntryId == e.Id))
            .OrderBy(e => e.CreatedAt)
            .Take(500)
            .ToListAsync(ct);

        if (unprojectedEntries.Count == 0) return;

        var affectedAccountIds = unprojectedEntries
            .Select(e => e.AccountId)
            .Distinct()
            .ToList();

        foreach (var accountId in affectedAccountIds)
        {
            var balance = await context.LedgerEntries
                .Where(e => e.AccountId == accountId)
                .SumAsync(e => e.EntryType == EntryType.Credit
                    ? e.Amount
                    : -e.Amount, ct);

            var account = await context.Accounts.FindAsync([accountId], ct);
            if (account is not null)
                account.ApplyBalance(balance);
        }

        // Record checkpoints
        context.BalanceProjectionCheckpoints.AddRange(
            unprojectedEntries.Select(e => new BalanceProjectionCheckpoint
            {
                LedgerEntryId = e.Id,
                ProjectedAt = DateTimeOffset.UtcNow
            }));

        await context.SaveChangesAsync(ct);
    }
}

CQRS: Read Side for Regulatory Reporting

The read side is deliberately separate. Compliance queries hit a different PostgreSQL schema — reporting — populated by the projection worker. This schema is append-only by database policy (a PostgreSQL role with no UPDATE or DELETE privileges). Auditors connect directly to this schema with a read-only role.

C#
// AmlTransactionReportQuery.cs
public sealed record AmlReportQuery(
    DateTimeOffset From,
    DateTimeOffset To,
    decimal AmountThreshold,
    string? CountryCode);

public sealed class AmlTransactionReportHandler(ReportingDbContext context)
{
    public async Task<IReadOnlyList<AmlTransactionRecord>> HandleAsync(
        AmlReportQuery query,
        CancellationToken ct)
    {
        return await context.AmlTransactionView
            .Where(t =>
                t.OccurredAt >= query.From &&
                t.OccurredAt <= query.To &&
                t.Amount >= query.AmountThreshold &&
                (query.CountryCode == null || t.AccountCountryCode == query.CountryCode))
            .OrderByDescending(t => t.Amount)
            .Take(10_000)
            .Select(t => new AmlTransactionRecord(
                t.TransactionId,
                t.AccountId,
                t.Amount,
                t.Currency,
                t.Narrative,
                t.OccurredAt,
                t.AccountCountryCode))
            .ToListAsync(ct);
    }
}

What We'd Do Differently

1. Use an Outbox Pattern for Event Publishing

We initially published domain events after SaveChangesAsync but within the same method call. This creates a window: the database commits but the message broker is temporarily unavailable. We lost events.

The fix is an outbox: store the integration event in the same database transaction as the ledger entries. A separate relay process reads pending outbox entries and publishes them. This gives exactly-once writes and at-least-once delivery, which is the correct guarantee for financial events.

C#
// OutboxMessage.cs — committed in the same transaction as ledger entries
public sealed class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string EventType { get; set; }
    public string Payload { get; set; }       // JSON-serialized event
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? ProcessedAt { get; set; }
    public int RetryCount { get; set; }
}

2. Partition the Ledger Table by Month

After 18 months in production, the ledger_entries table had 800 million rows. Queries that compute balance-by-sum for old accounts were scanning old partitions unnecessarily. PostgreSQL declarative range partitioning on created_at per month, with older partitions moved to cold storage, would have been set up from day one.

3. Separate Overdraft Check from the Write Lock

The current design acquires a row lock then re-computes the balance from the ledger. For accounts with long histories, this balance recomputation adds latency to every transfer. A materialized balance with a version-stamped write-lock would be faster: lock the row, read CurrentBalance, check overdraft, append ledger entries, and update the balance in the same transaction — all without a full ledger scan.

4. Invest Earlier in Property-Based Testing

Financial logic is precisely the domain where property-based testing shines. The invariant "sum of all debits equals sum of all credits" can be expressed as a property and verified across thousands of generated transfer sequences. We added this 6 months in; it should have been present from the first sprint.

C#
// Example FsCheck property test (using FsCheck.NUnit)
[Property]
public Property DoubleEntryInvariant_SumIsAlwaysZero(
    NonEmptyArray<TransferInstruction> transfers)
{
    var ledger = SimulateLedger(transfers.Get);
    var netSum = ledger.Sum(e =>
        e.EntryType == EntryType.Credit ? e.Amount : -e.Amount);
    return (netSum == 0m).Label($"Net sum was {netSum}, expected 0");
}

The core banking domain is one of the few areas where "we'll add correctness later" is not an option. The architecture — double-entry ledger, saga-based forward recovery, pessimistic locking for overdraft checks, and event-sourced audit trail — is not over-engineering. It is the minimum viable correct design for a system where every bug has a financial and regulatory consequence.

Enjoyed this article?

Explore the System Design learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.