ruby 104 lines · 4 tabs

Atomic Account Transfers in Rails With Row Locks and a Balance Service

Shared by codesnips Sep 2026
4 tabs
class CreateAccountsAndLedger < ActiveRecord::Migration[7.1]
  def change
    create_table :accounts do |t|
      t.string :name, null: false
      t.string :currency, null: false, default: "USD"
      t.bigint :balance_cents, null: false, default: 0
      t.timestamps
    end

    add_check_constraint :accounts, "balance_cents >= 0", name: "non_negative_balance"

    create_table :ledger_entries do |t|
      t.references :account, null: false, foreign_key: true
      t.bigint :amount_cents, null: false
      t.string :direction, null: false
      t.string :dedupe_key, null: false
      t.timestamps
    end

    add_index :ledger_entries, :dedupe_key, unique: true
  end
end
4 files · ruby Explain with highlit

This snippet shows how a money transfer between two accounts is made atomic and safe under concurrency in Rails, using a database transaction, pessimistic row locks, and a service object that encapsulates the invariant.

The create_accounts migration establishes the storage. Balances are stored in integer cents columns rather than floats to avoid rounding errors, and a CHECK (balance_cents >= 0) constraint is added as a last line of defense so the database itself refuses to persist an overdrawn account even if the application logic is bypassed. A ledger_entries table records every debit and credit so the running balance can always be reconciled against an append-only history, which is the essence of double-entry bookkeeping.

The Account model exposes a debit! and credit! pair. Both operate on cents and both write a matching LedgerEntry, so the mutation of balance_cents and the audit row are created together. The bang methods raise on failure, which is deliberate: they are only ever called inside a transaction, so a raised error triggers a rollback and undoes any partial work.

The heart of the pattern lives in BalanceService. transfer wraps everything in ActiveRecord::Base.transaction and, crucially, calls lock! on both accounts before touching their balances. lock! issues SELECT ... FOR UPDATE, so concurrent transfers on the same account serialize instead of racing and producing a lost update. The accounts are locked in a deterministic order (sort_by(&:id)) to avoid deadlocks when two transfers touch the same pair of accounts in opposite directions. An InsufficientFunds guard runs after the lock is held, ensuring the balance check reflects the truly current row.

Idempotency is handled with dedupe_key: if a LedgerEntry with that key already exists the method returns early, so a retried request (from a client timeout, for instance) will not double-charge.

The TransfersController is thin — it parses params, delegates to BalanceService, and rescues the domain errors into appropriate HTTP responses. This separation keeps the invariant in one testable place and prevents controllers or callbacks from mutating balances directly, which is the most common source of ledger drift in real systems.


Related snips

Share this code

Here's the card — post it anywhere.

Atomic Account Transfers in Rails With Row Locks and a Balance Service — share card
Link copied