rust 115 lines · 3 tabs

Atomic Double-Entry Ledger Transfers With Rust Locking and Poison Recovery

Shared by codesnips Jul 2026
3 tabs
use std::collections::HashMap;
use std::sync::Mutex;

pub type AccountId = u64;
pub type Cents = i64;

#[derive(Debug, Clone)]
pub struct Account {
    pub id: AccountId,
    pub balance: Cents,
}

#[derive(Debug, PartialEq)]
pub enum LedgerError {
    UnknownAccount(AccountId),
    SameAccount,
    InsufficientFunds { account: AccountId, needed: Cents },
    Overflow,
    Poisoned,
}

pub struct Ledger {
    accounts: Mutex<HashMap<AccountId, Account>>,
}

impl Ledger {
    pub fn new() -> Self {
        Ledger { accounts: Mutex::new(HashMap::new()) }
    }

    pub fn open(&self, id: AccountId, opening: Cents) -> Result<(), LedgerError> {
        let mut guard = self.accounts.lock().map_err(|_| LedgerError::Poisoned)?;
        guard.insert(id, Account { id, balance: opening });
        Ok(())
    }
}
3 files · rust Explain with highlit

This snippet implements a small double-entry ledger where money moves between accounts atomically, entirely in memory. The core idea in accounting is that every transfer touches at least two accounts and the sum of all balances must never change — a debit on one side is matched by a credit on the other. The challenge in a concurrent program is making that pair of mutations appear indivisible: no other thread may observe a state where the debit landed but the credit did not, and a failed transfer must leave both accounts exactly as they were.

In the Ledger types tab, Account holds a balance in integer minor units (cents), which avoids the rounding pitfalls of floating-point money. The whole account map lives behind a single Mutex<HashMap<AccountId, Account>> inside Ledger. Guarding the entire map with one lock is a deliberate trade-off: it serializes all transfers, so throughput is limited, but it makes atomicity trivial because no partial state is ever visible while the guard is held. Per-account locks would scale better but reintroduce lock-ordering and deadlock concerns that this design sidesteps.

The Transfer logic tab contains transfer, the heart of the module. It locks once, resolves both accounts, and crucially performs all validation — existence checks, the same-account guard, and the InsufficientFunds check via checked_sub — BEFORE mutating anything. This validate-then-apply ordering is what gives the operation all-or-nothing semantics: if any check fails it returns an Err and no balance has changed. Because debit and credit happen under the same held guard, the pair is effectively one atomic step. checked_add also guards against overflow on the credit side.

Note the .lock() call uses map_err to convert a poisoned mutex into a domain error rather than panicking. A Mutex becomes poisoned if a thread panics while holding it; recovering explicitly keeps one bad transfer from taking down every future one.

The Concurrent test tab spawns many threads hammering transfer in both directions and then asserts the total balance is unchanged. This is the real proof of correctness for a ledger: individual transfers may fail with InsufficientFunds, but the conservation invariant holds regardless of interleaving. Reach for this pattern when correctness and auditability matter more than raw parallel throughput.


Related snips

Share this code

Here's the card — post it anywhere.

Atomic Double-Entry Ledger Transfers With Rust Locking and Poison Recovery — share card
Link copied