Safe Financial Systems with Actors

Actor Reentrancy showed how state can change across await points. Now let's build systems that handle this correctly.
Follow along with the code: iOS-Practice on GitHub
The Problem Restated
An actor with async validation:
actor BankAccount {
private var balance: Double = 100
func withdraw(amount: Double) async -> Bool {
guard balance >= amount else { return false }
await validateWithBank() // State can change here!
balance -= amount
return true
}
}
Two $60 withdrawals on a $100 account both succeed because both pass the guard before either deducts.
Solution 1: Synchronous State Changes
Move all state mutations to synchronous code paths:
actor BankAccount {
private var balance: Double = 100
func withdraw(amount: Double) async -> Bool {
// Validate externally first
let approved = await validateWithBank(amount: amount)
guard approved else { return false }
// Then do synchronous balance check and deduction
return deductIfPossible(amount: amount)
}
// No await = no reentrancy window
private func deductIfPossible(amount: Double) -> Bool {
guard balance >= amount else { return false }
balance -= amount
return true
}
}
The critical section—checking and deducting—happens in a single synchronous block. No await, no reentrancy.
Solution 2: Transaction States
Model the operation as a state machine:
actor BankAccount {
private var balance: Double = 100
private var pendingWithdrawals: [UUID: Double] = [:]
func withdraw(amount: Double) async -> Result<Double, WithdrawError> {
let transactionId = UUID()
// Phase 1: Reserve (synchronous)
guard balance >= amount else {
return .failure(.insufficientFunds)
}
balance -= amount
pendingWithdrawals[transactionId] = amount
// Phase 2: Validate (async - can be interrupted)
let approved = await validateWithBank(amount: amount)
// Phase 3: Commit or rollback (synchronous)
pendingWithdrawals.removeValue(forKey: transactionId)
if approved {
return .success(balance)
} else {
balance += amount // Rollback
return .failure(.validationFailed)
}
}
}
The balance is deducted immediately (Phase 1). If validation fails, it's restored (Phase 3). Other withdrawals see the reduced balance during the async window.
Solution 3: Serial Queue Pattern
Process withdrawals one at a time using an async queue:
actor BankAccount {
private var balance: Double = 100
private var operationQueue: [CheckedContinuation<Bool, Never>] = []
private var isProcessing = false
func withdraw(amount: Double) async -> Bool {
// Wait for our turn
if isProcessing {
await withCheckedContinuation { continuation in
operationQueue.append(continuation)
}
}
isProcessing = true
defer { processNext() }
guard balance >= amount else { return false }
await validateWithBank(amount: amount)
guard balance >= amount else { return false } // Re-check
balance -= amount
return true
}
private func processNext() {
if let next = operationQueue.first {
operationQueue.removeFirst()
next.resume(returning: true)
} else {
isProcessing = false
}
}
}
Only one withdrawal processes at a time. Others queue up and wait.
Solution 4: Optimistic Concurrency with Retry
Let concurrent operations proceed, but detect conflicts:
actor BankAccount {
private var balance: Double = 100
private var version: Int = 0
func withdraw(amount: Double) async -> Bool {
let startVersion = version
let startBalance = balance
guard startBalance >= amount else { return false }
await validateWithBank(amount: amount)
// Check if state changed during await
if version != startVersion {
// Conflict! Could retry or fail
return false
}
balance -= amount
version += 1
return true
}
}
The version number detects any modification. If it changed, someone else modified state and we bail out.
Which Pattern to Use?
Synchronous state changes (Solution 1):
- Simplest to reason about
- Best when async work can happen before state check
- Example: Pre-validate, then transact
Transaction states (Solution 2):
- Best for operations that must "reserve" resources
- Handles rollback cleanly
- Example: Inventory systems, booking systems
Serial queue (Solution 3):
- Guaranteed ordering
- May impact throughput
- Example: Critical financial operations
Optimistic concurrency (Solution 4):
- Best throughput under low contention
- Requires retry logic
- Example: High-read, low-write scenarios
Real-World Consideration
In production financial systems, you'd likely:
- Use database transactions (not in-memory state)
- Implement idempotency keys
- Have audit logs
- Handle distributed systems concerns
But the patterns here demonstrate the concurrency thinking that underlies those systems.
The Complete Safe Account
actor SafeBankAccount {
private var balance: Double
private var transactionLog: [String] = []
init(initialBalance: Double) {
self.balance = initialBalance
}
func withdraw(amount: Double) async -> Result<Double, TransactionError> {
// Synchronous reservation
guard balance >= amount else {
log("Withdrawal of $\(amount) rejected - insufficient funds")
return .failure(.insufficientFunds)
}
balance -= amount
let reservedBalance = balance
// Async validation
let approved = await performFraudCheck(amount: amount)
if approved {
log("Withdrew $\(amount) - Balance: $\(balance)")
return .success(balance)
} else {
// Rollback
balance += amount
log("Withdrawal of $\(amount) rolled back - fraud check failed")
return .failure(.fraudCheckFailed)
}
}
func getBalance() -> Double { balance }
func getLog() -> [String] { transactionLog }
private func log(_ message: String) {
transactionLog.append("[\(Date())]: \(message)")
}
private func performFraudCheck(amount: Double) async -> Bool {
try? await Task.sleep(nanoseconds: 500_000_000)
return true
}
}
enum TransactionError: Error {
case insufficientFunds
case fraudCheckFailed
}
The balance is deducted synchronously before the await. Other concurrent withdrawals see the reduced balance immediately. If validation fails, we roll back.
The Rule
Design for reentrancy. Actors protect against data races, not against time. Structure your code so state changes happen in synchronous blocks, and handle the case where async operations need to be rolled back.