Class: RuboCop::Cop::DevDoc::Rails::NoPersistenceInService
- Inherits:
-
Base
- Object
- Base
- RuboCop::Cop::DevDoc::Rails::NoPersistenceInService
- Defined in:
- lib/rubocop/cop/dev_doc/rails/no_persistence_in_service.rb
Overview
Classes under app/services/ must not persist domain records, open
transactions, or enqueue jobs/mailers.
Rationale
app/services/ is reserved for external-boundary adapters: clients for
third-party APIs, storage backends, and their test doubles. An adapter
talks to an external system and returns data. Anything more is domain
orchestration, and its home routes by what is being orchestrated (see
the orchestration best-practice doc):
- Transactions and record writes are data-saving orchestration. Home:
a domain-named
ActiveModel::ModelPORO underapp/models/(see the model best-practice doc) or a method on the model that owns the data. - Enqueues (jobs, mailer sends) are execution-flow control. Home: the
calling controller or job entry point — ordering is then enforced by
data flow, because the trigger runs only after the domain call
returns post-commit. The one exception: an enqueue inseparable from
a record state change lives in the model behind an explicitly named
notify_*/send_*method with a justified per-line disable ofDevDoc/Rails/NoPerformLaterInModel.
Keeping domain writes out of app/services/ prevents the "fake-generic
service" failure mode: a *Service class with one caller whose generic
name and location advertise reusability it does not have, and whose
existence bypasses the conventions (form-object POROs, model methods,
concerns) the project actually uses for domain logic.
❌
# app/services/orders/publish_service.rb
class Orders::PublishService
def call
Order.transaction do
@order.save!
OrderMailer.with(order: @order).published.deliver_later
end
end
end
✔️
# app/models/orders/publish.rb — domain PORO owns the transaction and writes,
# and RETURNS; it does not enqueue (that would trip NoPerformLaterInModel)
class Orders::Publish
include ActiveModel::Model
def save
Order.transaction { @order.save! }
end
end
# app/controllers/orders_controller.rb — the caller triggers the send;
# it can only run after the PORO's transaction has committed
OrderMailer.with(order: publish.order).published.deliver_later if publish.save
✔️
# app/services/payment_gateway.rb — adapter; external calls only
class PaymentGateway
def charge(amount_cents)
@client.post('/charges', amount: amount_cents)
end
end
Configurable blocklist for write wrappers
A service can hide its writes behind a model method that persists
internally (e.g. an add_member_if_missing-style helper). The cop
cannot see through arbitrary methods; register the known ones via
KnownWriteWrappers so those sends flag too. This is a partial
mitigation — reviewers must still catch unregistered wrappers.
NOTE: Two residuals the cop cannot catch — reviewers own both:
- A service whose writes all go through wrapper methods not listed in
KnownWriteWrappers. - Read-only domain logic parked in
app/services/(queries, catalogs, pure utilities). Nothing persists, so nothing flags — but its home is stillapp/models/orlib/.Hash#delete/Array#insertcollisions are why baredelete/insertare not in the tracked list;delete_all/insert_allare. BareupdateIS tracked despiteHash#update(amerge!alias): ActiveRecordupdateis a far more common sight in a service than a Hash mutation, so the rare Hash collision is accepted (disable inline with a reason) rather than losing detection ofrecord.update.
Constant Summary collapse
- MSG =
Two messages, routed per the orchestration taxonomy: persistence and transactions move to the model layer; enqueues move to the caller.
'`%<method>s` in app/services — services are external-boundary ' \ 'adapters and must not persist domain records or enqueue jobs. ' \ 'Move this into a domain object under app/models.'.freeze
- ENQUEUE_MSG =
'`%<method>s` in app/services — services are external-boundary ' \ 'adapters and must not enqueue jobs or send mail. Return data and ' \ 'let the calling controller or job trigger the send.'.freeze
- TRANSACTION_METHODS =
with_lockopens a transaction too (see NoDeliverLaterInTransaction); an enqueue/write inside it is the same violation in a service. %i[transaction with_lock].freeze
- PERSISTENCE_METHODS =
ActiveRecord persistence. Non-bang forms and the find-or-create family are included (an observed violation used find_or_create_by!).
deleteandinsertare deliberately ABSENT: Hash#delete / Array#insert are common enough to flood false positives;delete_all/insert_allremain. %i[ save save! create create! update update! update_all update_column update_columns update_attribute destroy destroy! destroy_all delete_all insert_all insert_all! upsert upsert_all find_or_create_by find_or_create_by! create_or_find_by create_or_find_by! first_or_create first_or_create! touch increment! decrement! toggle! ].freeze
- ENQUEUE_METHODS =
Enqueues/sends — mirror NoDeliverLaterInTransaction::CORE_METHODS, plus deliver_now (an adapter must not send mail synchronously either).
%i[ deliver_later deliver_later! deliver_now perform_later perform_all_later enqueue ].freeze
- TRACKED_METHODS =
Union of the three fixed lists, pre-computed once for O(1) membership. KnownWriteWrappers (config) are matched separately as strings — see tracked_method?.
(TRANSACTION_METHODS + PERSISTENCE_METHODS + ENQUEUE_METHODS).freeze
Instance Method Summary collapse
-
#on_send(node) ⇒ Object
(also: #on_csend)
Config-driven (KnownWriteWrappers), so do NOT use RESTRICT_ON_SEND (CONTRIBUTING documents this exact exception).
Instance Method Details
#on_send(node) ⇒ Object Also known as: on_csend
Config-driven (KnownWriteWrappers), so do NOT use RESTRICT_ON_SEND (CONTRIBUTING documents this exact exception). Filter inside on_send, mirroring NoDeliverLaterInTransaction#tracked_method?.
141 142 143 144 145 146 147 |
# File 'lib/rubocop/cop/dev_doc/rails/no_persistence_in_service.rb', line 141 def on_send(node) name = node.method_name return unless tracked_method?(name) msg = ENQUEUE_METHODS.include?(name) ? ENQUEUE_MSG : MSG add_offense(node.loc.selector, message: format(msg, method: name)) end |