Reject AI-Generated Rails Callbacks That Do Slow Work
That after_save callback is why your test suite hits the network. Ask an AI assistant to "sync the record to the CRM after saving" and it will happily hand you an after_save that makes an HTTP call — inside your save, on every save, in every test. It compiles, it passes the happy-path spec, and it plants a slow, flaky dependency in the hottest path of your app. This is one of the easiest AI-generated patterns to reject in review, because the rule is one sentence long.
Why the generated callback is a trap
A callback isn't a hook that runs "after the important part." It is part of the important part:
- It runs on every save — the form update, the admin edit, the data migration, the factory in every one of your 4,000 tests. One 300 ms API call turns
user.saveinto a 300 ms operation everywhere. - It runs inside the transaction. The Rails guides are explicit: "The whole callback chain is wrapped in a transaction. If any callback raises an exception, the execution chain gets halted and a rollback is issued." So a third-party timeout doesn't just fail the sync — it rolls back your save and holds a database transaction open for the duration of the HTTP call.
- It has no retry story. If the network call fails, the work is simply lost — or worse, it half-happened and the record rolled back.
The one-sentence review rule
Callbacks are for cheap invariants. Network and slow or retryable work goes to background jobs.
| Work in the callback | Verdict |
|---|---|
| Normalize an email, set a default, compute a slug | Callback — fine |
| Update a counter or denormalized column | Callback — fine |
| HTTP call to any external API | Reject — background job |
| Sending email, push, or webhooks | Reject — background job |
| Anything that can fail transiently and should retry | Reject — background job |
| Anything slower than ~1 ms | Reject — background job |
The compliant version is barely longer than the generated one:
# AI-generated — reject in review
after_save :sync_to_crm
def sync_to_crm
CrmClient.new.upsert(self) # network call inside your save
end
# What to ask for instead
after_commit :enqueue_crm_sync, on: [:create, :update]
def enqueue_crm_sync
CrmSyncJob.perform_later(id)
end
class CrmSyncJob < ApplicationJob
retry_on Net::OpenTimeout, wait: 3.seconds, attempts: 5
def perform(record_id)
CrmClient.new.upsert(Record.find(record_id))
end
end
Note after_commit, not after_save: it fires only after the transaction actually commits, so you never enqueue a job for a record that rolled back — and by then an exception "won't roll anything back anymore."
What you win
- Saves stay fast. The save does database work and enqueues a job — microseconds, not API round-trips.
- Tests stay offline. No stubbing an HTTP call in every model spec just to call
save. - Slow work retries safely. Active Job's
retry_ongives you declarative retries (default: 5 attempts, 3-second waits). A failed sync becomes a retried job, not a rolled-back save.
Power-user moves
- Make the rejection automatic. Put
WebMock.disable_net_connect!(allow_localhost: true)in your test setup. The next AI-generated network callback fails the suite instantly with a loud "Real HTTP connections are disabled" — you don't have to catch it in review. - Design jobs for at-least-once. Sidekiq's own best practices warn that a job runs "at least once, not exactly once." Pass IDs (not objects), re-fetch state inside
perform, and make the external call idempotent (upsert, not create). - Put the rule in your assistant's context. One line in
CLAUDE.md/ your rules file — "Never do network or slow work in Active Record callbacks; useafter_commit+ a job" — and the model stops generating the pattern instead of you rejecting it weekly.
Resources
- Active Record Callbacks — Rails Guides
- Active Job Basics — Rails Guides
- Sidekiq Best Practices — idempotent, at-least-once jobs
- WebMock — disable real HTTP in tests
Building with AI coding assistants? Yeda AI designs, audits, and ships production LLM systems.