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
class Account < ApplicationRecord
has_many :ledger_entries, dependent: :restrict_with_exception
validates :currency, presence: true
validates :balance_cents, numericality: { greater_than_or_equal_to: 0 }
def debit!(cents, dedupe_key:)
raise ArgumentError, "amount must be positive" unless cents.positive?
update!(balance_cents: balance_cents - cents)
ledger_entries.create!(amount_cents: cents, direction: "debit", dedupe_key: dedupe_key)
end
def credit!(cents, dedupe_key:)
raise ArgumentError, "amount must be positive" unless cents.positive?
update!(balance_cents: balance_cents + cents)
ledger_entries.create!(amount_cents: cents, direction: "credit", dedupe_key: dedupe_key)
end
end
class BalanceService
class InsufficientFunds < StandardError; end
class CurrencyMismatch < StandardError; end
def self.transfer(from:, to:, cents:, idempotency_key:)
new(from, to, cents, idempotency_key).transfer
end
def initialize(from, to, cents, idempotency_key)
@from = from
@to = to
@cents = cents
@idempotency_key = idempotency_key
end
def transfer
ActiveRecord::Base.transaction do
# Lock in a stable order to avoid deadlocks between concurrent transfers.
[@from, @to].sort_by(&:id).each(&:lock!)
return :already_applied if LedgerEntry.exists?(dedupe_key: debit_key)
raise CurrencyMismatch unless @from.currency == @to.currency
raise InsufficientFunds if @from.balance_cents < @cents
@from.debit!(@cents, dedupe_key: debit_key)
@to.credit!(@cents, dedupe_key: credit_key)
:ok
end
end
private
def debit_key
"#{@idempotency_key}:debit"
end
def credit_key
"#{@idempotency_key}:credit"
end
end
class TransfersController < ApplicationController
def create
from = Account.find(params.require(:from_account_id))
to = Account.find(params.require(:to_account_id))
cents = Integer(params.require(:amount_cents))
result = BalanceService.transfer(
from: from,
to: to,
cents: cents,
idempotency_key: request.headers["Idempotency-Key"] || SecureRandom.uuid
)
render json: { status: result, from_balance: from.reload.balance_cents }, status: :created
rescue BalanceService::InsufficientFunds
render json: { error: "insufficient_funds" }, status: :unprocessable_entity
rescue BalanceService::CurrencyMismatch
render json: { error: "currency_mismatch" }, status: :unprocessable_entity
rescue ArgumentError => e
render json: { error: e.message }, status: :bad_request
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.