Ruby Science

Replace Callback with Method

If your models are hard to use and change because their persistence logic is coupled with business logic, one way to loosen things up is by replacing callbacks.

Uses

  • Reduces coupling of persistence logic with business logic.
  • Makes it easier to extract concerns from models.
  • Fixes bugs from accidentally triggered callbacks.
  • Fixes bugs from callbacks with side effects when transactions roll back.

Steps

  • Use extract method if the callback is an anonymous block.
  • Promote the callback method to a public method if it’s private.
  • Call the public method explicitly, rather than relying on save and callbacks.

Example

# app/models/survey_inviter.rb
def deliver_invitations
  recipients.map do |recipient_email|
    Invitation.create!(
      survey,
      sender,
      recipient_email,
      'pending',
      @message
    )
  end
end
# app/models/invitation.rb
after_create 
# app/models/invitation.rb
private

def deliver
  Mailer.invitation_notification(self).deliver
end

In the above code, the SurveyInviter is simply creating Invitation records, and the actual delivery of the invitation email is hidden behind Invitation.create! via a callback.

If one of several invitations fails to save, the user will see a 500 page, but some of the invitations will already have been saved and delivered. The user will be unable to tell which invitations were sent.

Because delivery is coupled with persistence, there’s no way to make sure that all the invitations are saved before starting to deliver emails.

Let’s make the callback method public so that it can be called from SurveyInviter:

# app/models/invitation.rb
def deliver
  Mailer.invitation_notification(self).deliver
end

private

Then remove the after_create line to detach the method from persistence.

Now we can split invitations into separate persistence and delivery phases:

# app/models/survey_inviter.rb
def deliver_invitations
  create_invitations.each(&)
end

def create_invitations
  Invitation.transaction do
    recipients.map do |recipient_email|
      Invitation.create!(
        survey,
        sender,
        recipient_email,
        'pending',
        @message
      )
    end
  end
end

If any of the invitations fail to save, the transaction will roll back. Nothing will be committed and no messages will be delivered.

Next Steps

  • Find other instances where the model is saved, to make sure that the extracted method doesn’t need to be called.

Ruby Science

The canonical reference for writing fantastic Rails applications from authors who have created hundreds.

Work with us to make a new Rails app, or to maintain, improve, or scale your existing app.