To Call or not to Callback

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 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 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: