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.