ActiveRecord High severity

Callback Hell

The accumulation of ActiveRecord lifecycle callbacks (before_save, after_create, after_commit) that silently trigger side effects such as emails, external API calls, or cache invalidation whenever a record is persisted.

Before / After

Problematic Pattern
class User < ApplicationRecord
after_create :send_welcome_email
after_create :notify_slack
after_update :refresh_search_index
after_commit :sync_to_crm, on: :update

private

def notify_slack
  SlackClient.post("New user: #{email}")
end
end
Target Architecture
class User < ApplicationRecord
# Callbacks removed, side effects are explicit
end

class UserOnboarding
def self.call(user)
  WelcomeMailer.with(user: user).deliver_later
  SlackNotificationJob.perform_later(user.id)
  CrmSyncJob.perform_later(user.id)
end
end

# In the controller
user = User.create!(params)
UserOnboarding.call(user)

Why this hurts

Callbacks execute inside the ActiveRecord persistence pipeline, which means every side effect runs within the implicit database transaction wrapping the save. A raised exception from a Slack webhook or a failed SMTP connection aborts the INSERT and rolls back the record, producing user-facing errors for problems entirely outside the domain of user creation. The coupling is invisible at the call site: User.create! no longer means “create a row”, it means “create a row and perform an unknown number of network operations dependent on model include order”.

Side effects hidden in callbacks fire on every persistence event, including data migrations, seed scripts, and RSpec fixtures. A User.update_all executed in a console session accidentally queues thousands of emails because update_all bypasses callbacks but the operator forgot which operations are and are not guarded. Test setup becomes noisy: every spec that instantiates a user must stub SlackClient, the mailer, and the CRM job, or the suite makes live HTTP calls.

The order of callbacks depends on the order of included concerns and the file load order. Adding a new module to the model silently changes the execution sequence, producing subtle regressions that only appear in specific code paths. Rubocop and RuboCop-Rails cannot detect the problem because the callbacks are syntactically valid. Debugging “why did the CRM get updated?” requires grepping the entire model tree for after_* declarations, including any concern mixed in via metaprogramming. The model’s public interface no longer describes its true behavior.

See also: Rails 5.2 Active Record Callback Hell: A Refactoring Playbook.

Get Expert Help

Inheriting a legacy Rails codebase with this problem? Request a Technical Debt Audit.