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

Is Go Language the Right Choice for Your Next Project?

14
.
11
.
2023
Maciej Zdunek
Backend
Business

SXSW Tradeshow 2020: Get Your FREE Tickets and Meet Us

02
.
10
.
2024
Michał Krochecki
Ruby on Rails
Conferences
Frontend
Backend
Business

How to build effective website: simplicity & McDonald's

14
.
11
.
2023
Lukasz Jackiewicz
Ruby on Rails
Frontend
Design

Thermal Printer Protocols for Image and Text

14
.
11
.
2023
Burak Aybar
Backend
Tutorial
Software

WebUSB - Print Image and Text in Thermal Printers

14
.
11
.
2023
Burak Aybar
Backend
Tutorial
Software

What happened in Visuality in 2019

14
.
11
.
2023
Maciej Zdunek
Visuality
HR

Three strategies that work in board games and in real life

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

HR Wave - No Bullshit HR Conference 2019

14
.
11
.
2023
Alicja Gruszczyk
HR
Conferences

Lightning Talks in your company

14
.
11
.
2023
Jarosław Kowalewski
Ruby on Rails
Visuality

Stress in Project Management

02
.
10
.
2024
Wiktor De Witte
HR
Project Management

How to find good developers and keep them happy - Part 1

02
.
10
.
2024
Michał Krochecki
HR
Visuality

PKP Intercity - Redesign and case study of polish national carrier

14
.
11
.
2023
Katarzyna Szewc
Design
Business
Frontend

Let’s prepare for GITEX Dubai together!

14
.
11
.
2023
Michał Piórkowski
Conferences
Business

Ruby Quirks

14
.
11
.
2023
Jan Matusz
Ruby on Rails
Ruby

Visuality recognized as one of the Best Ruby on Rails Devs

14
.
11
.
2023
Maciej Zdunek
Ruby on Rails
Visuality
Business

Is the culture of the organization important?

14
.
11
.
2023
Alicja Gruszczyk
Conferences
Visuality

Between the devil and the deep blue sea

04
.
12
.
2023
Mateusz Wodyk
Project Management
Backend
HR

Let’s prototype!

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

5 marketing hacks which will make your life easier

14
.
11
.
2023
Maciej Zdunek
Marketing
Design