DEV Community

Cover image for 5 Tips to Design Ruby on Rails Transactions the Right Way
Paweł Dąbrowski for AppSignal

Posted on • Originally published at blog.appsignal.com

5 Tips to Design Ruby on Rails Transactions the Right Way

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)