DEV Community

Cover image for How I Handle Concurrency-Safe Wallets, Deadlock-Free Transfers, and Idempotency in .NET + PostgreSQL
Harut Petrosyan
Harut Petrosyan

Posted on

How I Handle Concurrency-Safe Wallets, Deadlock-Free Transfers, and Idempotency in .NET + PostgreSQL

Whenever a project needs an internal wallet, SaaS credit system, or user balance, doing a naive wallet.Balance -= amount in EF Core is a recipe for race conditions and negative balances under load.

I put together a minimal reference architecture in .NET + EF Core + PostgreSQL to solve the four main headaches of balance management without introducing heavy distributed locks (like Redis Redlock).


1. Preventing Double-Spending (Pessimistic + Optimistic Locking)

Instead of relying solely on optimistic concurrency (which throws DbUpdateConcurrencyException and forces clients to retry under high contention), we lock the wallet row inside the transaction using PostgreSQL's FOR UPDATE:

var lockedWallets = await db.Wallets
    .FromSqlInterpolated($"SELECT *, xmin FROM \"Wallets\" WHERE \"Id\" = {walletId} FOR UPDATE")
    .ToListAsync(ct);
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Important EF Core + Npgsql Gotcha:
If you map PostgreSQL's xmin system column as a concurrency token, doing a standard SELECT * inside a subquery strips system columns, causing Npgsql to throw 42703: column w.xmin does not exist. Explicitly selecting SELECT *, xmin and materializing with ToListAsync keeps EF Core's change tracker happy while acquiring the row-level lock. We also back this up with a DB-level CHECK ("Balance" >= 0) constraint.


2. Preventing A -> B and B -> A Deadlocks

If User A transfers to User B at the exact same millisecond User B transfers to User A, locking FromWallet then ToWallet will deadlock the database.

The fix is deterministic lock orderingβ€”always sort the GUIDs in memory before executing the SELECT ... FOR UPDATE:

var firstId = req.FromWalletId.CompareTo(req.ToWalletId) < 0 ? req.FromWalletId : req.ToWalletId;
var secondId = firstId == req.FromWalletId ? req.ToWalletId : req.FromWalletId;

var lockedWallets = await db.Wallets
    .FromSqlInterpolated($"SELECT *, xmin FROM \"Wallets\" WHERE \"Id\" IN ({firstId}, {secondId}) ORDER BY \"Id\" FOR UPDATE")
    .ToListAsync(ct);
Enter fullscreen mode Exit fullscreen mode

3. Handling Network Retries (Idempotency-Key Endpoint Filter)

Mobile and frontend clients retry on timeouts. By implementing a lightweight IEndpointFilter, we hash the incoming request payload (SHA-256) alongside the Idempotency-Key header, insert an in-flight lock record in Postgres, and replay the exact status code and JSON body (X-Idempotent-Replayed: true) if the client repeats the call:

if (existing.StatusCode.HasValue && existing.ResponseBody is not null)
{
    httpContext.Response.Headers.Append("X-Idempotent-Replayed", "true");
    return Results.Content(existing.ResponseBody, "application/json", statusCode: existing.StatusCode.Value);
}
Enter fullscreen mode Exit fullscreen mode

4. The 50-Thread Concurrency Stress Test

To verify it, I built an endpoint (/api/wallets/stress-test) that seeds a wallet with $100 and fires Task.WhenAll with 50 parallel scopes simultaneously trying to withdraw $10 at the exact same millisecond.

The Result:

{
  "testSummary": "50 parallel threads tried to withdraw $10 simultaneously from a $100 wallet.",
  "expectedSuccesses": 10,
  "actualSuccesses": 10,
  "expectedFailures": 40,
  "actualFailures": 40,
  "finalWalletBalance": 0.0000,
  "totalLedgerEntries": 11,
  "passed": true
}
Enter fullscreen mode Exit fullscreen mode

Exactly 10 succeed, 40 fail cleanly, 11 immutable double-entry ledger records are written, and the balance lands on 0.0000.


πŸ“¦ Get the Full Source Code Micro-Kit

If you are building fintech features, SaaS credits, or internal wallets right now and want the full plug-and-play solution (with the WalletEngine, IdempotencyFilter, append-only LedgerEntry audit trail, stress-test endpoint, and Docker setup), I packaged it into a clean micro-kit here:

πŸ‘‰ Download the Bulletproof .NET Wallet & Idempotent Ledger Micro-Kit

Happy to answer any questions about PostgreSQL locking or EF Core concurrency gotchas in the comments!

Top comments (0)