Ruby Rails Patterns
When to Use
Use this skill when:
- User asks about structuring Rails services, query objects, form objects, presenters, or decorators
- User wants to reduce fat models or fat controllers by extracting business logic into dedicated objects
- User needs to implement background job patterns, event-driven architecture, or pub/sub within a Rails app
- User asks about organizing domain logic across a large Rails monolith or preparing for a modular monolith or service extraction
- User wants guidance on Rails conventions like concerns, callbacks, scopes, and when to break away from them in favor of explicit patterns
- User is asking about testing patterns for Rails -- factories, stubs, integration specs, and avoiding brittle test suites
- User wants to implement multi-tenancy, soft deletes, auditing, or state machine patterns in Rails
Do NOT use this skill when:
- User needs help with Rails API design or REST/GraphQL endpoint structure -- check the api-design skill in the backend subcategory
- User is asking about Rails performance tuning or N+1 query elimination -- check the database-query-optimization skill
- User needs frontend architecture patterns (Stimulus, Hotwire, ViewComponent composition) -- check the frontend-rails skill
- User is asking about Ruby language features unrelated to Rails patterns (metaprogramming, Fibers, Ractors) -- check the ruby-language skill
- User needs deployment, containerization, or CI/CD for Rails -- check the rails-devops skill
- User wants authentication/authorization implementation specifics -- check the auth-patterns skill
- User is asking about a non-Rails Ruby framework (Sinatra, Hanami, Grape) -- those have distinct patterns not covered here
Process
1. Diagnose the Architectural Problem
Before recommending any pattern, identify the specific pain point:
- Fat model symptoms: A model file exceeding 300 lines, more than 10 callbacks, more than 20 methods, or methods that touch external services (mailers, HTTP clients, payment gateways)
- Fat controller symptoms: Action methods exceeding 15 lines, controller tests requiring complex setup, or the same logic duplicated across multiple actions or controllers
- Coupling symptoms: A test file that requires 5+
let blocks and multiple factories just to set up one object, indicating too many responsibilities
- Leaky abstraction symptoms: Views directly calling model methods that trigger database queries, or presentational logic living in models
- Ask the user: What is the size of the app (number of models, monthly active users, team size)? This determines which patterns are justified -- a 5-model CRUD app does not need a full service layer.
2. Select the Right Pattern for the Problem
Apply this decision framework based on the nature of the logic being extracted:
- Service Objects (Command pattern): Use when an action involves multiple models, external calls, or side effects. The canonical rule: if you would name the method
process_, create_, update_, or sync_, it belongs in a service object. Naming convention: Users::CreateService, Orders::FulfillService.
- Query Objects: Use when a scope or named query grows beyond two chained ActiveRecord methods, requires joins across 3+ tables, or needs to be reused across multiple controllers. Naming:
Users::ActiveInTrialQuery, Orders::OverdueQuery.
- Form Objects (with ActiveModel): Use when a form submission touches more than one model, requires custom validations not tied to persistence, or needs to represent a multi-step wizard step. Back each form object with
include ActiveModel::Model and include ActiveModel::Attributes.
- Presenters / Decorators: Use when view logic requires conditional formatting, nil-safe chaining, or computed display attributes. Use Draper-style decoration or plain POROs (Plain Old Ruby Objects) -- prefer POROs unless you need collection decoration.
- Domain Events / Pub-Sub: Use when an action triggers more than 2 unrelated side effects (e.g., user signs up → send email + create trial + notify Slack). Use
ActiveSupport::Notifications or a lightweight EventBus module rather than adding callback chains.
3. Design the Service Object Interface
The service object is the most widely applicable pattern. Follow this interface contract:
- Class method
.call: Every service object exposes a single class-level entry point: Users::CreateService.call(params:, current_user:). This makes it testable without instantiation boilerplate in specs.
- Return a Result object, not
true/false: Define a simple Result = Struct.new(:success?, :payload, :errors) or use the dry-monads gem's Result type for complex flows. Never return raw model instances from services -- callers should not need to inspect the model's errors array.
- Keep the
#call instance method under 20 lines. If it exceeds this, extract sub-steps into private methods with descriptive names: #create_user, #send_welcome_email, #provision_subscription.
- Wrap multi-step operations in a transaction: Use
ActiveRecord::Base.transaction with raise ActiveRecord::Rollback on failure. Do not rescue StandardError inside a transaction block -- it silences legitimate errors.
- Do not inject the repository (AR model) unless testing demands it. For most Rails apps, calling
User.create! directly inside a service is acceptable and less over-engineered than full repository injection.
4. Implement Query Objects
Query objects keep controllers thin and scopes from accumulating in models:
- Inherit from a base class or include a module that provides
#call and returns an ActiveRecord::Relation: the return type must always be a relation, never an array, so callers can chain further.
- Accept optional parameters through the constructor:
Users::ActiveInTrialQuery.new(plan: :growth, days_remaining: 7).call
- Avoid using
pluck or to_a inside the query object -- leave materialization to the caller.
- Co-locate specs in
spec/queries/ and test with real database records (use database_cleaner with truncation strategy for query specs, not transaction strategy, when testing complex joins).
- Compose queries using the merge pattern:
User.where(active: true).merge(Subscription::ExpiringQuery.new.call) -- this keeps the interface chainable.
5. Structure Form Objects and Validations
Form objects decouple your validation logic from persistence:
- Use
include ActiveModel::Model and include ActiveModel::Validations -- do NOT use ActiveRecord inheritance for form objects.
- Define attributes explicitly with
attribute :email, :string using ActiveModel::Attributes -- this gives you type coercion and prevents mass-assignment surprises.
- Implement
#save (not #persist) as the public mutation method: it runs valid? first and returns false on failure, mirroring the AR interface that views and controllers already understand.
- For multi-step wizards, use one form object per step and store intermediate state in the session or a
WizardSession service backed by a wizard_sessions table with a JSON payload column.
- Validate cross-field constraints in form objects, not in model callbacks -- this prevents the infamous "valid? returns false but I only changed one attribute" bug.
6. Apply the Pub/Sub Pattern for Side Effects
Replace callback chains with explicit event dispatch:
- Define a central
EventBus module using ActiveSupport::Notifications as the backend:
module EventBus
def self.publish(event_name, payload = {})
ActiveSupport::Notifications.instrument("app.#{event_name}", payload)
end
def self.subscribe(event_name, &block)
ActiveSupport::Notifications.subscribe("app.#{event_name}") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
block.call(event.payload)
end
end
end
- Register subscribers in initializers (
config/initializers/event_subscribers.rb), not in models or controllers.
- Name events in past tense:
user.registered, order.fulfilled, subscription.cancelled. This communicates that the event describes something that already happened.
- Each subscriber handles exactly one concern. Never put conditionals inside a subscriber that route to different behaviors -- create separate subscribers.
- For async side effects (sending emails, calling webhooks), have the subscriber enqueue a Sidekiq job rather than performing the work inline. This keeps the request-response cycle fast and makes the side effect retriable.
7. Organize Code with Modules and the Modular Monolith Pattern
As apps grow beyond ~20 models, namespace and encapsulate domains:
- Use Rails engines or Ruby modules to create bounded contexts:
Billing::, Identity::, Inventory::, Fulfillment::. Each namespace owns its models, services, queries, and mailers.
- Enforce boundaries using the
packwerk gem: define package.yml files per domain package and run packwerk check in CI to prevent cross-package constant references that violate the dependency graph.
- Place shared infrastructure (ApplicationRecord, ApplicationMailer, ApplicationJob base classes) in an
app/ top-level that all packages can reference.
- Set a rule: packages may depend on shared infrastructure and on sibling packages only through explicitly published APIs (service objects or query objects), never by reaching into another package's models directly.
- Use
zeitwerk naming conventions strictly -- file paths must match constant paths or autoloading will fail silently in production.
8. Validate and Test the Pattern Implementation
Each pattern has a specific testing strategy:
- Service objects: Test with unit specs in
spec/services/. Test the .call interface directly. Assert on the returned Result object, not on model state. Test failure paths explicitly -- do not just test the happy path.
- Query objects: Test with integration specs against a real database. Use
FactoryBot to create records matching and not matching the query criteria. Assert on the collection returned, not on SQL strings.
- Form objects: Test validations directly: instantiate with invalid params, call
valid?, and assert on errors. Test #save with a database transaction that rolls back after each spec.
- Event/subscriber patterns: Test that the correct events are published using
expect(EventBus).to receive(:publish).with("user.registered", ...). Test subscribers in isolation by calling the handler block directly.
- Target: service object specs should run in under 0.1 seconds each (no Rails boot if using
--require spec_helper only). Query specs will be slower (DB) -- isolate them so the fast suite stays fast.
Output Format
When advising a user on a Rails pattern, structure output as follows:
## Pattern Recommendation: [Pattern Name]
### Problem Diagnosed
[1-3 sentences describing the specific anti-pattern or pain point being addressed]
### Recommended Pattern: [e.g., Service Object + Result Type]
### Interface Contract
[Method signatures and return types]
### Implementation
```ruby
# File: app/services/[namespace]/[name]_service.rb
# [Complete, runnable code -- not pseudocode]
Usage in Controller
# File: app/controllers/[controller].rb
# [How the controller delegates to the pattern]
Spec Template
# File: spec/services/[namespace]/[name]_service_spec.rb
# [Minimal but complete test coverage showing happy path + failure path]
Trade-offs
| Concern |
This Pattern |
Alternative |
| Testability |
High -- isolated unit |
Lower -- requires AR |
| Complexity |
Moderate |
Low (fat model) |
| Team familiarity |
Requires ramp-up |
Familiar Rails idiom |
| LOC overhead |
~30 lines/service |
0 (inline in model) |
When to Revisit
[Specific signal that means this pattern is no longer the right fit]
---
## Rules
1. **Never use `before_save` or `after_create` callbacks for business logic.** Callbacks are appropriate only for data normalization (e.g., `before_validation :strip_email_whitespace`) or cache invalidation. Business logic in callbacks is invisible to callers, fires in unexpected contexts (seeds, fixtures, console operations), and makes testing nightmarish. Extract to a service object instead.
2. **Never return `nil` from a service object.** Always return a Result object or raise a typed exception. Callers that receive `nil` from a service resort to `nil` checks scattered through the codebase, which is indistinguishable from a missing value vs. a failed operation.
3. **Never define more than 3 named scopes on a model that involve joins.** Scopes with joins leak SQL into model definitions and compose poorly. When you have 3+ join-heavy scopes, extract them all into a Query Object. Simple attribute scopes (`scope :active, -> { where(active: true) }`) are acceptable to keep on the model.
4. **Never put conditional presentation logic in model methods.** Methods like `def display_name; full_name.present? ? full_name : email; end` belong in a presenter or decorator, not the model. Models accumulate these methods and eventually return HTML strings or depend on view helpers -- a serious SRP violation.
5. **Never define a service object that accepts an ActiveRecord model instance and then calls `.save` on it.** This makes the service object responsible for persistence in an opaque way. Either the service creates/updates via the model's class methods (and owns the full lifecycle) or it only contains logic and the controller/caller handles persistence.
6. **Never skip the `valid?` check before calling `.save` in a form object.** Many form object implementations call `#save` and only surface errors after a failed DB write. Always validate first: `return false unless valid?` as the first line of `#save`.
7. **Always namespace service objects by domain, not by action type.** `Services::CreateUser` is worse than `Users::CreateService` -- the former creates a flat `Services/` directory of hundreds of files, the latter groups related operations and scales as the domain grows.
8. **Never use `rescue Exception`** inside a service object or anywhere in application code. This catches `SignalException` (Ctrl-C), `NoMemoryError`, and `SystemExit`, which will cause processes to hang or data to corrupt. Always rescue specific exception classes.
9. **Always make Query Objects return an `ActiveRecord::Relation`, never an Array.** Returning an array breaks chainability, forces eager loading at the wrong time, and prevents callers from adding pagination (`.page(1).per(25)`), ordering, or additional scopes without rewriting the query.
10. **When adding the pub/sub pattern to an existing app, start with one domain only.** Attempting to refactor all callbacks to events in one pass is a high-risk big-bang migration. Pick the domain with the most callback pain (typically `User` or `Order`), migrate it, run in production for 2 weeks, then expand. This gives you real evidence of failure modes before they spread.
---
## Edge Cases
### Fat Model with Deep Callback Chains
When a model has 8+ callbacks, removing them safely requires a specific sequence to avoid regressions:
- First, audit every callback with `grep -n "after_\|before_\|around_" app/models/user.rb` and document what each one does and when it fires.
- Add integration specs that cover the side effects of each callback BEFORE making any changes -- these become your regression suite.
- Extract callbacks one at a time into service objects. For `after_create` callbacks, wrap the creation call in a service: the service calls `User.create!` and then performs the side effect explicitly after the record is saved.
- For `before_validation` callbacks used for normalization, keep them -- this is their legitimate use case.
- Do NOT remove a callback from the model until the service object is the only code path that creates or updates that model in production. If seeds, console scripts, or other services also touch the model, the side effect will silently stop firing.
### Multi-Tenancy Scoping
When implementing multi-tenancy alongside service objects and query objects:
- Tenant scope should be injected as a parameter, not inferred from `Current.tenant` inside the service. Services that read from `Current.*` thread-local state are untestable in concurrent contexts and fail silently when called from background jobs (where `Current` is not set).
- Pattern: `Orders::FulfillService.call(order_id: id, tenant: current_tenant)`. The service uses `tenant.orders.find(order_id)` to scope its queries, never `Order.find(order_id)`.
- Add a base query object that all tenant-scoped queries inherit from: it accepts `tenant:` in the constructor and always prepends `.where(tenant_id: tenant.id)` to any relation it returns.
- In specs, always test that a service object raises or returns an error when given an order belonging to a different tenant. This is the most common multi-tenancy security regression.
### State Machine Complexity
When a model has more than 3 lifecycle states and transitions:
- Do not implement state transitions as plain string attribute updates. Use the `state_machines-activerecord` or `aasm` gem -- but encapsulate ALL transition calls inside service objects, never call `order.fulfill!` directly from controllers.
- Define guard clauses as separate predicate methods on the model (`def can_fulfill?`) and test them independently. Do not embed complex conditionals inside the state machine's `guard:` lambda.
- When a transition fails (guard not met), the gem raises an exception. Catch the specific exception in the service object and return a failure Result with a user-friendly message. Never let state machine exceptions propagate to the controller unhandled.
- For state history/audit trail, use the `paper_trail` gem with `only:` limited to the state column -- do not track every attribute if your model has 30+ columns; it creates enormous `versions` table bloat (the `object` JSON column stores the full serialized record).
### Background Job Integration with Service Objects
When service logic needs to run asynchronously:
- Never pass an ActiveRecord object to a Sidekiq job as an argument. Pass the ID only: `Users::WelcomeEmailJob.perform_later(user_id: user.id)`. The job re-fetches the record. Passing an AR object serializes it to YAML (legacy) or JSON, which contains stale data and causes subtle bugs.
- The job's `#perform` method should contain one line: instantiate and call the service object. Keep all logic in the service, not in `#perform`. This makes the service testable without an ActiveJob harness.
- For jobs that should run at most once per record per event (idempotency), add a `processed_at` timestamp or use a Redis-based lock with `redis-mutex` or Sidekiq Enterprise's unique jobs. Without idempotency, retry logic will double-send emails or double-charge payments.
- Set queue depths and timeouts explicitly. A service that calls an external API should have a job timeout of 30 seconds maximum -- without it, a slow API will exhaust your worker pool.
### Extracting to a Modular Monolith Mid-Project
When refactoring an existing flat Rails app into bounded contexts:
- Do not move files to new directories on the first pass. First, add the namespace constant only (rename `User` to `Identity::User` with `require` aliases) and verify the app boots and tests pass. Moving files and renaming constants simultaneously doubles the merge conflict surface area.
- Use `packwerk` in "check" mode (non-blocking) for the first 4 weeks to identify violations without breaking CI. Only switch to blocking mode after you have resolved more than 80% of existing violations.
- Create package boundaries based on team ownership, not just conceptual grouping. If the same 2 engineers own billing and subscriptions, putting them in separate packages creates unnecessary API overhead with no organizational benefit.
- Define a "strangler fig" migration: new features are always built inside packages, existing code is only moved when it needs to be modified for other reasons. This avoids the "big refactor sprint" that never gets prioritized.
### Presenter and Decorator Performance
When decorating collections in views:
- Never call `collection.map { |item| ItemPresenter.new(item) }` inside a view -- this allocates N presenter objects and is not lazy. Use a `CollectionPresenter` or decorate lazily with Enumerator.
- If using Draper, be aware that `User.all.decorate` calls `to_a` on the relation, loading all records into memory. Always paginate before decorating: `User.page(1).per(25).decorate`.
- Presenters that call additional database queries (e.g., `def related_orders; user.orders.recent.limit(5); end`) cause N+1 problems when rendered in a list. Either eager-load the association before decoration or use a specific query object that batch-loads for the collection.
- Benchmark decorator overhead: in a response rendering 100 decorated objects with 10 presenter methods each, you are making 1000 method calls per request. Profile with `rack-mini-profiler` before assuming this is acceptable at scale.
---
## Example
**Input:** "Our `Order` model is 450 lines long. It has `after_create` callbacks for sending confirmation emails and creating an audit log entry, `before_save` for recalculating the total, and a `process_payment!` method that calls Stripe. Our controller's `create` action is also 40 lines. How do we clean this up?"
**Output:**
## Pattern Recommendation: Service Object + Query Object + Pub/Sub Event
### Problem Diagnosed
The `Order` model has at least three distinct responsibilities: persistence and domain logic, payment processing (external I/O), and triggering side effects (email, audit). The fat controller is a symptom of the model not providing a clean service interface. The `before_save` recalculation callback will fire during seeds, console operations, and test factories -- likely causing test flakiness already.
---
### Step 1 -- Extract `Orders::FulfillService`
```ruby
# app/services/orders/fulfill_service.rb
module Orders
class FulfillService
Result = Struct.new(:success?, :order, :errors, keyword_init: true)
def self.call(order_params:, user:)
new(order_params: order_params, user: user).call
end
def initialize(order_params:, user:)
@order_params = order_params
@user = user
end
def call
ActiveRecord::Base.transaction do
order = build_order
return failure(order.errors.full_messages) unless order.save
charge = process_payment(order)
return failure([charge.error_message]) unless charge.success?
order.update!(
stripe_charge_id: charge.id,
fulfilled_at: Time.current
)
EventBus.publish("order.fulfilled", order_id: order.id, user_id: @user.id)
Result.new(success?: true, order: order, errors: [])
end
rescue Stripe::CardError => e
Result.new(success?: false, order: nil, errors: [e.message])
end
private
def build_order
Order.new(@order_params.merge(user: @user, total: calculate_total))
end
def calculate_total
# Moved from before_save -- called explicitly, not via callback
@order_params[:line_items].sum { |item| item[:quantity] * item[:unit_price] }
end
def process_payment(order)
Stripe::PaymentProcessor.charge(
amount: (order.total * 100).to_i, # Stripe expects cents
currency: "usd",
customer: @user.stripe_customer_id,
description: "Order ##{order.id}"
)
end
def failure(messages)
raise ActiveRecord::Rollback
Result.new(success?: false, order: nil, errors: messages)
end
end
end
Step 2 -- Replace Callbacks with Event Subscribers
Remove the after_create callbacks from Order and register subscribers:
# config/initializers/event_subscribers.rb
EventBus.subscribe("order.fulfilled") do |payload|
Orders::SendConfirmationEmailJob.perform_later(order_id: payload[:order_id])
end
EventBus.subscribe("order.fulfilled") do |payload|
Audit::LogOrderFulfillmentJob.perform_later(
order_id: payload[:order_id],
user_id: payload[:user_id]
)
end
Each job delegates to a dedicated service:
# app/jobs/orders/send_confirmation_email_job.rb
class Orders::SendConfirmationEmailJob < ApplicationJob
queue_as :default
sidekiq_options retry: 3, dead: false
def perform(order_id:)
order = Order.find(order_id)
OrderMailer.confirmation(order).deliver_now
end
end
Step 3 -- Slim the Controller
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
result = Orders::FulfillService.call(
order_params: order_params,
user: current_user
)
if result.success?
redirect_to order_path(result.order), notice: "Order placed successfully."
else
flash.now[:alert] = result.errors.join(", ")
render :new, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(:shipping_address, line_items: [:product_id, :quantity, :unit_price])
end
end
Controller is now 18 lines. It knows nothing about Stripe, email, or audit logging.
Step 4 -- Remove the before_save Callback from the Model
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
has_many :line_items
# REMOVED: before_save :recalculate_total
# Total is now calculated explicitly in Orders::FulfillService#calculate_total
# and in Orders::UpdateService when an order is modified post-creation.
validates :total, numericality: { greater_than: 0 }
validates :user, presence: true
end
The model drops to a clean ~50 lines: associations, validations, and nothing else.
Spec Template
# spec/services/orders/fulfill_service_spec.rb
RSpec.describe Orders::FulfillService do
let(:user) { create(:user, stripe_customer_id: "cus_test123") }
let(:product) { create(:product, price: 29.99) }
let(:order_params) do
{
shipping_address: "123 Main St",
line_items: [{ product_id: product.id, quantity: 2, unit_price: 29.99 }]
}
end
describe ".call" do
context "when payment succeeds" do
before do
allow(Stripe::PaymentProcessor).to receive(:charge).and_return(
double(success?: true, id: "ch_test456", error_message: nil)
)
end
it "returns a successful result" do
result = described_class.call(order_params: order_params, user: user)
expect(result.success?).to be true
expect(result.order).to be_persisted
end
it "sets the total correctly" do
result = described_class.call(order_params: order_params, user: user)
expect(result.order.total).to eq(59.98) # 2 * 29.99
end
it "publishes the order.fulfilled event" do
expect(EventBus).to receive(:publish).with("order.fulfilled", hash_including(:order_id))
described_class.call(order_params: order_params, user: user)
end
end
context "when payment fails" do
before do
allow(Stripe::PaymentProcessor).to receive(:charge)
.and_raise(Stripe::CardError.new("Your card was declined.", nil))
end
it "returns a failure result" do
result = described_class.call(order_params: order_params, user: user)
expect(result.success?).to be false
expect(result.errors).to include("Your card was declined.")
end
it "does not persist the order" do
expect {
described_class.call(order_params: order_params, user: user)
}.not_to change(Order, :count)
end
it "does not publish events" do
expect(EventBus).not_to receive(:publish)
described_class.call(order_params: order_params, user: user)
end
end
end
end
Trade-offs
| Concern |
Service Object + Events |
Original Fat Model |
| Testability |
High -- Stripe is stubbed, no DB needed for unit |
Low -- requires real AR, callbacks fire unexpectedly |
| Controller LOC |
18 lines |
40+ lines |
| Side effect visibility |
Explicit in initializer |
Hidden in callback chain |
| Onboarding clarity |
Read service to understand flow |
Must trace callbacks across file |
| Complexity overhead |
~80 LOC across 3 new files |
0 new files (accumulates in place) |
| Transaction safety |
Explicit rollback on payment failure |
Depends on callback order |
When to Revisit
If Orders::FulfillService grows beyond 80 lines, extract payment processing into Payments::ChargeService and have FulfillService call it. If the number of order.fulfilled subscribers exceeds 6, consider switching from ActiveSupport::Notifications to a proper message queue (Sidekiq's batch callbacks or a dedicated EventStore) to get retry guarantees and observability per subscriber independently.
1---2name: ruby-rails-patterns3description: Guides expert-level ruby on rails patterns implementation: ruby and frameworks decision frameworks, production-ready patterns, and concrete templates for ruby rails patterns workflows. Use when the user asks about ruby on rails patterns, ruby rails patterns configuration, or ruby best practices for ruby projects. Do NOT use when the user needs a different languages runtimes capability -- check sibling skills in the languages runtimes subcategory.4license: Apache-2.05---6# Ruby Rails Patterns78## When to Use910**Use this skill when:**11- User asks about structuring Rails services, query objects, form objects, presenters, or decorators12- User wants to reduce fat models or fat controllers by extracting business logic into dedicated objects13- User needs to implement background job patterns, event-driven architecture, or pub/sub within a Rails app14- User asks about organizing domain logic across a large Rails monolith or preparing for a modular monolith or service extraction15- User wants guidance on Rails conventions like concerns, callbacks, scopes, and when to break away from them in favor of explicit patterns16- User is asking about testing patterns for Rails -- factories, stubs, integration specs, and avoiding brittle test suites17- User wants to implement multi-tenancy, soft deletes, auditing, or state machine patterns in Rails1819**Do NOT use this skill when:**20- User needs help with Rails API design or REST/GraphQL endpoint structure -- check the api-design skill in the backend subcategory21- User is asking about Rails performance tuning or N+1 query elimination -- check the database-query-optimization skill22- User needs frontend architecture patterns (Stimulus, Hotwire, ViewComponent composition) -- check the frontend-rails skill23- User is asking about Ruby language features unrelated to Rails patterns (metaprogramming, Fibers, Ractors) -- check the ruby-language skill24- User needs deployment, containerization, or CI/CD for Rails -- check the rails-devops skill25- User wants authentication/authorization implementation specifics -- check the auth-patterns skill26- User is asking about a non-Rails Ruby framework (Sinatra, Hanami, Grape) -- those have distinct patterns not covered here2728---2930## Process3132### 1. Diagnose the Architectural Problem3334Before recommending any pattern, identify the specific pain point:3536- **Fat model symptoms:** A model file exceeding 300 lines, more than 10 callbacks, more than 20 methods, or methods that touch external services (mailers, HTTP clients, payment gateways)37- **Fat controller symptoms:** Action methods exceeding 15 lines, controller tests requiring complex setup, or the same logic duplicated across multiple actions or controllers38- **Coupling symptoms:** A test file that requires 5+ `let` blocks and multiple factories just to set up one object, indicating too many responsibilities39- **Leaky abstraction symptoms:** Views directly calling model methods that trigger database queries, or presentational logic living in models40- Ask the user: What is the size of the app (number of models, monthly active users, team size)? This determines which patterns are justified -- a 5-model CRUD app does not need a full service layer.4142### 2. Select the Right Pattern for the Problem4344Apply this decision framework based on the nature of the logic being extracted:4546- **Service Objects (Command pattern):** Use when an action involves multiple models, external calls, or side effects. The canonical rule: if you would name the method `process_`, `create_`, `update_`, or `sync_`, it belongs in a service object. Naming convention: `Users::CreateService`, `Orders::FulfillService`.47- **Query Objects:** Use when a scope or named query grows beyond two chained ActiveRecord methods, requires joins across 3+ tables, or needs to be reused across multiple controllers. Naming: `Users::ActiveInTrialQuery`, `Orders::OverdueQuery`.48- **Form Objects (with ActiveModel):** Use when a form submission touches more than one model, requires custom validations not tied to persistence, or needs to represent a multi-step wizard step. Back each form object with `include ActiveModel::Model` and `include ActiveModel::Attributes`.49- **Presenters / Decorators:** Use when view logic requires conditional formatting, nil-safe chaining, or computed display attributes. Use Draper-style decoration or plain POROs (Plain Old Ruby Objects) -- prefer POROs unless you need collection decoration.50- **Domain Events / Pub-Sub:** Use when an action triggers more than 2 unrelated side effects (e.g., user signs up → send email + create trial + notify Slack). Use `ActiveSupport::Notifications` or a lightweight `EventBus` module rather than adding callback chains.5152### 3. Design the Service Object Interface5354The service object is the most widely applicable pattern. Follow this interface contract:5556- **Class method `.call`:** Every service object exposes a single class-level entry point: `Users::CreateService.call(params:, current_user:)`. This makes it testable without instantiation boilerplate in specs.57- **Return a Result object, not `true/false`:** Define a simple `Result = Struct.new(:success?, :payload, :errors)` or use the `dry-monads` gem's `Result` type for complex flows. Never return raw model instances from services -- callers should not need to inspect the model's `errors` array.58- **Keep the `#call` instance method under 20 lines.** If it exceeds this, extract sub-steps into private methods with descriptive names: `#create_user`, `#send_welcome_email`, `#provision_subscription`.59- **Wrap multi-step operations in a transaction:** Use `ActiveRecord::Base.transaction` with `raise ActiveRecord::Rollback` on failure. Do not rescue `StandardError` inside a transaction block -- it silences legitimate errors.60- **Do not inject the repository (AR model) unless testing demands it.** For most Rails apps, calling `User.create!` directly inside a service is acceptable and less over-engineered than full repository injection.6162### 4. Implement Query Objects6364Query objects keep controllers thin and scopes from accumulating in models:6566- Inherit from a base class or include a module that provides `#call` and returns an ActiveRecord::Relation: the return type must always be a relation, never an array, so callers can chain further.67- Accept optional parameters through the constructor: `Users::ActiveInTrialQuery.new(plan: :growth, days_remaining: 7).call`68- Avoid using `pluck` or `to_a` inside the query object -- leave materialization to the caller.69- Co-locate specs in `spec/queries/` and test with real database records (use `database_cleaner` with truncation strategy for query specs, not transaction strategy, when testing complex joins).70- Compose queries using the merge pattern: `User.where(active: true).merge(Subscription::ExpiringQuery.new.call)` -- this keeps the interface chainable.7172### 5. Structure Form Objects and Validations7374Form objects decouple your validation logic from persistence:7576- Use `include ActiveModel::Model` and `include ActiveModel::Validations` -- do NOT use `ActiveRecord` inheritance for form objects.77- Define attributes explicitly with `attribute :email, :string` using `ActiveModel::Attributes` -- this gives you type coercion and prevents mass-assignment surprises.78- Implement `#save` (not `#persist`) as the public mutation method: it runs `valid?` first and returns `false` on failure, mirroring the AR interface that views and controllers already understand.79- For multi-step wizards, use one form object per step and store intermediate state in the session or a `WizardSession` service backed by a `wizard_sessions` table with a JSON payload column.80- Validate cross-field constraints in form objects, not in model callbacks -- this prevents the infamous "valid? returns false but I only changed one attribute" bug.8182### 6. Apply the Pub/Sub Pattern for Side Effects8384Replace callback chains with explicit event dispatch:8586- Define a central `EventBus` module using `ActiveSupport::Notifications` as the backend:8788```ruby89module EventBus90 def self.publish(event_name, payload = {})91 ActiveSupport::Notifications.instrument("app.#{event_name}", payload)92 end9394 def self.subscribe(event_name, &block)95 ActiveSupport::Notifications.subscribe("app.#{event_name}") do |*args|96 event = ActiveSupport::Notifications::Event.new(*args)97 block.call(event.payload)98 end99 end100end101```102103- Register subscribers in initializers (`config/initializers/event_subscribers.rb`), not in models or controllers.104- Name events in past tense: `user.registered`, `order.fulfilled`, `subscription.cancelled`. This communicates that the event describes something that already happened.105- Each subscriber handles exactly one concern. Never put conditionals inside a subscriber that route to different behaviors -- create separate subscribers.106- For async side effects (sending emails, calling webhooks), have the subscriber enqueue a Sidekiq job rather than performing the work inline. This keeps the request-response cycle fast and makes the side effect retriable.107108### 7. Organize Code with Modules and the Modular Monolith Pattern109110As apps grow beyond ~20 models, namespace and encapsulate domains:111112- Use Rails engines or Ruby modules to create bounded contexts: `Billing::`, `Identity::`, `Inventory::`, `Fulfillment::`. Each namespace owns its models, services, queries, and mailers.113- Enforce boundaries using the `packwerk` gem: define `package.yml` files per domain package and run `packwerk check` in CI to prevent cross-package constant references that violate the dependency graph.114- Place shared infrastructure (ApplicationRecord, ApplicationMailer, ApplicationJob base classes) in an `app/` top-level that all packages can reference.115- Set a rule: packages may depend on shared infrastructure and on sibling packages only through explicitly published APIs (service objects or query objects), never by reaching into another package's models directly.116- Use `zeitwerk` naming conventions strictly -- file paths must match constant paths or autoloading will fail silently in production.117118### 8. Validate and Test the Pattern Implementation119120Each pattern has a specific testing strategy:121122- **Service objects:** Test with unit specs in `spec/services/`. Test the `.call` interface directly. Assert on the returned Result object, not on model state. Test failure paths explicitly -- do not just test the happy path.123- **Query objects:** Test with integration specs against a real database. Use `FactoryBot` to create records matching and not matching the query criteria. Assert on the collection returned, not on SQL strings.124- **Form objects:** Test validations directly: instantiate with invalid params, call `valid?`, and assert on `errors`. Test `#save` with a database transaction that rolls back after each spec.125- **Event/subscriber patterns:** Test that the correct events are published using `expect(EventBus).to receive(:publish).with("user.registered", ...)`. Test subscribers in isolation by calling the handler block directly.126- Target: service object specs should run in under 0.1 seconds each (no Rails boot if using `--require spec_helper` only). Query specs will be slower (DB) -- isolate them so the fast suite stays fast.127128---129130## Output Format131132When advising a user on a Rails pattern, structure output as follows:133134```135## Pattern Recommendation: [Pattern Name]136137### Problem Diagnosed138[1-3 sentences describing the specific anti-pattern or pain point being addressed]139140### Recommended Pattern: [e.g., Service Object + Result Type]141142### Interface Contract143[Method signatures and return types]144145### Implementation146147```ruby148# File: app/services/[namespace]/[name]_service.rb149# [Complete, runnable code -- not pseudocode]150```151152### Usage in Controller153154```ruby155# File: app/controllers/[controller].rb156# [How the controller delegates to the pattern]157```158159### Spec Template160161```ruby162# File: spec/services/[namespace]/[name]_service_spec.rb163# [Minimal but complete test coverage showing happy path + failure path]164```165166### Trade-offs167| Concern | This Pattern | Alternative |168|-------------------|------------------|--------------------------|169| Testability | High -- isolated unit | Lower -- requires AR |170| Complexity | Moderate | Low (fat model) |171| Team familiarity | Requires ramp-up | Familiar Rails idiom |172| LOC overhead | ~30 lines/service| 0 (inline in model) |173174### When to Revisit175[Specific signal that means this pattern is no longer the right fit]176```177178---179180## Rules1811821. **Never use `before_save` or `after_create` callbacks for business logic.** Callbacks are appropriate only for data normalization (e.g., `before_validation :strip_email_whitespace`) or cache invalidation. Business logic in callbacks is invisible to callers, fires in unexpected contexts (seeds, fixtures, console operations), and makes testing nightmarish. Extract to a service object instead.1831842. **Never return `nil` from a service object.** Always return a Result object or raise a typed exception. Callers that receive `nil` from a service resort to `nil` checks scattered through the codebase, which is indistinguishable from a missing value vs. a failed operation.1851863. **Never define more than 3 named scopes on a model that involve joins.** Scopes with joins leak SQL into model definitions and compose poorly. When you have 3+ join-heavy scopes, extract them all into a Query Object. Simple attribute scopes (`scope :active, -> { where(active: true) }`) are acceptable to keep on the model.1871884. **Never put conditional presentation logic in model methods.** Methods like `def display_name; full_name.present? ? full_name : email; end` belong in a presenter or decorator, not the model. Models accumulate these methods and eventually return HTML strings or depend on view helpers -- a serious SRP violation.1891905. **Never define a service object that accepts an ActiveRecord model instance and then calls `.save` on it.** This makes the service object responsible for persistence in an opaque way. Either the service creates/updates via the model's class methods (and owns the full lifecycle) or it only contains logic and the controller/caller handles persistence.1911926. **Never skip the `valid?` check before calling `.save` in a form object.** Many form object implementations call `#save` and only surface errors after a failed DB write. Always validate first: `return false unless valid?` as the first line of `#save`.1931947. **Always namespace service objects by domain, not by action type.** `Services::CreateUser` is worse than `Users::CreateService` -- the former creates a flat `Services/` directory of hundreds of files, the latter groups related operations and scales as the domain grows.1951968. **Never use `rescue Exception`** inside a service object or anywhere in application code. This catches `SignalException` (Ctrl-C), `NoMemoryError`, and `SystemExit`, which will cause processes to hang or data to corrupt. Always rescue specific exception classes.1971989. **Always make Query Objects return an `ActiveRecord::Relation`, never an Array.** Returning an array breaks chainability, forces eager loading at the wrong time, and prevents callers from adding pagination (`.page(1).per(25)`), ordering, or additional scopes without rewriting the query.19920010. **When adding the pub/sub pattern to an existing app, start with one domain only.** Attempting to refactor all callbacks to events in one pass is a high-risk big-bang migration. Pick the domain with the most callback pain (typically `User` or `Order`), migrate it, run in production for 2 weeks, then expand. This gives you real evidence of failure modes before they spread.201202---203204## Edge Cases205206### Fat Model with Deep Callback Chains207208When a model has 8+ callbacks, removing them safely requires a specific sequence to avoid regressions:209210- First, audit every callback with `grep -n "after_\|before_\|around_" app/models/user.rb` and document what each one does and when it fires.211- Add integration specs that cover the side effects of each callback BEFORE making any changes -- these become your regression suite.212- Extract callbacks one at a time into service objects. For `after_create` callbacks, wrap the creation call in a service: the service calls `User.create!` and then performs the side effect explicitly after the record is saved.213- For `before_validation` callbacks used for normalization, keep them -- this is their legitimate use case.214- Do NOT remove a callback from the model until the service object is the only code path that creates or updates that model in production. If seeds, console scripts, or other services also touch the model, the side effect will silently stop firing.215216### Multi-Tenancy Scoping217218When implementing multi-tenancy alongside service objects and query objects:219220- Tenant scope should be injected as a parameter, not inferred from `Current.tenant` inside the service. Services that read from `Current.*` thread-local state are untestable in concurrent contexts and fail silently when called from background jobs (where `Current` is not set).221- Pattern: `Orders::FulfillService.call(order_id: id, tenant: current_tenant)`. The service uses `tenant.orders.find(order_id)` to scope its queries, never `Order.find(order_id)`.222- Add a base query object that all tenant-scoped queries inherit from: it accepts `tenant:` in the constructor and always prepends `.where(tenant_id: tenant.id)` to any relation it returns.223- In specs, always test that a service object raises or returns an error when given an order belonging to a different tenant. This is the most common multi-tenancy security regression.224225### State Machine Complexity226227When a model has more than 3 lifecycle states and transitions:228229- Do not implement state transitions as plain string attribute updates. Use the `state_machines-activerecord` or `aasm` gem -- but encapsulate ALL transition calls inside service objects, never call `order.fulfill!` directly from controllers.230- Define guard clauses as separate predicate methods on the model (`def can_fulfill?`) and test them independently. Do not embed complex conditionals inside the state machine's `guard:` lambda.231- When a transition fails (guard not met), the gem raises an exception. Catch the specific exception in the service object and return a failure Result with a user-friendly message. Never let state machine exceptions propagate to the controller unhandled.232- For state history/audit trail, use the `paper_trail` gem with `only:` limited to the state column -- do not track every attribute if your model has 30+ columns; it creates enormous `versions` table bloat (the `object` JSON column stores the full serialized record).233234### Background Job Integration with Service Objects235236When service logic needs to run asynchronously:237238- Never pass an ActiveRecord object to a Sidekiq job as an argument. Pass the ID only: `Users::WelcomeEmailJob.perform_later(user_id: user.id)`. The job re-fetches the record. Passing an AR object serializes it to YAML (legacy) or JSON, which contains stale data and causes subtle bugs.239- The job's `#perform` method should contain one line: instantiate and call the service object. Keep all logic in the service, not in `#perform`. This makes the service testable without an ActiveJob harness.240- For jobs that should run at most once per record per event (idempotency), add a `processed_at` timestamp or use a Redis-based lock with `redis-mutex` or Sidekiq Enterprise's unique jobs. Without idempotency, retry logic will double-send emails or double-charge payments.241- Set queue depths and timeouts explicitly. A service that calls an external API should have a job timeout of 30 seconds maximum -- without it, a slow API will exhaust your worker pool.242243### Extracting to a Modular Monolith Mid-Project244245When refactoring an existing flat Rails app into bounded contexts:246247- Do not move files to new directories on the first pass. First, add the namespace constant only (rename `User` to `Identity::User` with `require` aliases) and verify the app boots and tests pass. Moving files and renaming constants simultaneously doubles the merge conflict surface area.248- Use `packwerk` in "check" mode (non-blocking) for the first 4 weeks to identify violations without breaking CI. Only switch to blocking mode after you have resolved more than 80% of existing violations.249- Create package boundaries based on team ownership, not just conceptual grouping. If the same 2 engineers own billing and subscriptions, putting them in separate packages creates unnecessary API overhead with no organizational benefit.250- Define a "strangler fig" migration: new features are always built inside packages, existing code is only moved when it needs to be modified for other reasons. This avoids the "big refactor sprint" that never gets prioritized.251252### Presenter and Decorator Performance253254When decorating collections in views:255256- Never call `collection.map { |item| ItemPresenter.new(item) }` inside a view -- this allocates N presenter objects and is not lazy. Use a `CollectionPresenter` or decorate lazily with Enumerator.257- If using Draper, be aware that `User.all.decorate` calls `to_a` on the relation, loading all records into memory. Always paginate before decorating: `User.page(1).per(25).decorate`.258- Presenters that call additional database queries (e.g., `def related_orders; user.orders.recent.limit(5); end`) cause N+1 problems when rendered in a list. Either eager-load the association before decoration or use a specific query object that batch-loads for the collection.259- Benchmark decorator overhead: in a response rendering 100 decorated objects with 10 presenter methods each, you are making 1000 method calls per request. Profile with `rack-mini-profiler` before assuming this is acceptable at scale.260261---262263## Example264265**Input:** "Our `Order` model is 450 lines long. It has `after_create` callbacks for sending confirmation emails and creating an audit log entry, `before_save` for recalculating the total, and a `process_payment!` method that calls Stripe. Our controller's `create` action is also 40 lines. How do we clean this up?"266267**Output:**268269## Pattern Recommendation: Service Object + Query Object + Pub/Sub Event270271### Problem Diagnosed272273The `Order` model has at least three distinct responsibilities: persistence and domain logic, payment processing (external I/O), and triggering side effects (email, audit). The fat controller is a symptom of the model not providing a clean service interface. The `before_save` recalculation callback will fire during seeds, console operations, and test factories -- likely causing test flakiness already.274275---276277### Step 1 -- Extract `Orders::FulfillService`278279```ruby280# app/services/orders/fulfill_service.rb281module Orders282 class FulfillService283 Result = Struct.new(:success?, :order, :errors, keyword_init: true)284285 def self.call(order_params:, user:)286 new(order_params: order_params, user: user).call287 end288289 def initialize(order_params:, user:)290 @order_params = order_params291 @user = user292 end293294 def call295 ActiveRecord::Base.transaction do296 order = build_order297 return failure(order.errors.full_messages) unless order.save298299 charge = process_payment(order)300 return failure([charge.error_message]) unless charge.success?301302 order.update!(303 stripe_charge_id: charge.id,304 fulfilled_at: Time.current305 )306307 EventBus.publish("order.fulfilled", order_id: order.id, user_id: @user.id)308309 Result.new(success?: true, order: order, errors: [])310 end311 rescue Stripe::CardError => e312 Result.new(success?: false, order: nil, errors: [e.message])313 end314315 private316317 def build_order318 Order.new(@order_params.merge(user: @user, total: calculate_total))319 end320321 def calculate_total322 # Moved from before_save -- called explicitly, not via callback323 @order_params[:line_items].sum { |item| item[:quantity] * item[:unit_price] }324 end325326 def process_payment(order)327 Stripe::PaymentProcessor.charge(328 amount: (order.total * 100).to_i, # Stripe expects cents329 currency: "usd",330 customer: @user.stripe_customer_id,331 description: "Order ##{order.id}"332 )333 end334335 def failure(messages)336 raise ActiveRecord::Rollback337 Result.new(success?: false, order: nil, errors: messages)338 end339 end340end341```342343---344345### Step 2 -- Replace Callbacks with Event Subscribers346347Remove the `after_create` callbacks from `Order` and register subscribers:348349```ruby350# config/initializers/event_subscribers.rb351EventBus.subscribe("order.fulfilled") do |payload|352 Orders::SendConfirmationEmailJob.perform_later(order_id: payload[:order_id])353end354355EventBus.subscribe("order.fulfilled") do |payload|356 Audit::LogOrderFulfillmentJob.perform_later(357 order_id: payload[:order_id],358 user_id: payload[:user_id]359 )360end361```362363Each job delegates to a dedicated service:364365```ruby366# app/jobs/orders/send_confirmation_email_job.rb367class Orders::SendConfirmationEmailJob < ApplicationJob368 queue_as :default369 sidekiq_options retry: 3, dead: false370371 def perform(order_id:)372 order = Order.find(order_id)373 OrderMailer.confirmation(order).deliver_now374 end375end376```377378---379380### Step 3 -- Slim the Controller381382```ruby383# app/controllers/orders_controller.rb384class OrdersController < ApplicationController385 def create386 result = Orders::FulfillService.call(387 order_params: order_params,388 user: current_user389 )390391 if result.success?392 redirect_to order_path(result.order), notice: "Order placed successfully."393 else394 flash.now[:alert] = result.errors.join(", ")395 render :new, status: :unprocessable_entity396 end397 end398399 private400401 def order_params402 params.require(:order).permit(:shipping_address, line_items: [:product_id, :quantity, :unit_price])403 end404end405```406407Controller is now 18 lines. It knows nothing about Stripe, email, or audit logging.408409---410411### Step 4 -- Remove the `before_save` Callback from the Model412413```ruby414# app/models/order.rb415class Order < ApplicationRecord416 belongs_to :user417 has_many :line_items418419 # REMOVED: before_save :recalculate_total420 # Total is now calculated explicitly in Orders::FulfillService#calculate_total421 # and in Orders::UpdateService when an order is modified post-creation.422423 validates :total, numericality: { greater_than: 0 }424 validates :user, presence: true425end426```427428The model drops to a clean ~50 lines: associations, validations, and nothing else.429430---431432### Spec Template433434```ruby435# spec/services/orders/fulfill_service_spec.rb436RSpec.describe Orders::FulfillService do437 let(:user) { create(:user, stripe_customer_id: "cus_test123") }438 let(:product) { create(:product, price: 29.99) }439 let(:order_params) do440 {441 shipping_address: "123 Main St",442 line_items: [{ product_id: product.id, quantity: 2, unit_price: 29.99 }]443 }444 end445446 describe ".call" do447 context "when payment succeeds" do448 before do449 allow(Stripe::PaymentProcessor).to receive(:charge).and_return(450 double(success?: true, id: "ch_test456", error_message: nil)451 )452 end453454 it "returns a successful result" do455 result = described_class.call(order_params: order_params, user: user)456 expect(result.success?).to be true457 expect(result.order).to be_persisted458 end459460 it "sets the total correctly" do461 result = described_class.call(order_params: order_params, user: user)462 expect(result.order.total).to eq(59.98) # 2 * 29.99463 end464465 it "publishes the order.fulfilled event" do466 expect(EventBus).to receive(:publish).with("order.fulfilled", hash_including(:order_id))467 described_class.call(order_params: order_params, user: user)468 end469 end470471 context "when payment fails" do472 before do473 allow(Stripe::PaymentProcessor).to receive(:charge)474 .and_raise(Stripe::CardError.new("Your card was declined.", nil))475 end476477 it "returns a failure result" do478 result = described_class.call(order_params: order_params, user: user)479 expect(result.success?).to be false480 expect(result.errors).to include("Your card was declined.")481 end482483 it "does not persist the order" do484 expect {485 described_class.call(order_params: order_params, user: user)486 }.not_to change(Order, :count)487 end488489 it "does not publish events" do490 expect(EventBus).not_to receive(:publish)491 described_class.call(order_params: order_params, user: user)492 end493 end494 end495end496```497498---499500### Trade-offs501502| Concern | Service Object + Events | Original Fat Model |503|---|---|---|504| Testability | High -- Stripe is stubbed, no DB needed for unit | Low -- requires real AR, callbacks fire unexpectedly |505| Controller LOC | 18 lines | 40+ lines |506| Side effect visibility | Explicit in initializer | Hidden in callback chain |507| Onboarding clarity | Read service to understand flow | Must trace callbacks across file |508| Complexity overhead | ~80 LOC across 3 new files | 0 new files (accumulates in place) |509| Transaction safety | Explicit rollback on payment failure | Depends on callback order |510511---512513### When to Revisit514515If `Orders::FulfillService` grows beyond 80 lines, extract payment processing into `Payments::ChargeService` and have `FulfillService` call it. If the number of `order.fulfilled` subscribers exceeds 6, consider switching from `ActiveSupport::Notifications` to a proper message queue (Sidekiq's batch callbacks or a dedicated `EventStore`) to get retry guarantees and observability per subscriber independently.