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);
π‘ 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);
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);
}
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
}
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)