Wrapping business logic in ActiveRecord transactions
At some point in your career, it's very likely that you'll be asked to create more than one record in a single request. For features like this, it will make sense to rollback the database changes if anything fails to persist to the database. An all-or-nothing approach. Our answer to this problem lies in the database.
Database transactions
Databases provide the tools we need for this type of feature in the form of transactions. Postgres describes transactions in the following way:
Transactions are a fundamental concept of all database systems. The essential point of a transaction is that it bundles multiple steps into a single, all-or-nothing operation. The intermediate states between the steps are not visible to other concurrent transactions, and if some failure occurs that prevents the transaction from completing, then none of the steps affect the database at all.
In PostgreSQL, a transaction is set up by surrounding your commands with the BEGIN
and COMMIT
commands.
BEGIN;
UPDATE accounts SET balance = balance - 100.00
WHERE name = 'Alice';
UPDATE branches SET balance = balance - 100.00
WHERE name = (SELECT branch_name FROM accounts WHERE name = 'Alice');
UPDATE accounts SET balance = balance + 100.00
WHERE name = 'Bob';
UPDATE branches SET balance = balance + 100.00
WHERE account_id = (SELECT id FROM accounts WHERE name = 'Bob');
COMMIT;
Using transactions in ActiveRecord
In ActiveRecord
the transaction might look something like this:
alice = Account.find_by(name: "Alice")
bob = Account.find_by(name: "Bob")
ActiveRecord::Base.transaction: do
alice.update!(balance: alice.balance - 100)
bob.update!(balance: bob.balance + 100)
bob.branch.update!(balance: bob.branch.balance + 100)
end
Bangarang!
Notice that we used update!
instead of update
. In Ruby, methods that end in !
are commonly referred to as "bang methods" and they usually carry the connotation that they will either: mutate data and persist it or raise an exception if they fail.
In this case, the bang method is necessary to raise an exception and trigger the rollback if the update fails (Non-bang versions, i.e. update
in this case, will simply add values to the errors
attribute on your ActiveRecord
object and fail to roll anything back).
Rescuing the failure
If you need to perform some action on the failure condition like displaying info to your user, then you can rescue
the exception like any other Ruby exception.
class TransfersController < ApplicationController
def create
alice = Account.find_by(name: "Alice") # Hard-coding these, but you get the idea
bob = Account.find_by(name: "Bob")
ActiveRecord::Base.transaction: do
alice.update!(balance: alice.balance - 100)
bob.update!(balance: bob.balance + 100)
bob.branch.update!(balance: bob.branch.balance + 100)
end
rescue
flash[:error] = "Your transfer could not be completed."
redirect_to account_path(alice)
end
end