Why callback chains rot at scale in Rails, and how I replace them with service objects, domain events, and form objects.
It was a Tuesday at the creator-economy platform I worked at and staging was on fire. A teammate had added one more after_commit to the User model. Nothing fancy. Just a “ping the search index when the user changes”. Suddenly, User.create! in tests took 9 seconds, the staging signup flow was timing out, and a colleague pinged me in DMs with the classic line. “I didn’t change anything weird.”
He’d changed exactly one thing. The model already had seventeen callbacks. He added an eighteenth. That was the one that broke the camel.
I’ve been on Rails since the 3.x days. I’ll defend convention over configuration to anyone who’ll listen. But ActiveRecord callbacks, the way most Rails apps use them, are a long-running tax that nobody pays attention to until the monolith is too big to refactor in a sprint. On that platform the User model was getting hit from all over, across a sprawl of repos, against an Aurora writer that was already doing more than I’d like. Every callback ran on every save. Half of them weren’t even needed for half of the call sites.
My position is simple. Callbacks should not contain business logic. Period. Use them for invariants tied to the row itself, things like normalizing an email before insert. For everything else, write a service object, publish a domain event, or wrap the input in a form object. The Rails community has known this for a decade, and we still keep losing the argument to “but it’s just one more callback”.
Here is the kind of User model I’ve inherited more than once.
class User < ApplicationRecord
has_many :memberships, dependent: :destroy
has_one :profile, dependent: :destroy
before_validation :normalize_email
before_create :assign_default_role
after_create :create_default_profile
after_create :send_welcome_email
after_create :enqueue_onboarding_drip
after_commit :reindex_in_elasticsearch, on: [:create, :update]
after_commit :notify_crm, on: :create
after_commit :grant_trial_subscription, on: :create
after_update :sync_to_billing_provider, if: :saved_change_to_email?
private
def reindex_in_elasticsearch
SearchIndex.reindex(self)
end
def notify_crm
CrmClient.new.upsert_contact(self)
end
end
Every line above looks reasonable on its own. Together they make User.create! undebuggable. A failure in notify_crm rolls back the transaction in surprising ways. A test that just wants a User fixture pulls in half the platform. Worse, when you’re doing something like the bulk imports we ran against a multi-terabyte Aurora writer, you literally cannot afford the side effects on every row.
The fix I push everywhere I work is to give each meaningful write path its own service object. Not “fat models”, not “skinny controllers”. One use case per service.
class SignUpUser
Result = Struct.new(:user, :errors, keyword_init: true)
def self.call(params)
new(params).call
end
def initialize(params)
@params = params
end
def call
user = User.new(@params.slice(:email, :password))
user.email = user.email.to_s.downcase.strip
user.role = :member
return Result.new(errors: user.errors) unless user.save
Profiles::CreateDefault.call(user)
Events.publish("user.signed_up", user_id: user.id)
Result.new(user: user, errors: nil)
end
end
Controllers now read like the use case they implement. SignUpUser.call(params). ChangeUserEmail.call(user, new_email). MergeAccounts.call(primary, duplicate). No callback magic. The transaction boundary is explicit. The list of side effects is right there in the file you opened.
This works because Rails never required you to use callbacks. It only encouraged it. Convention is a default, not a contract.
The other piece is decoupling the side effects from the write. Welcome email, CRM sync, search reindex, trial provisioning. None of them belong inside the transaction. None of them belong on the model.
module Events
class << self
def publish(name, payload)
job = EventDispatchJob.set(wait: 1.second).perform_later(name, payload.deep_stringify_keys)
Rails.logger.info(event: name, job_id: job.provider_job_id)
rescue StandardError => e
Honeybadger.notify(e, context: { event: name, payload: payload })
end
end
end
class WelcomeEmailListener
EVENT = "user.signed_up"
def self.call(payload)
user = User.find_by(id: payload["user_id"])
return unless user
UserMailer.welcome(user).deliver_later
end
end
A couple of things to notice. The publish wraps the failure mode, because nothing kills a Rails app’s signup flow faster than a Sidekiq outage taking down the model save. The listener accepts a payload, not a record, so a replay or a retry doesn’t depend on the row still looking the way it did a minute ago. Idempotency belongs in the listener, not the publisher.
This is the same shape we used on the branded-mobile-app billing flow at that creator platform, when Apple’s server-to-server renewal notifications kept retrying after our 30-second deadline and our handler had no idempotency key. We pushed the work into a Sidekiq job, gated it with a database-level unique constraint on (apple_original_transaction_id, notification_uuid), and the endpoint started returning 200 within 5 seconds. Apple’s retries became idempotent at the queue layer. Same pattern, different domain. The model didn’t grow another callback. The queue did the work.
The last piece, less famous but the one that quietly removes the most callback pressure, is the form object. Any time you’re tempted to add a before_validation to massage params, you want a form object.
class SignUpForm
include ActiveModel::Model
attr_accessor :email, :password, :marketing_opt_in
validates :email, presence: true, format: URI::MailTo::EMAIL_REGEXP
validates :password, length: { minimum: 12 }
def normalized_email
email.to_s.downcase.strip
end
def to_user_params
{ email: normalized_email, password: password }
end
end
The form object validates the input. The service object runs the use case. The model enforces row-level invariants and nothing else. Three responsibilities, three files. Easy to read, easy to test, no surprises at 2 a.m.
before_validation gymnastics.Thanks for reading. If you’ve got thoughts, send them my way.