AgentSkillsCN

rails-architecture

为现代 Rails 8 代码架构决策与设计模式提供指导。在决定代码的存放位置、在服务对象、Concerns 与查询对象之间做出选择、设计功能架构、通过重构提升代码组织性,或当用户提及“architecture”、“code organization”、“design patterns”或“layered design”时,可选用此方法。

SKILL.md
--- frontmatter
name: rails-architecture
description: Guides modern Rails 8 code architecture decisions and patterns. Use when deciding where to put code, choosing between patterns (service objects vs concerns vs query objects), designing feature architecture, refactoring for better organization, or when user mentions architecture, code organization, design patterns, or layered design.
allowed-tools: Read, Glob, Grep

Modern Rails 8 Architecture Patterns

Project Conventions

  • Testing: Minitest + fixtures (NEVER RSpec or FactoryBot)
  • Components: ViewComponents for reusable UI (partials OK for simple one-offs)
  • Authorization: Pundit policies (deny by default)
  • Jobs: Solid Queue, shallow jobs, _later/_now naming
  • Frontend: Hotwire (Turbo + Stimulus) + Tailwind CSS
  • State: State-as-records for business state (booleans only for technical flags)
  • Architecture: Rich models first, service objects for multi-model orchestration
  • Routing: Everything-is-CRUD (new resource over new action)
  • Quality: RuboCop (omakase) + Brakeman

Architecture Decision Tree

code
Where should this code go?
│
├─ Is it data validation, associations, or simple business logic?
│   └─ → Model (rich models first!)
│
├─ Is it shared behavior across models?
│   └─ → Concern
│
├─ Is it business state tracking (who/when/why)?
│   └─ → State Record (see: state-records pattern)
│
├─ Does it orchestrate 3+ models or call external APIs?
│   └─ → Service Object (with Result pattern)
│
├─ Is it a complex database query (3+ joins, aggregations)?
│   └─ → Query Object
│
├─ Is it view/display formatting?
│   └─ → Presenter (SimpleDelegator)
│
├─ Is it authorization logic?
│   └─ → Pundit Policy
│
├─ Is it reusable UI with logic?
│   └─ → ViewComponent
│
├─ Is it async/background work?
│   └─ → Shallow Job (Solid Queue)
│
├─ Is it a complex form (multi-model, wizard)?
│   └─ → Form Object
│
├─ Is it a transactional email?
│   └─ → Mailer
│
└─ Is it HTTP request/response handling only?
    └─ → Controller (keep it thin!)

Hybrid Philosophy: Models First, Services When Needed

The Rule of Three

  • 1 model affected → Keep logic in the model
  • 2 models affected → Consider a concern or model method
  • 3+ models affected → Extract to a service object

Rich Models (Default)

Models handle validations, associations, scopes, simple derived attributes, and single-model business logic. This is where most code belongs.

ruby
class Order < ApplicationRecord
  include Closeable  # State-as-records concern

  belongs_to :user
  has_many :line_items, dependent: :destroy

  validates :total_cents, presence: true, numericality: { greater_than: 0 }

  scope :recent, -> { order(created_at: :desc) }
  scope :pending, -> { where.missing(:closure) }

  def add_item(product, quantity: 1)
    line_items.create!(product: product, quantity: quantity, price_cents: product.price_cents)
    recalculate_total!
  end

  private

  def recalculate_total!
    update!(total_cents: line_items.sum("price_cents * quantity"))
  end
end

Service Objects (When Justified)

Use only when logic spans 3+ models, calls external APIs, or orchestrates complex workflows.

ruby
module Orders
  class CheckoutService
    def call(user:, cart:, payment_method_id:)
      order = nil

      ActiveRecord::Base.transaction do
        order = user.orders.create!(total_cents: cart.total_cents)
        cart.items.each { |item| order.add_item(item.product, quantity: item.quantity) }
        Inventory::ReserveService.new.call(order: order)
      end

      Payments::ChargeService.new.call(order: order, payment_method_id: payment_method_id)
      OrderMailer.confirmation(order).deliver_later
      Result.new(success: true, data: order)
    rescue ActiveRecord::RecordInvalid => e
      Result.new(success: false, error: e.message)
    end
  end
