Class: RuboCop::Cop::DevDoc::Rails::AvoidRailsCallbacks

Inherits:
Base
  • Object
show all
Defined in:
lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb

Overview

Avoid Rails ActiveRecord callback DSL in model files.

Rationale

Rails callbacks (after_create, before_save, etc.) cause problems with transaction ordering, hidden side effects, and unclear control flow. When a callback fires is not always obvious to the reader, and callbacks can trigger unexpectedly during testing or data migrations.

Instead, implement an explicit method that makes the side effect visible at the call site:


class Order < ApplicationRecord
after_create :send_confirmation_email
end

✔️
class Order < ApplicationRecord
def save_with_confirmation_email
  transaction do
    saved = save
    invoice.save! if saved
  end
  OrderMailer.confirmation(self).deliver_later if saved
end
end

The deliver_later is outside the transaction deliberately — DevDoc/Rails/NoDeliverLaterInTransaction flags mailers/jobs queued inside a transaction (the job may run before commit and read stale data). The saved local leaks out of the block (Ruby's normal scoping), gating the mailer on actual success.

When you have multiple call sites needing the same guard

The single-method-per-place pattern above works when there is one natural call site. When several controllers/jobs/bulk operations all need to enforce the same invariant before destroying or saving, the temptation is to reach for before_destroy / before_save so nothing slips through. There is a cleaner pattern that gives the same guarantee without a callback — the fence-and-helper:

1. Alias the dangerous primitive as private, e.g.
   alias _unguarded_hard_destroy hard_destroy
   private :_unguarded_hard_destroy
2. Override the public name to raise with a pointer to the safe
 method:
   def hard_destroy
     raise 'Use SomeModel#safely_hard_destroy — it keeps X in sync'
   end
3. Expose a `safely_*` helper that does the guard, calls the
 private alias, and runs any side effects, all wrapped in a
 transaction so atomicity is a property of the helper rather
 than of the caller:
   def safely_hard_destroy
     if invariant_would_be_violated?
       errors.add(:base, '...')
       return false
     end
     ApplicationRecord.transaction do
       _unguarded_hard_destroy
       cleanup_side_effects!
     end
     destroyed?
   end

Direct calls to the dangerous primitive now raise with a helpful message; the only legal path is the safe helper. Every existing caller updates to safely_* and inherits the guard automatically.

Bonus property: ad-hoc bypass for ops/debugging is clean. Calling model.send(:_unguarded_hard_destroy) in the Rails console is scoped to the single call (no global state), self-documenting (you have to type "unguarded"), and greppable. This is intended for console / data-cleanup / debugging use only — if app code reaches for send(:_unguarded_*), that's a design smell and should be solved by extending the helper instead.

Inline disable

We have not yet found a case in our codebases where a callback was the right answer — explicit methods or the fence-and-helper pattern have always covered the legitimate intent. If you think you have a genuine exception, disable this cop inline with a written justification — expect pushback in review:

after_create :some_callback # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
# Reason: <explanation>

Both the symbol form and the block form are flagged:

 Symbol form
after_create :send_confirmation_email

 Block form
after_create { send_confirmation_email }

Examples:

# bad
after_create :send_confirmation
before_save :normalize_name
around_update :wrap_in_audit_log

# bad (block form)
after_create { send_confirmation }

# good
def save_with_confirmation
  transaction do
    saved = save
    invoice.save! if saved
  end
  send_confirmation if saved
end

Constant Summary collapse

CALLBACKS =
%i[
  after_create after_create_commit after_save after_update
  after_destroy after_commit after_rollback after_initialize
  after_find after_touch before_create before_save before_update
  before_destroy before_validation after_validation
  around_create around_save around_update around_destroy
].freeze
MSG =
'Avoid `%<method>s` — extract an explicit method (e.g. `save_with_*`) ' \
'so the side effect is visible at the call site.'.freeze
RESTRICT_ON_SEND =
CALLBACKS

Instance Method Summary collapse

Instance Method Details

#on_block(node) ⇒ Object Also known as: on_numblock

rubocop:disable InternalAffairs/NumblockHandler



134
135
136
137
138
139
# File 'lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb', line 134

def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
  send_node = node.send_node
  return unless CALLBACKS.include?(send_node.method_name)

  add_offense(send_node.loc.selector, message: format(MSG, method: send_node.method_name))
end

#on_send(node) ⇒ Object



130
131
132
# File 'lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb', line 130

def on_send(node)
  add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
end