To Call or not to Callback
If you have worked on a Rails project, it’s guaranteed to come across callbacks. We will see in this post a refactoring example and how to use them safely. Let’s get started :smiley:
Let’s start with a reminder, in case you are asking: “Hmmm excuse me, but what are callbacks exactly? :sweat_smile:”
Think about callbacks as breakpoints in an object lifecycle. It allows us to execute some custom methods when reached.
Here is a small example:
# schema:
# name: String
# scroe: Integer
class Student < ApplicationRecord
before_save :assign_score
after_save :log_onboarding
private
def assign_score
score = 100
end
def log_onboarding
p "Student onboarded"
end
end
With the code above, if you create a Student
, any instance will have a score
of 100 and it will output the log_onboarding
message. This will happen just by running Student.create({name: "John"})
.
Let’s move to some real-world example :smiling_imp:
Preparation
Let’s assume that we have an education platform. A student can subscribe to a course and get access to it. Here is the class diagram for our platform. In this example, we care only about the following part:
Class Diagram
And here is a simplified implementation of those classes.
# Schema
# email: String
# first_name: String
# last_name: String
class Student < ApplicationRecord
has_one :subscription
before_save :normalize_name
private
def normalize_name
first_name.downcase
last_name.downcase
end
end
# Schema
# credit: Integer
# plan: String
class Subscription < ApplicationRecord
BASIC_PLAN_CREDIT = 20
PREMIUM_PLAN_CREDIT = 35
BASIC_PLAN = 'basic'
PREMIUM_PLAN = 'premium'
belongs_to :student
has_many :access_links
before_create :assign_credit
after_save :generate_access_link
private
def assign_credit
return credit = BASIC_PLAN_CREDIT if plan == BASIC_PLAN
credit = PREMIUM_PLAN_CREDIT
end
def generate_access_link
access_links.active_links.update_all(expires_at: Time.current)
access_links.create(expires_at: 30.days.from_now)
end
end
# Schema
# expires_at: DateTime
# link: String
class AccessLink < ApplicationRecord
belongs_to :subscription
after_save :send_access_link
scope :active_links, -> { where("expires_at > ?", Time.current) }
private
def send_access_link
access_link = active_links.last
SendEmail.call(link: access_link, student: subscription.student)
end
end
So what happens if we run the following lines:
student = Student.create(first_name: "John", last_name: "DOE", email: "j.doe@test.com")
Subscription.create(plan: "premium", student: student)
Did you get everything together? Here is a flowchart diagram:
Flowchart Diagram
Hmmm, let’s do some refactoring, we will introduce a new service class with the name PrepareOnboarding
.
class PrepareOnboarding
attr_reader :student, :subscription
def initialize(student:, subscription:)
@student = student
@subscription = subscription
end
def call
expires_active_links
access_link = create_one_month_valid_access
# Don't forget to make this service asynchronous
SendEmail.call(link: access_link, student: subscription.student)
end
private
def expires_active_links
subscription.access_links.active_links.update_all(expires_at: Time.current)
end
def create_one_month_valid_access
subscription.access_links.create(expires_at: 30.days.from_now)
end
end
Of course, we need to change the previous classes:
# Schema
# email: String
# first_name: String
# last_name: String
class Student < ApplicationRecord
has_one :subscription
before_save :normalize_name
private
def normalize_name
first_name.downcase
last_name.downcase
end
end
# Schema
# credit: Integer
# plan: String
class Subscription < ApplicationRecord
BASIC_PLAN_CREDIT = 20
PREMIUM_PLAN_CREDIT = 35
BASIC_PLAN = 'basic'
PREMIUM_PLAN = 'premium'
belongs_to :student
has_many :access_links
before_create :assign_credit
private
def assign_credit
return credit = BASIC_PLAN_CREDIT if plan == BASIC_PLAN
credit = PREMIUM_PLAN_CREDIT
end
end
# Schema
# expires_at: DateTime
# link: String
class AccessLink < ApplicationRecord
belongs_to :subscription
scope :active_links, -> { where("expires_at > ?", Time.current) }
end
We can now run the whole flow with this snippet.
student = Student.create(first_name: "John", last_name: "DOE", email: "j.doe@test.com")
subscription = Subscription.create(plan: "premium", student: student)
PrepareOnboarding.call(student: student, subscription: subscription)
Let’s summarize the important points from this refactoring:
It’s safe to use callbacks to manage the internal state of an object. For example, think about before_* callbacks, it could be used for data normalization or formatting preferably with before_validation. The benefit of this approach that we have consistent behavior. It could be called from the console, script but we always want to have this normalization. (check Student class for reference).
We should avoid triggering a side effect, think of the classic example of sending an email with after_save or modifying other models (mainly through associations).
Another point to keep in mind is that callbacks aren’t asynchronous by default, it’s a common mistake and could be easily solved using background jobs.
It’s easy to add a service class for a specific business need. This approach helps with improving coordination between different models in one place and we can cover more edge cases with specs.
Conclusion
Like any advice you read online, take mine with a grain of salt. I could have moved everything to a service class and omitted callbacks. But I wanted to highlight that’s completely fine to use a service and callbacks. It’s a question of balance and preference.
That’s it for today, happy coding :wave: