29
.
04
.
2024
3
.
10
.
2022
Ruby on Rails
Backend
Tutorial

Should I use Active Record Callbacks?

Mateusz Woźniczka
Ruby Developer

Callbacks are methods that are called at specified moments of an object's life. They can be called whenever the object is created, updated, saved, deleted, validated, or loaded from the database.

Let's assume that in our Blog app, we want to perform the following actions whenever a new post is added:

  • make sure that the title is uppercase
  • send an email notification to subscribers

Our code (utilizing callbacks) might look as follows:

# models/post.rb
class Post < ApplicationRecord
  before_save :format_title
  after_create :send_email_notification

  private

  def format_title
    title.upcase!
  end

  def send_email_notification
    puts "SENDING EMAIL - Post #{id}"
    UserMailer.with(post_id: id).new_post_added.deliver_later
  end
end

Everything seems to be working as intended: the title of the post is capitalized, and an email has been sent.

> Post.create(title: 'a title', body: 'a body')
   (0.6ms)  SELECT sqlite_version(*)
  TRANSACTION (0.1ms)  begin transaction
  Post Create (0.4ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?)  
    [["title", "A TITLE"], ["body", "a body"], ["created_at", "2022-08-18 22:45:27.863105"], ["updated_at", "2022-08-18 22:45:27.863105"]]
SENDING EMAIL - Post 5
  TRANSACTION (2.0ms)  commit transaction
=> <Post id: 5, title: "A TITLE", body: "a body", created_at: "2022-08-18 22:45:27.863105000 +0000", 
        updated_at: "2022-08-18 22:45:27.863105000 +0000">

It looks like Rails allows us to add new functionality linked to the object's lifecycle in a really simple, quick, and DRY way.

Sharp knives

One of the Rails doctrines is to Provide sharp knives. It means that Rails supplies some features, that can cause a lot of harm if used improperly or without full understanding.

Active Record Callback is an example of such a feature because using it often ends up making the code harder to read and maintain. The following examples describe some of the potential issues, that using callbacks could cause.

Callbacks run every time

Every time an object's state changes all callbacks linked to that change are called. It means, that:

  • format_title method is invoked whenever a post record is about to be saved. This might be helpful to keep the format of the titles consistent, and not cause any real trouble.
  • every time a new post is created an email notification is sent. This can cause some issues because we might not want to send an email when the post is created during debugging, testing, or creating posts in some other way (such as bulk import or any other functionality, that might be implemented in the future).

Even if a callback is fitting perfectly our current needs, it is very probable, that we will have to change it or even get rid of it during the implementation of a new feature. And this means additional work - sometimes quite a lot of it.

Testing models with callbacks is harder

As we already know, callbacks run every time, so in our case, this means, that an email notification will be sent every time a Post object is saved in the DB. This is obviously something we want to avoid, and there are plenty of ways to do it (configuring dev and test mailers, so emails are not delivered, or using some gems). But it won't work in cases when a callback is making some API call, or calling some other service. In this case, we may need to use stubs or mocks as a workaround - yet again more work to do.

Callback may increase coupling

Callbacks (especially the ‘after’ ones) are somehow encouraging to put some of the domain logic into them because it makes the feature implementation faster.

In our example, we want to send an email notification, every time a new post is added. Triggering email delivery from after create callback is very easy to implement, but it increases the coupling between Post and UserMailer classes. Every change we make in UserMailer class can potentially cause a change in behavior of Post object which is an obvious antipattern.

It is hard to pick the right callback for the job

There are about 13 different callbacks, and some of them have very subtle differences. Picking the one, that we actually need may be quite difficult

For example, if we change after_create to after_commit we end up with the following code:

# models/post.rb
class Post < ApplicationRecord
  before_save :format_title
  after_commit :send_email_notification

  private

  def format_title
    title.upcase!
  end

  def send_email_notification
    puts "SENDING EMAIL - Post #{id}"
    UserMailer.with(post_id: id).new_post_added.deliver_later
  end
end

In this case, an email is sent after the DB transaction.

> Post.create(title: 'a title', body: 'a body')
   (0.7ms)  SELECT sqlite_version(*)
  TRANSACTION (0.1ms)  begin transaction
  Post Create (0.4ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?)  
    [["title", "A TITLE"], ["body", "a body"], ["created_at", "2022-08-18 23:38:24.943231"], ["updated_at", "2022-08-18 23:38:24.943231"]]
  TRANSACTION (3.2ms)  commit transaction
SENDING EMAIL - Post 6
=> <Post id: 6, title: "A TITLE", body: "a body", created_at: "2022-08-18 23:38:24.943231000 +0000", 
        updated_at: "2022-08-18 23:38:24.943231000 +0000">

The difference seems to be very subtle. But if a transaction fails, data added by it will be rolled back. It means that after_commit won't send the email, but after_save will send it (so we end up with an email sent, that informs about the new post, which has not been added).

The execution order of callbacks is tricky

Callbacks are triggered by specific moments in the object's lifecycle. It is obvious, that before_save will be called before after_save. When we have multiple callbacks of the same type, they will be called in the order in which they are defined.

Things become more tricky, when we have multiple callbacks, that are triggered by the same event - then the more generic one (after_save) runs after the more specific ones (after_create).

Let's consider the following class:

