Class: RuboCop::Cop::DevDoc::Rails::AvoidRailsCallbacks
- Inherits:
-
Base
- Object
- Base
- RuboCop::Cop::DevDoc::Rails::AvoidRailsCallbacks
- 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
save!
OrderMailer.confirmation(self).deliver_later
end
end
end
## 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 }
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
-
#on_block(node) ⇒ Object
(also: #on_numblock)
rubocop:disable InternalAffairs/NumblockHandler.
- #on_send(node) ⇒ Object
Instance Method Details
#on_block(node) ⇒ Object Also known as: on_numblock
rubocop:disable InternalAffairs/NumblockHandler
124 125 126 127 128 129 |
# File 'lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb', line 124 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
120 121 122 |
# File 'lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb', line 120 def on_send(node) add_offense(node.loc.selector, message: format(MSG, method: node.method_name)) end |