Data integrity problems are among the most common database issues Rails developers face. Besides allowing for proper validation, correctly designed transaction blocks ensure that your data isn't partially created or updated.
However, transactions can also harm your application — or even take down your whole database — when not properly designed.
This article offers a set of good practices for working with transactions. The tips are pretty simple, but they will help make your transactions bulletproof, readable, and relatively safe.
Let's dive in!
1. Use Bang Methods in Rails When Possible
In Rails, method versions with !
can give you confidence that an error will be raised when something goes wrong.
For example, the #save
method also exists in the save!
version. You might want to use this version in the controller if you don't want to raise any errors:
def create
user = User.new(user_params)
if user.save
# redirect
else
render :new
end
end
The above approach won't work well in transactions. By using save
, we can't roll back the process when the error is raised. That's why it is so important to use the !
version of the methods:
ActiveRecord::Base.transaction do
user = User.create(user_attributes)
user.memberships.create(membership_attributes)
end
In the above, the transaction succeeds even if the membership record isn't created, and we end up with a messed-up data structure in the database.
If you use the following version, the transaction reverts due to an ActiveRecord::RecordNotSaved
error:
ActiveRecord::Base.transaction do
user = User.create!(user_attributes)
user.memberships.create!(membership_attributes)
end
2. Handle Errors in Rails Transactions Properly
When it comes to errors in transactions, there are a few rules that you should respect. By following these rules, you'll have readable and well-working code that will not create confusion among other developers or weird behavior that is hard to debug.
Do Not Rescue from ActiveRecord::StatementInvalid
ActiveRecord::StatementInvalid
is a special error raised when something on the database level goes wrong. Never rescue from this error. You should always be explicitly notified when something goes wrong with the database query.
Avoid the following code:
def perform_action(...)
User.transaction do
# perform transaction
end
rescue ActiveRecord::StatementInvalid
# do something
end
Use the Rescue on the Right Level
If you use rescue on the following level, you'll catch the error:
User.transaction do
user.perform_action!
user.perform_another_action!
rescue SomeError
# rescue
end
The transaction does not roll back because you caught the error. Let the error be raised and catch it outside the transaction block:
def some_method
User.transaction do
user.perform_action!
user.perform_another_action!
end
rescue SomeError
# rescue
end
In the above approach, the transaction rolls back in case of an error, and you catch the error. This is the right approach to catch errors raised inside transactions without overwriting transaction behavior.
Do Not Catch Generic Errors
You should avoid catching generic errors like StandardError
or ArgumentError
. This is more like a general rule for readable and easily testable code, but it's worth mentioning.
Catching these errors can make debugging harder, as other places in the code may raise the errors. This could silence some serious issues in your app that are not necessarily related to the place you rescue them.
Use ActiveRecord's Default Rollback Error Wisely
ActiveRecord provides a particular error class that you can use inside a transaction to make a silent rollback. You roll back the transaction by raising the ActiveRecord::Rollback
error, but the error isn't raised outside, as happens with other errors. Keep this behavior in mind and use it wisely.
3. Know When to Avoid Using Transactions in Rails
As with anything, you should not overuse transactions in your code. For example, a common mistake is to wrap only one query into your transaction. This does not make sense because, if the query doesn't succeed, there is no need to roll back anything.
Another common mistake is to wrap code unrelated to your database call into a transaction. You should avoid such an approach, as the transaction will hold the connection unless the code inside the block executes. Limit the code inside the block to call only your database, if possible.
4. Understand the Disadvantages of Transactions
Transactions help maintain data integrity inside a database, but you should also be aware of their disadvantages. For example, queries wrapped in a transaction block take more DB resources than single queries.
Another drawback of using transactions is that it leads to more complex code. Transactions can make your code less readable when used incorrectly.
5. Use the Transaction Block in the Right Context
You can use the transaction method when a class inherits from the ActiveRecord
class. This does not mean that the version you use does not matter. Although it might not matter from a functional perspective, it matters in terms of ensuring your code is readable.
Three common versions use the transaction method:
ActiveRecord::Base.transaction
Model.transaction
Model.new.transaction
When you use many models and mix instance method invocations with classes inside a block, you should use ActiveRecord::Base.transaction
:
ActiveRecord::Base.transaction do
attributes = user.prepare_attributes(account)
membership = Membership.create(attributes)
LogService.log_creation(user, membership)
end
If you deal mostly with code that belongs to a given model, invoke the transaction method on a class:
User.transaction do
user = User.create!(attributes)
user.log_activity(‘creation’)
end
When you operate on a model instance, it makes sense to invoke the transaction method on the instance level:
user.transaction do
user.make_transaction(attributes)
user.log_activity(‘transaction’)
end
Of course, these rules are not official. They are just suggestions to make code more readable.
Next Steps: Review Transactions in Your Ruby on Rails Project
I hope you've found these tips for working with transactions in Ruby on Rails useful.
We've covered the importance of designing Rails transactions properly to improve data integrity and ensure that your processes perform without surprising side effects.
However, a proper error handling policy isn't only beneficial when using transactions — it will also improve your whole codebase. Keep that in mind the next time you expect your code to throw some errors.
Now is an excellent time to review transactions in your Ruby on Rails project design to avoid errors. Design for efficient and reliable communication with your database to make your application more stable.
Happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Top comments (0)