# models/post.rb
class Post < ApplicationRecord
  after_save :send_email_to_author
  after_create :send_twitter_notification
  after_create :send_email_notification

  private

  def send_email_notification
    puts "SEND EMAIL - Post #{id}"
    UserMailer.with(post_id: id).new_post_added.deliver_later
  end

  def send_email_to_author
    puts 'SEND EMAIL TO AUTHOR'
    UserMailer.with(post_id: id,
                    author_id: author.id).publication_notification.deliver_later
  end

  def send_twitter_notification
    puts 'SEND TWITTER NOTIFICATION'
      TwitterPublisher.perform_later(post_id: id)
  end
end

The creation of a new Post object looks in this case as follows:

> > Post.create(title: 'A TITLE', body: 'a body')
   (0.8ms)  SELECT sqlite_version(*)
  TRANSACTION (0.1ms)  begin transaction
  Post Create (1.8ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?)  
    [["title", "A TITLE"], ["body", "a body"], ["created_at", "2022-08-19 23:15:10.322892"], ["updated_at", "2022-08-19 23:15:10.322892"]]
SEND TWITTER NOTIFICATION
SENDING EMAIL - Post 8
SEND EMAIL TO AUTHOR
  TRANSACTION (0.6ms)  commit transaction
=> <Post id: 8, title: "A TITLE", body: "a body", created_at: "2022-08-19 23:15:10.322892000 +0000", 
        updated_at: "2022-08-19 23:15:10.322892000 +0000">

As we can see, the callbacks are run in the following order: send_twitter_notification, send_email_notification, send_email_to_author which may be a bit counterintuitive.

Summary

Active Record Callbacks at first seem to be yet another nice Rails feature helping us write the code the 'Rails way'. But actually, when the application grows the profits of using them are quickly buried by lots of additional work, that is needed to work around them.

If you really have to use a callback, make sure, that it interferes only with the object, that triggered callback in the first place.

Mateusz Woźniczka
Ruby Developer

Check my Twitter

Check my Linkedin

Did you like it? 

Sign up To VIsuality newsletter

READ ALSO

How to become a Ruby Certified Programmer Title image

How to become a Ruby Certified Programmer

14
.
11
.
2023
Michał Łęcicki
Ruby
Visuality
Vector Search in Ruby - Paweł Strzałkowski

Vector Search in Ruby

17
.
03
.
2024
Paweł Strzałkowski
ChatGPT
Embeddings
Postgresql
Ruby
Ruby on Rails
LLM Embeddings in Ruby - Paweł Strzałkowski

LLM Embeddings in Ruby

17
.
03
.
2024
Paweł Strzałkowski
Ruby
LLM
Embeddings
ChatGPT
Ollama
Handling Errors in Concurrent Ruby, Michał Łęcicki

Handling Errors in Concurrent Ruby

14
.
11
.
2023
Michał Łęcicki
Ruby
Ruby on Rails
Tutorial
Recap of Friendly.rb 2024 conference

Insights and Inspiration from Friendly.rb: A Ruby Conference Recap

02
.
10
.
2024
Kaja Witek
Conferences
Ruby on Rails

Covering indexes - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Ruby on Rails
Postgresql
Backend
Ula Sołogub - SQL Injection in Ruby on Rails

The Deadly Sins in RoR security - SQL Injection

14
.
11
.
2023
Urszula Sołogub
Backend
Ruby on Rails
Software
Michal - Highlights from Ruby Unconf 2024

Highlights from Ruby Unconf 2024

14
.
11
.
2023
Michał Łęcicki
Conferences
Visuality
Cezary Kłos - Optimizing Cloud Infrastructure by $40 000 Annually

Optimizing Cloud Infrastructure by $40 000 Annually

14
.
11
.
2023
Cezary Kłos
Backend
Ruby on Rails

Smooth Concurrent Updates with Hotwire Stimulus

14
.
11
.
2023
Michał Łęcicki
Hotwire
Ruby on Rails
Software
Tutorial

Freelancers vs Software house

02
.
10
.
2024
Michał Krochecki
Visuality
Business

Table partitioning in Rails, part 2 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
Ruby on Rails

N+1 in Ruby on Rails

14
.
11
.
2023
Katarzyna Melon-Markowska
Ruby on Rails
Ruby
Backend

Turbo Streams and current user

29
.
11
.
2023
Mateusz Bilski
Hotwire
Ruby on Rails
Backend
Frontend

Showing progress of background jobs with Turbo

14
.
11
.
2023
Michał Łęcicki
Ruby on Rails
Ruby
Hotwire
Frontend
Backend

Table partitioning in Rails, part 1 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend
Ruby on Rails

Table partitioning types - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend

Indexing partitioned table - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
SQL Views in Ruby on Rails

SQL views in Ruby on Rails

14
.
11
.
2023
Jan Grela
Backend
Ruby
Ruby on Rails
Postgresql
Design your bathroom in React

Design your bathroom in React

14
.
11
.
2023
Bartosz Bazański
Frontend
React
Lazy Attributes in Ruby - Krzysztof Wawer

Lazy attributes in Ruby

14
.
11
.
2023
Krzysztof Wawer
Ruby
Software

Exporting CSV files using COPY - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Ruby
Ruby on Rails
Michał Łęcicki - From Celluloid to Concurrent Ruby

From Celluloid to Concurrent Ruby: Practical Examples Of Multithreading Calls

14
.
11
.
2023
Michał Łęcicki
Backend
Ruby
Ruby on Rails
Software