rails-architecture
2
总安装量
2
周安装量
#70260
全站排名
安装命令
npx skills add https://github.com/dchuk/rails_ai_agents --skill rails-architecture
Agent 安装分布
opencode
2
gemini-cli
2
antigravity
2
claude-code
2
windsurf
2
codex
2
Skill 文档
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/_nownaming - 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
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.
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.
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:
# 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
| Layer | Responsibility | Should NOT contain |
|---|---|---|
| Controller | HTTP, params, authorize, render | Business logic, queries |
| Model | Data, validations, relations, scopes | Display logic, HTTP |
| Concern | Shared model/controller behavior | Unrelated cross-cutting logic |
| Service | Multi-model orchestration, external APIs | HTTP, display logic |
| Query | Complex database queries, reports | Business logic |
| Presenter | View formatting, badges | Business logic, queries |
| Policy | Authorization rules | Business logic |
| Component | Reusable UI encapsulation | Business logic |
| Job | Async delegation (shallow!) | Business logic |
Project Directory Structure
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
| Situation | Keep It Simple | Don’t Create |
|---|---|---|
| Simple CRUD (< 10 lines) | Keep in controller | Service object |
| Used only once | Inline the code | Abstraction |
| Simple query with 1-2 conditions | Model scope | Query object |
| Basic text formatting | Helper method | Presenter |
| Single model form | form_with model: |
Form object |
| Simple partial without logic | Partial | ViewComponent |
When TO Abstract
| Signal | Action |
|---|---|
| Same code in 3+ places | Extract to concern/service |
| Controller action > 15 lines | Extract to service |
| Model > 300 lines | Extract concerns |
| Complex conditionals | Extract to policy/service |
| Query joins 3+ tables | Extract to query object |
| Form spans multiple models | Extract to form object |
| Partial has > 5 lines of logic | Use ViewComponent |
Result Object Pattern
All services return a consistent Result:
# 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
| Layer | Test Type | Location | Focus |
|---|---|---|---|
| Model | Unit | test/models/ |
Validations, scopes, methods |
| Service | Unit | test/services/ |
Business logic, edge cases |
| Query | Unit | test/queries/ |
Query results, correctness |
| Presenter | Unit | test/presenters/ |
Formatting, HTML output |
| Controller | Integration | test/controllers/ |
HTTP flow, authorization |
| Component | Component | test/components/ |
Rendering, variants |
| Policy | Unit | test/policies/ |
Authorization rules |
| System | E2E | test/system/ |
Critical user paths |
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| God Model | Model > 500 lines | Extract concerns |
| Fat Controller | Logic in controllers | Move to models/services |
| Premature Service | Service for 3 lines | Keep in model |
| Callback Hell | Complex model callbacks | Use services for orchestration |
| Boolean State | approved: true |
State-as-records |
| N+1 Queries | Unoptimized queries | Use .includes() |
References
- See layer-interactions.md for layer communication patterns
- See service-patterns.md for service object patterns
- See query-patterns.md for query object patterns
- See error-handling.md for error handling strategies
- See testing-strategy.md for comprehensive testing
- See multi-tenancy.md for multi-tenant patterns
- See event-tracking.md for domain event patterns
- See state-records.md for state-as-records patterns