A concurrent bank transaction simulator written in Rust. Models a pull-based debit system where services (Netflix, YouTube, etc.) automatically charge a client account. Multiple charges can run simultaneously, so shared state must be protected against race conditions.
Built as a hands-on exercise to learn threads, shared state, and concurrent programming in Rust.
- Models bank accounts with debit and credit operations
- Registers and manages accounts through a central
Bankstruct - Simulates automatic subscription charges running concurrently on a shared account
- Protects shared state using
Arc<Mutex<T>> - Prevents deadlocks by enforcing a consistent lock ordering based on account ID
- Returns typed errors for domain failures (insufficient funds, invalid amount, account not found) and concurrency failures (lock poisoning)
src/
main.rs
bank/
mod.rs
account.rs # Account struct, debit/credit logic, AccountError
transfer.rs # Transfer struct, execute logic, TransferError
bank.rs # Bank struct — account registry and transfer orchestration
thread::spawnandthread::joinfor launching and synchronizing threadsArc<T>— atomically reference-counted pointer for shared ownership across threadsMutex<T>— mutual exclusion lock for safe mutable access to shared dataMutexGuard<T>and RAII — the lock is held as long as the guard is in scope, released automatically on drop- Race conditions — what they are and how Rust prevents data races at compile time
- Deadlock — how it occurs when two threads acquire the same locks in opposite order, and how to prevent it by enforcing a canonical lock order
Arc<Mutex<T>>as the standard pattern for shared mutable state across threadsResult<T, E>for error handling and the?operator for propagationFrom<E>trait for automatic error type conversion between layers- Enums as typed errors (
AccountError,TransferError,BankError) Deref— howMutexGuard<T>transparently exposes the inner typemoveclosures — required when passing owned data into threads- Module system and visibility (
pub,pub(crate), private fields)
- Separation of concerns: each struct has one job and no knowledge of the others
- Repository pattern:
Bankis the only entry point to account data — internal storage is an implementation detail - Command pattern:
Transferencapsulates the intent of an operation and exposesexecute() - Domain invariants enforced at construction (
Transfer::newreturnsResultand rejects invalid state before the struct is created) - Typed errors that carry context (
AccountNotFound(u64),DuplicateAccount(u64))
When two transfers involving the same accounts run concurrently in opposite directions, naive lock acquisition causes deadlock:
Thread A locks account 1, waits for account 2
Thread B locks account 2, waits for account 1
→ both wait forever
The solution: always acquire locks in ascending order by account ID, regardless of which is from and which is to. This guarantees no circular wait is possible.
if from_id < to_id {
(Arc::clone(&self.from_account), Arc::clone(&self.to_account))
} else {
(Arc::clone(&self.to_account), Arc::clone(&self.from_account))
}cargo run
cargo test
cargo test -- --nocapture # show println output inside tests- Language: Rust
- Standard library only — no external crates