end

Everything-is-CRUD Routing

Prefer creating a new resource over adding custom actions:

ruby
# GOOD: New resource for publishing
resources :posts do
  resource :publication, only: [:create, :destroy]
end
# POST /posts/:post_id/publication → Publications#create
# DELETE /posts/:post_id/publication → Publications#destroy

# BAD: Custom action
resources :posts do
  member do
    post :publish
    post :unpublish
  end
end

Layer Responsibilities

LayerResponsibilityShould NOT contain
ControllerHTTP, params, authorize, renderBusiness logic, queries
ModelData, validations, relations, scopesDisplay logic, HTTP
ConcernShared model/controller behaviorUnrelated cross-cutting logic
ServiceMulti-model orchestration, external APIsHTTP, display logic
QueryComplex database queries, reportsBusiness logic
PresenterView formatting, badgesBusiness logic, queries
PolicyAuthorization rulesBusiness logic
ComponentReusable UI encapsulationBusiness logic
JobAsync delegation (shallow!)Business logic

Project Directory Structure

code
app/
├── channels/            # Action Cable channels
├── components/          # ViewComponents (UI + logic)
├── controllers/
│   └── concerns/        # Shared controller behavior
├── forms/               # Form objects
├── jobs/                # Background jobs (Solid Queue)
├── mailers/             # Action Mailer classes
├── models/
│   └── concerns/        # Shared model behavior
├── policies/            # Pundit authorization
├── presenters/          # View formatting
├── queries/             # Complex queries
├── services/            # Business logic (use sparingly)
│   └── result.rb        # Shared Result class
└── views/
    └── components/      # ViewComponent templates

When NOT to Abstract

SituationKeep It SimpleDon't Create
Simple CRUD (< 10 lines)Keep in controllerService object
Used only onceInline the codeAbstraction
Simple query with 1-2 conditionsModel scopeQuery object
Basic text formattingHelper methodPresenter
Single model formform_with model:Form object
Simple partial without logicPartialViewComponent

When TO Abstract

SignalAction
Same code in 3+ placesExtract to concern/service
Controller action > 15 linesExtract to service
Model > 300 linesExtract concerns
Complex conditionalsExtract to policy/service
Query joins 3+ tablesExtract to query object
Form spans multiple modelsExtract to form object
Partial has > 5 lines of logicUse ViewComponent

Result Object Pattern

All services return a consistent Result:

ruby
# app/services/result.rb
class Result
  attr_reader :data, :error, :code

  def initialize(success:, data: nil, error: nil, code: nil)
    @success = success
    @data = data
    @error = error
    @code = code
  end

  def success? = @success
  def failure? = !@success

  def self.success(data = nil) = new(success: true, data: data)
  def self.failure(error, code: nil) = new(success: false, error: error, code: code)
end

Testing Strategy by Layer

LayerTest TypeLocationFocus
ModelUnittest/models/Validations, scopes, methods
ServiceUnittest/services/Business logic, edge cases
QueryUnittest/queries/Query results, correctness
PresenterUnittest/presenters/Formatting, HTML output
ControllerIntegrationtest/controllers/HTTP flow, authorization
ComponentComponenttest/components/Rendering, variants
PolicyUnittest/policies/Authorization rules
SystemE2Etest/system/Critical user paths

Anti-Patterns to Avoid

Anti-PatternProblemSolution
God ModelModel > 500 linesExtract concerns
Fat ControllerLogic in controllersMove to models/services
Premature ServiceService for 3 linesKeep in model
Callback HellComplex model callbacksUse services for orchestration
Boolean Stateapproved: trueState-as-records
N+1 QueriesUnoptimized queriesUse .includes()

References