ruby 43 lines · 9 steps

Atomic money transfers with Rails transactions

A service object moves funds between two accounts atomically using row locks and a database transaction.

Explained by highlit
1class FundsTransfer
2 class InsufficientFundsError < StandardError; end
3 
4 def initialize(source:, destination:, amount:)
5 @source = source
6 @destination = destination
7 @amount = amount
8 end
9 
10 def call
11 ActiveRecord::Base.transaction do
12 @source.lock!
13 @destination.lock!
14 
15 raise InsufficientFundsError if @source.balance < @amount
16 
17 @source.update!(balance: @source.balance - @amount)
18 @destination.update!(balance: @destination.balance + @amount)
19 
20 transfer = Transfer.create!(
21 source_account: @source,
22 destination_account: @destination,
23 amount: @amount,
24 status: :completed
25 )
26 
27 LedgerEntry.create!(account: @source, transfer: transfer, change: -@amount)
28 LedgerEntry.create!(account: @destination, transfer: transfer, change: @amount)
29 
30 transfer
31 end
32 rescue InsufficientFundsError
33 errors << "Source account has insufficient funds"
34 false
35 rescue ActiveRecord::RecordInvalid => e
36 errors << e.record.errors.full_messages.to_sentence
37 false
38 end
39 
40 def errors
41 @errors ||= []
42 end
43end
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Wrapping mutations in a transaction guarantees that a partial failure rolls everything back.
  2. 2Pessimistic row locks serialize concurrent transfers so two requests can't double-spend the same balance.
  3. 3Rescuing specific exceptions lets a service return a clean false-or-result instead of leaking raw errors.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Atomic money transfers with Rails transactions — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code