Rails Callbacks
What is a callback?
In Ruby on Rails, a callback is a method or unit of code you can connect to the lifecycle events of Active Record models. For example, if in your application you want to send a welcome email after a user is first created, you could use a callback for that. It would look something like this:
Example 1
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
user = User.create(user_params)
if user.save
render user
else
render user.errors
end
end
end
# app/models/user.rb
class User < ApplicationRecord
after_create :send_welcome_email
def send_welcome_email
UserMailer.with(user: self)
.welcome
.deliver_later
end
end
Why are callbacks used?
If you didn’t use a callback in the example above, you would need to find every place in your codebase a user was created and add a call to the UserMailer after. In a simple application, this might be 1 place in the code, the UsersController, but it could be more in practice. In addition, many would consider calling a method to send an email a side effect of creating a user that does not belong in a controller. It makes sense to use a callback so that logic belongs to the User model, rather than the controller.
Example 2
class UsersController < ApplicationController
def create
user = User.create(user_params)
if user.save
UserMailer.with(user: user)
.welcome
.deliver_later
render user
else
render user.errors
end
end
end
What problems do callbacks cause?
If you have every read opinions online on the use of callbacks in Ruby on Rails applications, you know they are divisive. The simple example presented above may not cause problems, but let’s examine some problems callbacks can cause as your application scales
Indirection
The code in example 2 reads top to bottom without needing to switch files. A User is created, and if it is created successfully, the welcome mailer is sent. In example 1, if you didn’t know about callbacks you might now know when or where in the code the users mailer is being sent. Even though this approach follows the best practice of reducing side effects in controllers, the reader must ‘know’ that side effects of creating a User may be in the User model.
Complexity
Example 1 contains a bug. When the job to send the user mailer runs, it might be before the transaction to commit the user record to the database has finished. You would see an ActiveRecord::RecordNotFound error then, and the job would retry. The correct solution to this would be to use the after_commit callback, since it runs after the transaction has committed. This is an example of one of the many gotchas around callbacks. Here are some more complications around callbacks that have caused production errors in applications I maintained:
Execution Order
In the following script, what order will the callbacks run in?
class User < ActiveRecord::Base
after_create :this_happens_first
after_create :then_this
def this_happens_first
puts 'first'
end
def then_this
puts 'second'
end
end
User.create(name: 'John Lennon', age: 90)
# => 'first'
# => 'second'
If the example used after_commit callbacks, they would still run in the same order, right? Wrong! In the example below, this didn’t change the outcome, but in real production applications it can. It’s a good illustration of the kinds of problems associated with having multiple callbacks
class User < ActiveRecord::Base
after_commit :this_happens_first
after_commit :then_this
def this_happens_first
puts 'first'
end
def then_this
puts 'second'
end
end
User.create(name: 'John Lennon', age: 90)
# => 'second'
# => 'first'
Error Behavior
Errors in 1 callback will affect others in the callback chain. An unhandled exception, as are so common in legacy applications, could result in your critical business processes never running at all.
class User < ActiveRecord::Base
after_commit :this_never_happens
after_commit :because_this_raises_an_error
def because_this_raises_an_error
raise 'an error occurred'
end
def this_never_happens
puts 'super important business process'
end
end
begin
User.create(name: 'John Lennon', age: 90)
rescue RuntimeError => e
puts e
end
# => 'an error occurred'
Too sharp a knife
It’s easy to think that you want the welcome email to send every time a user is created, but exceptions will pop up over time. Long after you have implemented the callback to send the email after creating a user, your business or operations team will need to bulk onboard some users. No problem, you say, as you write a quick script to iterate over their spreadsheet and create some users. However, when you run it in production you end up overwhelming your mail provider, erroring out, and sending some unexpected emails. Sure, adding logic to skip the callback in certain cases is easy, but you are adding complexity to what seemed so simple.
Alternatives
To me, callbacks are Rails’ answer to the question of where do you put stuff that isn’t a CRUD action, AKA your business logic. There are a lot of opinions on alternatives to using callbacks for business logic, which I will explore below.
Service Objects
Service objects can open up a can of worms as to what they even are, but in this case I am referring to encapsulating the business process of creating a user, for example, into a piece of procedural code, like so:
class CreateUser
def initialize(user_params)
@user_params = user_params
end
def call
user = User.create!(user_params)
UserMailer.with(user: user)
.welcome
.deliver_later unless user.import?
# ...
end
end
This has the advantage of being able to read top to bottom without changing files, avoids any complication due to database transactions, and will be easier to test than each other example presented so far.
Refactoring to this approach in an existing application would involve finding where you are creating records and calling this class instead, and maintaining that strategy moving forward.
Event Based Architecture
Some would disagree with the Rails convention of coupling business logic and CRUD altogether and propose an event-based architecture. According to the Single Responsibility Principle, none of the examples above really have one responsibility since they trigger the welcome email in one way or another. An event-based solution might look like the User model publishing a ‘User #1 Created Event’ to a stream and a process separate from Rails consuming that event stream and running code based on the events. I’m not going to include an example here as I haven’t too many well-supported libraries in the Ruby ecosystem supporting event-based logic that would also be succinct enough to make a good example. It seems like the most likely choice moving forward will be something like the Socketry Async framework as the concept of Fibers becomes more fleshed out.
To wrap it up, I would make the argument that callbacks are fine when limited to 1 or 2 callbacks per model lifecycle but the increase in complexity as applications grow requires care to avoid the problems explained here.