Concurrency Deep Dive

Safe Financial Systems with Actors

February 12, 2026
6 min read
Featured image for blog post: 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:

  1. Use database transactions (not in-memory state)
  2. Implement idempotency keys
  3. Have audit logs
  4. 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch