Class: RuboCop::Cop::DevDoc::Rails::NoTransactionInController

Inherits:
Base
  • Object
show all
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.transaction returning an associated record) false-positives — disable inline with a reason.

Examples:

# bad
ApplicationRecord.transaction do
  order.save!
  order.line_items.create!(rows)
end

# bad
member.with_lock { member.upgrade! }

# good — the model or domain PORO owns the transaction
order.save_with_line_items!(rows)

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

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