Class: RuboCop::Cop::DevDoc::Rails::NoTransactionInController
- Inherits:
-
Base
- Object
- Base
- RuboCop::Cop::DevDoc::Rails::NoTransactionInController
- Defined in:
- lib/rubocop/cop/dev_doc/rails/no_transaction_in_controller.rb
Overview
Controllers must not open transactions (transaction / with_lock);
a transaction boundary belongs in the model layer.
Rationale
A transaction boundary encodes a domain invariant: which records must
land together for the data to stay consistent. That knowledge belongs
to the layer that owns the data — an ActiveRecord model method or a
domain ActiveModel::Model PORO under app/models/ (see the model
and orchestration best-practice docs). The controller owns request
concerns: param binding, authorization, and deciding when to
trigger the operation — not what must commit atomically.
with_lock opens a transaction too (see
DevDoc/Rails/NoDeliverLaterInTransaction), so it is tracked as well.
❌
# app/controllers/orders_controller.rb
def create
ApplicationRecord.transaction do
@order.save!
@order.line_items.create!(line_item_rows)
end
end
✔️
# app/controllers/orders_controller.rb — one call; the model owns the boundary
def create
@order.assign_attributes(create_params)
@order.save_with_line_items!(line_item_rows)
end
Relationship with DevDoc/Rails/ApplicationRecordTransaction
That cop governs the spelling of transactions in the places they
remain legitimate outside app/models/ (jobs, rake tasks); this cop
removes controllers from that set entirely. Once the write sequence
moves under app/models/, that cop's app/models exclusion applies
and SomeModel.transaction is the sanctioned spelling again.
Exception
Jobs and rake tasks are asynchronous entry points and may open
transactions (the job best-practice doc assumes idempotent writes);
they are out of scope via Include. When adopting this cop on an
existing codebase with a backlog, pin it off by name with a measured
backlog comment and schedule the migration as its own wave.
NOTE: Limitations — reviewers own both:
- A transaction opened inside a non-model helper the controller calls
(e.g. a
lib/utility wrapping a block in a transaction) is invisible to this cop. - Receiver-agnostic matching means a domain reader genuinely named
transaction(e.g.payment.transactionreturning an associated record) false-positives — disable inline with a reason.
Constant Summary collapse
- MSG =
'`%<method>s` in a controller — a transaction boundary is data-saving ' \ 'orchestration and belongs to the layer that owns the domain invariant. Move the ' \ 'write sequence into a model method or domain PORO under app/models.'.freeze
- RESTRICT_ON_SEND =
Fixed two-method set, so RESTRICT_ON_SEND applies (unlike the config-driven NoPersistenceInService). Receiver-agnostic: any receiver spelling (
ApplicationRecord.,SomeModel., bare) opens the same boundary. %i[transaction with_lock].freeze
Instance Method Summary collapse
- #on_send(node) ⇒ Object (also: #on_csend)
Instance Method Details
#on_send(node) ⇒ Object Also known as: on_csend
81 82 83 |
# File 'lib/rubocop/cop/dev_doc/rails/no_transaction_in_controller.rb', line 81 def on_send(node) add_offense(node.loc.selector, message: format(MSG, method: node.method_name)) end |