
Rails Architecture
- 2 installs
- Updated February 9, 2026
- dchuk/rails_ai_agents
Guides modern Rails 8 architecture decisions, choosing between service objects, concerns, and query objects and where to place code.
About
Advises on modern Rails 8 code architecture and design patterns. A developer uses it when deciding where to put code, choosing between patterns, or refactoring for better organization.
- Choose between service objects, concerns, and query objects
- Layered design and feature architecture guidance
Rails Architecture by the numbers
- 2 all-time installs (skills.sh)
- Ranked #946 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dchuk/rails_ai_agents --skill rails-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | February 9, 2026 |
| Repository | dchuk/rails_ai_agents ↗ |
What it does
Guides modern Rails 8 architecture decisions, choosing between service objects, concerns, and query objects and where to place code.
Files
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
endService 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
endEverything-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
endLayer 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 templatesWhen 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)
endTesting 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
Error Handling Strategies
Result Object Pattern (Preferred)
Services return Result objects instead of raising exceptions:
# 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
# Pattern matching support (Ruby 3+)
def deconstruct_keys(keys)
{ success: @success, data: @data, error: @error, code: @code }
end
endError Code System
Define Error Codes
module Orders
class CreateService
ERROR_CODES = {
empty_cart: :empty_cart,
out_of_stock: :out_of_stock,
payment_declined: :payment_declined,
invalid_coupon: :invalid_coupon,
validation_failed: :validation_failed
}.freeze
MESSAGES = {
empty_cart: "Your cart is empty",
out_of_stock: "One or more items are out of stock",
payment_declined: "Your payment was declined",
invalid_coupon: "The coupon code is invalid",
validation_failed: "Please check your order details"
}.freeze
end
endReturn Typed Errors
def call(params)
return error(:empty_cart) if params[:items].empty?
return error(:out_of_stock) unless inventory_available?(params[:items])
order = create_order(params)
success(order)
rescue PaymentGateway::Declined
error(:payment_declined)
rescue ActiveRecord::RecordInvalid => e
error(:validation_failed, e.message)
end
private
def error(code, details = nil)
message = self.class::MESSAGES[code]
message = "#{message}: #{details}" if details
Result.new(success: false, error: message, code: code)
endController Error Handling
Handle by Error Code
class OrdersController < ApplicationController
def create
result = Orders::CreateService.new.call(order_params)
if result.success?
redirect_to result.data, notice: t(".success")
else
handle_error(result)
end
end
private
def handle_error(result)
case result.code
when :empty_cart
redirect_to cart_path, alert: result.error
when :out_of_stock
flash.now[:alert] = result.error
@out_of_stock = true
render :new, status: :unprocessable_entity
when :payment_declined
redirect_to payment_path, alert: result.error
else
flash.now[:alert] = result.error
render :new, status: :unprocessable_entity
end
end
endPattern Matching (Ruby 3+)
def create
case Orders::CreateService.new.call(order_params)
in { success: true, data: order }
redirect_to order, notice: t(".success")
in { code: :empty_cart }
redirect_to cart_path, alert: t(".empty_cart")
in { code: :payment_declined, error: message }
redirect_to payment_path, alert: message
in { error: message }
flash.now[:alert] = message
render :new, status: :unprocessable_entity
end
endAPI Error Responses
Consistent Error Format
# app/controllers/api/base_controller.rb
module Api
class BaseController < ApplicationController
private
def render_error(result, status: :unprocessable_entity)
render json: {
error: {
code: result.code,
message: result.error,
details: result.data # Optional additional context
}
}, status: status
end
def render_success(data, status: :ok)
render json: { data: data }, status: status
end
end
endHTTP Status Mapping
ERROR_STATUS_MAP = {
not_found: :not_found,
unauthorized: :unauthorized,
forbidden: :forbidden,
validation_failed: :unprocessable_entity,
conflict: :conflict,
rate_limited: :too_many_requests
}.freeze
def render_service_result(result)
if result.success?
render_success(result.data)
else
status = ERROR_STATUS_MAP.fetch(result.code, :unprocessable_entity)
render_error(result, status: status)
end
endException Handling Layers
Service Layer (Catch and Wrap)
class ExternalApiService
def call(params)
response = client.request(params)
success(response.data)
rescue Faraday::TimeoutError
error(:timeout, "External service timed out")
rescue Faraday::ConnectionFailed
error(:connection_failed, "Could not connect to service")
rescue JSON::ParserError
error(:invalid_response, "Invalid response from service")
end
endController Layer (Rescue From)
class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from Pundit::NotAuthorizedError, with: :forbidden
private
def not_found
respond_to do |format|
format.html { render "errors/not_found", status: :not_found }
format.json { render json: { error: "Not found" }, status: :not_found }
end
end
def forbidden
respond_to do |format|
format.html { redirect_to root_path, alert: t("errors.forbidden") }
format.json { render json: { error: "Forbidden" }, status: :forbidden }
end
end
endGlobal Error Handler
# config/initializers/error_handler.rb
Rails.application.config.exceptions_app = ->(env) {
ErrorsController.action(:show).call(env)
}
# app/controllers/errors_controller.rb
class ErrorsController < ApplicationController
skip_before_action :authenticate_user!
def show
@status = request.env["PATH_INFO"].delete("/").to_i
render status: @status
end
endValidation Errors
Model Validations to Result
def call(params)
record = Model.new(params)
if record.save
success(record)
else
validation_error(record)
end
end
def validation_error(record)
Result.new(
success: false,
error: record.errors.full_messages.join(", "),
code: :validation_failed,
data: record.errors.to_hash
)
endDisplay Validation Errors
# In controller
if result.failure? && result.code == :validation_failed
@errors = result.data # Hash of field => [messages]
end
# In view
<% if @errors&.dig(:email) %>
<p class="text-red-500"><%= @errors[:email].join(", ") %></p>
<% end %>Logging Errors
class ApplicationService
private
def error(code, message = nil, exception: nil)
log_error(code, message, exception)
Result.new(success: false, error: message || default_message(code), code: code)
end
def log_error(code, message, exception)
Rails.logger.error({
service: self.class.name,
error_code: code,
message: message,
exception: exception&.class&.name,
backtrace: exception&.backtrace&.first(5)
}.to_json)
end
endError Tracking Integration
# With Sentry/Rollbar
def error(code, message = nil, exception: nil)
if exception && should_report?(code)
Sentry.capture_exception(exception, extra: { code: code, message: message })
end
Result.new(success: false, error: message, code: code)
end
def should_report?(code)
# Don't report expected errors
![:validation_failed, :not_found, :unauthorized].include?(code)
endChecklist
- [ ] Services return Result objects
- [ ] Error codes are typed symbols
- [ ] Controllers handle errors by code
- [ ] API responses have consistent format
- [ ] Unexpected errors logged with context
- [ ] Sensitive data not exposed in errors
- [ ] User-facing messages use I18n
Event Tracking Patterns
Philosophy: Domain Event Records, Not Generic Tracking
Events are rich domain models (CardMoved, CommentAdded) — not generic Event rows with JSON blobs.
Domain Event Records
# GOOD: Rich domain event
class CardMoved < ApplicationRecord
belongs_to :card
belongs_to :from_column, class_name: "Column"
belongs_to :to_column, class_name: "Column"
belongs_to :creator
has_one :activity, as: :subject, dependent: :destroy
after_create_commit :create_activity
after_create_commit :broadcast_update_later
after_create_commit :deliver_webhooks_later
validates :card, :from_column, :to_column, presence: true
def description
"#{creator.name} moved #{card.title} from #{from_column.name} to #{to_column.name}"
end
private
def create_activity
Activity.create!(subject: self, creator: creator)
end
def broadcast_update_later
card.broadcast_replace_later
end
def deliver_webhooks_later
WebhookDeliveryJob.perform_later(self)
end
end
# BAD: Generic event blob
Event.create(event_type: "card.moved", data: { card_id: 1 })Activity Feed (Polymorphic)
class Activity < ApplicationRecord
belongs_to :subject, polymorphic: true # CardMoved, CommentAdded, etc.
belongs_to :creator, optional: true
scope :recent, -> { order(created_at: :desc).limit(50) }
endWebhook System
# Webhook endpoint configuration
class WebhookEndpoint < ApplicationRecord
has_many :deliveries, class_name: "WebhookDelivery", dependent: :destroy
validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
validates :events, presence: true
serialize :events, coder: JSON
def subscribed_to?(event_type)
events.include?(event_type)
end
end
# Delivery tracking
class WebhookDelivery < ApplicationRecord
belongs_to :webhook_endpoint
belongs_to :event, polymorphic: true
enum :status, { pending: 0, delivered: 1, failed: 2 }
scope :pending, -> { where(status: :pending) }
scope :failed, -> { where(status: :failed) }
endWebhook Delivery Job
class WebhookDeliveryJob < ApplicationJob
queue_as :webhooks
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
def perform(event)
WebhookEndpoint.all.select { |ep| ep.subscribed_to?(event.class.name.underscore) }.each do |endpoint|
delivery = endpoint.deliveries.create!(event: event, status: :pending)
response = deliver(endpoint.url, payload(event))
delivery.update!(status: :delivered, response_code: response.code)
rescue => e
delivery&.update!(status: :failed, error_message: e.message)
end
end
private
def deliver(url, body)
Net::HTTP.post(URI(url), body.to_json, "Content-Type" => "application/json")
end
def payload(event)
{ type: event.class.name.underscore, data: event.as_json, timestamp: Time.current.iso8601 }
end
endTesting Events
# test/models/card_moved_test.rb
require "test_helper"
class CardMovedTest < ActiveSupport::TestCase
test "creates activity on create" do
card = cards(:one)
assert_difference "Activity.count", 1 do
CardMoved.create!(
card: card,
from_column: columns(:todo),
to_column: columns(:done),
creator: users(:one)
)
end
end
test "#description includes details" do
moved = card_moveds(:recent)
assert_match moved.card.title, moved.description
assert_match moved.from_column.name, moved.description
end
endLayer Interactions
Detailed examples of how architectural layers communicate in a Rails 8 application.
Request Flow Example
A complete example showing how layers interact for creating an event with vendors.
1. Controller (Entry Point)
# app/controllers/events_controller.rb
class EventsController < ApplicationController
def create
# 1. Authorization (Policy)
authorize Event
# 2. Use Form Object for complex input
@form = EventCreationForm.new(event_params)
if @form.valid?
# 3. Delegate to Service
result = Events::CreateService.new.call(
account: current_account,
params: @form.attributes
)
if result.success?
# 4. Background job for notifications
EventCreatedJob.perform_later(result.data.id)
redirect_to result.data, notice: t(".success")
else
flash.now[:alert] = result.error
render :new, status: :unprocessable_entity
end
else
render :new, status: :unprocessable_entity
end
end
end2. Form Object (Input Handling)
# app/forms/event_creation_form.rb
class EventCreationForm < ApplicationForm
attribute :name, :string
attribute :event_date, :date
attribute :event_type, :string
attribute :vendor_ids, array: true, default: []
validates :name, presence: true
validates :event_date, presence: true
validate :event_date_in_future
private
def event_date_in_future
return if event_date.blank?
errors.add(:event_date, :in_past) if event_date < Date.current
end
end3. Service Object (Business Logic)
# app/services/events/create_service.rb
module Events
class CreateService < ApplicationService
def call(account:, params:)
event = nil
ActiveRecord::Base.transaction do
# Create event
event = account.events.create!(
name: params[:name],
event_date: params[:event_date],
event_type: params[:event_type]
)
# Attach vendors
attach_vendors(event, params[:vendor_ids])
# Update statistics
update_account_stats(account)
end
success(event)
rescue ActiveRecord::RecordInvalid => e
failure(e.message, :validation_error)
end
private
def attach_vendors(event, vendor_ids)
return if vendor_ids.blank?
vendor_ids.each do |vendor_id|
event.event_vendors.create!(vendor_id: vendor_id)
end
end
def update_account_stats(account)
# Could use a Query Object here
account.update_column(:events_count, account.events.count)
end
end
end4. Model (Data & Validations)
# app/models/event.rb
class Event < ApplicationRecord
belongs_to :account
has_many :event_vendors, dependent: :destroy
has_many :vendors, through: :event_vendors
validates :name, presence: true
validates :event_date, presence: true
enum :event_type, { wedding: 0, corporate: 1, private: 2 }
enum :status, { draft: 0, confirmed: 1, completed: 2, cancelled: 3 }
scope :upcoming, -> { where("event_date >= ?", Date.current) }
scope :recent, -> { order(created_at: :desc) }
end5. Policy (Authorization)
# app/policies/event_policy.rb
class EventPolicy < ApplicationPolicy
def create?
user.account_id.present?
end
def show?
owner?
end
def update?
owner? && !record.completed?
end
private
def owner?
record.account_id == user.account_id
end
class Scope < ApplicationPolicy::Scope
def resolve
scope.where(account_id: user.account_id)
end
end
end6. Background Job (Async Processing)
# app/jobs/event_created_job.rb
class EventCreatedJob < ApplicationJob
queue_as :default
def perform(event_id)
event = Event.find(event_id)
# Send email notification
EventMailer.created(event).deliver_later
# Broadcast to dashboard
DashboardChannel.broadcast_stats(event.account)
# Log activity
ActivityService.new.log(
account: event.account,
action: :event_created,
resource: event
)
end
end7. Mailer (Email)
# app/mailers/event_mailer.rb
class EventMailer < ApplicationMailer
def created(event)
@event = event
@user = event.account.users.first
mail(
to: @user.email_address,
subject: t(".subject", name: event.name)
)
end
end8. Query Object (Complex Queries)
# app/queries/dashboard_stats_query.rb
class DashboardStatsQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call
{
total_events: account.events.count,
upcoming_events: upcoming_events_count,
events_by_type: events_by_type,
recent_events: recent_events
}
end
private
def upcoming_events_count
account.events.upcoming.count
end
def events_by_type
account.events.group(:event_type).count
end
def recent_events
account.events.recent.limit(5)
end
end9. Presenter (View Formatting)
# app/presenters/event_presenter.rb
class EventPresenter < BasePresenter
STATUS_COLORS = {
draft: "bg-slate-100 text-slate-800",
confirmed: "bg-green-100 text-green-800",
completed: "bg-blue-100 text-blue-800",
cancelled: "bg-red-100 text-red-800"
}.freeze
def status_badge
tag.span(
status_text,
class: "inline-flex px-2 py-1 rounded-full text-xs font-medium #{status_color}"
)
end
def formatted_date
return not_specified_span if event_date.nil?
I18n.l(event_date, format: :long)
end
def vendor_count_text
I18n.t("events.vendors_count", count: vendors.size)
end
private
def status_text
I18n.t("activerecord.attributes.event/statuses.#{status}")
end
def status_color
STATUS_COLORS.fetch(status.to_sym, STATUS_COLORS[:draft])
end
end10. ViewComponent (Reusable UI)
# app/components/event_card_component.rb
class EventCardComponent < ApplicationComponent
def initialize(event:)
@event = EventPresenter.new(event)
end
attr_reader :event
end<%# app/components/event_card_component.html.erb %>
<article class="bg-white rounded-lg shadow p-6">
<header class="flex justify-between items-start">
<h3 class="text-lg font-semibold"><%= event.name %></h3>
<%= event.status_badge %>
</header>
<dl class="mt-4 space-y-2">
<div>
<dt class="text-sm text-slate-500"><%= t(".date") %></dt>
<dd><%= event.formatted_date %></dd>
</div>
<div>
<dt class="text-sm text-slate-500"><%= t(".vendors") %></dt>
<dd><%= event.vendor_count_text %></dd>
</div>
</dl>
<footer class="mt-4 flex gap-2">
<%= link_to t("common.view"), event, class: "btn btn-primary" %>
<% if policy(event.model).edit? %>
<%= link_to t("common.edit"), edit_event_path(event), class: "btn btn-secondary" %>
<% end %>
</footer>
</article>11. Channel (Real-time)
# app/channels/dashboard_channel.rb
class DashboardChannel < ApplicationCable::Channel
def subscribed
stream_for current_user.account
end
def self.broadcast_stats(account)
stats = DashboardStatsQuery.new(account: account).call
broadcast_to(account, {
type: "stats_update",
data: stats
})
end
endLayer Communication Rules
Who Can Call Whom
Controller → Service, Query, Policy, Form
Service → Model, Query, Job, Mailer, Channel
Query → Model (read-only)
Job → Service, Mailer, Channel
Presenter → Model (read-only)
Component → Presenter, Policy (for authorization checks)
Channel → Query (for broadcasting data)Who Should NOT Call Whom
Model → Controller, Service, Job (avoid callbacks that do this)
Presenter → Service, Job (no side effects)
Query → Service, Job (read-only)
Component → Service, Job (presentation only)Data Flow Patterns
Pattern 1: Simple CRUD
Request → Controller → Model → ViewPattern 2: Complex Business Logic
Request → Controller → Service → Model → Presenter → Component → Response
↘ Job → MailerPattern 3: Dashboard with Stats
Request → Controller → Query → Presenter → Component → Response
↘ Policy (for authorization)Pattern 4: Real-time Updates
Service → Channel → WebSocket → Client
↘ Job (async)Pattern 5: Form with Multiple Models
Request → Controller → Form Object → Service → Models → ResponseTesting Each Layer
| Layer | Test Type | What to Test |
|---|---|---|
| Controller | Request spec | HTTP flow, status codes, redirects |
| Service | Unit spec | Business logic, Result object |
| Query | Unit spec | SQL results, tenant isolation |
| Model | Model spec | Validations, associations, scopes |
| Policy | Policy spec | Authorization rules |
| Form | Unit spec | Validations, attribute handling |
| Presenter | Unit spec | Formatting, HTML output |
| Component | Component spec | Rendering |
| Job | Job spec | Execution, side effects |
| Mailer | Mailer spec | Recipients, content |
| Channel | Channel spec | Subscriptions, broadcasts |
Multi-Tenancy Patterns
URL-Based Multi-Tenancy
The preferred pattern for Rails multi-tenancy: account ID in the URL path.
# config/routes.rb
Rails.application.routes.draw do
scope "/:account_id" do
resources :boards do
resources :cards
end
end
end
# Routes: /accounts/123/boards/456/cards/789Current Attributes for Context
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :account
def user=(user)
super
self.account = user&.account
end
endController Scoping
class ApplicationController < ActionController::Base
before_action :set_current_account
private
def set_current_account
Current.account = current_user.accounts.find(params[:account_id])
rescue ActiveRecord::RecordNotFound
redirect_to root_path, alert: "Account not found"
end
end
class BoardsController < ApplicationController
def index
@boards = Current.account.boards
end
def show
@board = Current.account.boards.find(params[:id])
end
endAccount Model
class Account < ApplicationRecord
has_many :memberships, dependent: :destroy
has_many :users, through: :memberships
# All account resources
has_many :boards, dependent: :destroy
has_many :cards, dependent: :destroy
validates :name, presence: true
def member?(user)
users.exists?(user.id)
end
def add_member(user, role: :member)
memberships.find_or_create_by!(user: user) do |m|
m.role = role
end
end
endMembership Model
class Membership < ApplicationRecord
belongs_to :user
belongs_to :account
enum :role, { member: 0, admin: 1, owner: 2 }
validates :user_id, uniqueness: { scope: :account_id }
endEvery Table Gets account_id
class CreateBoards < ActiveRecord::Migration[8.0]
def change
create_table :boards do |t|
t.references :account, null: false, foreign_key: true
t.string :name, null: false
t.timestamps
end
add_index :boards, [:account_id, :name], unique: true
end
endScoping Pattern (Explicit, Not Default Scope)
# GOOD: Explicit scoping through association
Current.account.boards.find(params[:id])
# BAD: Default scope (implicit, hard to debug)
class Board < ApplicationRecord
default_scope { where(account_id: Current.account&.id) }
endTesting Multi-Tenancy
# test/models/board_test.rb
require "test_helper"
class BoardTest < ActiveSupport::TestCase
test "boards are scoped to account" do
account = accounts(:one)
other_account = accounts(:two)
board = boards(:one) # belongs to accounts(:one)
assert_includes account.boards, board
assert_not_includes other_account.boards, board
end
end
# test/controllers/boards_controller_test.rb
class BoardsControllerTest < ActionDispatch::IntegrationTest
test "cannot access other account's boards" do
sign_in_as users(:one) # belongs to accounts(:one)
board = boards(:other_account_board) # belongs to accounts(:two)
get board_url(board, account_id: accounts(:two).id)
assert_redirected_to root_path
end
endQuery Object Patterns
Basic Query Structure
# app/queries/[name]_query.rb
class NameQuery
attr_reader :account
def initialize(account:)
@account = account
end
# @return [ActiveRecord::Relation<Model>]
def call
account.models
.where(conditions)
.order(created_at: :desc)
end
endQuery Categories
1. Filter Queries
Return filtered ActiveRecord relations:
# app/queries/active_events_query.rb
class ActiveEventsQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call(date_range: nil)
scope = account.events.where(status: :active)
scope = scope.where(event_date: date_range) if date_range
scope.includes(:venue, :vendors).order(event_date: :asc)
end
end2. Aggregation Queries
Return computed statistics:
# app/queries/revenue_stats_query.rb
class RevenueStatsQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call(period: :month)
{
total: total_revenue,
by_period: revenue_by_period(period),
by_category: revenue_by_category,
growth_rate: calculate_growth
}
end
private
def total_revenue
account.orders.completed.sum(:total_cents)
end
def revenue_by_period(period)
group_clause = case period
when :day then "DATE(created_at)"
when :week then "DATE_TRUNC('week', created_at)"
when :month then "DATE_TRUNC('month', created_at)"
end
account.orders.completed
.group(Arel.sql(group_clause))
.sum(:total_cents)
end
def revenue_by_category
account.orders.completed
.joins(line_items: :product)
.group("products.category")
.sum(:total_cents)
end
end3. Dashboard Queries
Multiple related metrics:
# app/queries/dashboard_stats_query.rb
class DashboardStatsQuery
attr_reader :user, :account
def initialize(user:)
@user = user
@account = user.account
end
def upcoming_events(limit: 5)
account.events
.where("event_date >= ?", Date.current)
.order(event_date: :asc)
.limit(limit)
end
def pending_tasks_count
account.tasks.pending.count
end
def leads_by_status
account.leads.group(:status).count
end
def recent_activity(limit: 10)
account.activities
.includes(:user, :trackable)
.order(created_at: :desc)
.limit(limit)
end
end4. Search Queries
Full-text search with filters:
# app/queries/vendor_search_query.rb
class VendorSearchQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call(term:, filters: {})
scope = account.vendors
scope = apply_search(scope, term) if term.present?
scope = apply_filters(scope, filters)
scope = apply_sorting(scope, filters[:sort])
scope.includes(:category, :reviews)
end
private
def apply_search(scope, term)
scope.where(
"name ILIKE :term OR description ILIKE :term",
term: "%#{sanitize_like(term)}%"
)
end
def apply_filters(scope, filters)
scope = scope.where(category_id: filters[:category]) if filters[:category]
scope = scope.where(active: true) if filters[:active_only]
scope = scope.where("rating >= ?", filters[:min_rating]) if filters[:min_rating]
scope
end
def apply_sorting(scope, sort)
case sort
when "name" then scope.order(name: :asc)
when "rating" then scope.order(rating: :desc)
when "recent" then scope.order(created_at: :desc)
else scope.order(name: :asc)
end
end
def sanitize_like(term)
term.gsub(/[%_]/) { |x| "\\#{x}" }
end
end5. Report Queries
Complex data for exports:
# app/queries/event_report_query.rb
class EventReportQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call(date_range:)
account.events
.where(event_date: date_range)
.includes(:venue, :vendors, :attendees)
.select(
"events.*",
"COUNT(DISTINCT attendees.id) as attendee_count",
"SUM(event_vendors.amount_cents) as total_vendor_cost"
)
.joins(:attendees, :event_vendors)
.group("events.id")
.order(event_date: :asc)
end
endPerformance Patterns
Eager Loading
def call
account.events
.includes(:venue) # Belongs-to
.includes(:vendors) # Has-many through
.includes(attendees: :user) # Nested
.preload(:documents) # Separate query
.eager_load(:primary_contact) # LEFT JOIN
endBatch Processing
def process_all
account.events.find_each(batch_size: 100) do |event|
yield event
end
endSubquery Optimization
def call
# Use subquery instead of pluck for large datasets
active_vendor_ids = account.vendors.active.select(:id)
account.events
.where(vendor_id: active_vendor_ids)
.order(created_at: :desc)
endMulti-Tenancy Patterns
Always Scope Through Account
# GOOD
def call
account.events.where(status: :active)
end
# BAD - Security risk!
def call
Event.where(account_id: account.id, status: :active)
endTest Isolation
# test/queries/active_events_query_test.rb
require "test_helper"
class ActiveEventsQueryTest < ActiveSupport::TestCase
test "only returns events for the account" do
account = accounts(:one)
our_event = events(:one) # belongs to accounts(:one)
their_event = events(:other_account_event)
result = ActiveEventsQuery.new(account: account).call
assert_includes result, our_event
assert_not_includes result, their_event
end
endComposition Patterns
Query Chaining
# Queries return relations, enabling chaining
events = ActiveEventsQuery.new(account: account).call
upcoming = events.where("event_date > ?", Date.current)
paginated = upcoming.page(params[:page]).per(20)Query Composition
class ComplexReportQuery
def initialize(account:)
@events_query = ActiveEventsQuery.new(account: account)
@revenue_query = RevenueStatsQuery.new(account: account)
end
def call(date_range:)
{
events: @events_query.call(date_range: date_range),
revenue: @revenue_query.call
}
end
endUsage in Controllers
class EventsController < ApplicationController
def index
@events = ActiveEventsQuery.new(account: current_account)
.call
.page(params[:page])
end
def dashboard
@stats = DashboardStatsQuery.new(user: current_user)
end
endChecklist
- [ ] Constructor accepts
account:oruser: - [ ] Always scoped through account (multi-tenant)
- [ ] Return type documented (
@return) - [ ] Uses
.includes()to prevent N+1 - [ ] Search terms sanitized
- [ ] Spec tests tenant isolation
- [ ] Complex queries explain their purpose
Service Object Patterns
Basic Service Structure
# app/services/[namespace]/[verb]_service.rb
module Namespace
class VerbService
def initialize(dependencies = {})
@dependency = dependencies[:dependency] || DefaultDependency.new
end
def call(params)
validate_input(params)
perform_operation(params)
success(result)
rescue StandardError => e
failure(e.message)
end
private
attr_reader :dependency
def success(data)
Result.new(success: true, data: data)
end
def failure(error, code = :unknown)
Result.new(success: false, error: error, code: code)
end
end
endService Categories
1. Command Services (Write Operations)
Single action that changes state:
# app/services/orders/create_service.rb
module Orders
class CreateService
def call(user:, items:)
order = nil
ActiveRecord::Base.transaction do
order = user.orders.create!(status: :pending)
create_line_items(order, items)
reserve_inventory(items)
end
OrderMailer.confirmation(order).deliver_later
success(order)
rescue ActiveRecord::RecordInvalid => e
failure(e.message, :validation_error)
end
end
end2. Query Services (Read Operations)
Complex reads that don't fit in Query Objects:
# app/services/reports/generate_service.rb
module Reports
class GenerateService
def call(account:, date_range:, format:)
data = gather_data(account, date_range)
formatted = format_data(data, format)
success(formatted)
end
private
def gather_data(account, range)
{
events: EventStatsQuery.new(account: account).call(range),
revenue: RevenueQuery.new(account: account).call(range),
leads: LeadConversionQuery.new(account: account).call(range)
}
end
end
end3. Integration Services (External APIs)
Wrap external service calls:
# app/services/payments/charge_service.rb
module Payments
class ChargeService
def initialize(gateway: StripeGateway.new)
@gateway = gateway
end
def call(order:, payment_method_id:)
charge = gateway.charge(
amount: order.total_cents,
currency: "eur",
payment_method_id: payment_method_id
)
order.update!(
payment_status: :paid,
payment_reference: charge.id
)
success(charge)
rescue PaymentGateway::CardDeclined => e
failure(e.message, :card_declined)
rescue PaymentGateway::Error => e
failure(e.message, :payment_error)
end
private
attr_reader :gateway
end
end4. Orchestrator Services (Complex Workflows)
Coordinate multiple services:
# app/services/onboarding/complete_service.rb
module Onboarding
class CompleteService
def call(user:, params:)
results = []
results << Accounts::SetupService.new.call(user: user, params: params[:account])
return results.last if results.last.failure?
results << Preferences::ConfigureService.new.call(user: user, params: params[:preferences])
return results.last if results.last.failure?
results << Notifications::WelcomeService.new.call(user: user)
user.update!(onboarding_completed_at: Time.current)
success(user)
end
end
endDependency Injection Patterns
Constructor Injection (Preferred)
class OrderService
def initialize(
inventory: InventoryService.new,
payment: PaymentService.new,
notifier: NotificationService.new
)
@inventory = inventory
@payment = payment
@notifier = notifier
end
endTesting with Mocks
# test/services/orders/create_service_test.rb
require "test_helper"
class Orders::CreateServiceTest < ActiveSupport::TestCase
setup do
@inventory = Minitest::Mock.new
@payment = Minitest::Mock.new
@service = Orders::CreateService.new(inventory: @inventory, payment: @payment)
@user = users(:one)
end
test "checks inventory before charging" do
@inventory.expect :available?, true, [Array]
@inventory.expect :reserve, true, [Array]
@payment.expect :charge, true, [Hash]
@service.call(user: @user, items: [{ product_id: products(:widget).id, quantity: 1 }])
@inventory.verify
@payment.verify
end
endError Handling Patterns
Typed Error Codes
module Orders
class CreateService
ERROR_CODES = {
empty_cart: "No items in cart",
insufficient_inventory: "Item out of stock",
payment_failed: "Payment could not be processed",
validation_failed: "Invalid order data"
}.freeze
def call(params)
return failure(:empty_cart) if params[:items].empty?
return failure(:insufficient_inventory) unless inventory_available?(params[:items])
order = create_order(params)
success(order)
rescue PaymentError
failure(:payment_failed)
rescue ActiveRecord::RecordInvalid => e
failure(:validation_failed, e.message)
end
private
def failure(code, details = nil)
message = ERROR_CODES[code]
message = "#{message}: #{details}" if details
Result.new(success: false, error: message, code: code)
end
end
endController Error Handling
class OrdersController < ApplicationController
def create
result = Orders::CreateService.new.call(order_params)
if result.success?
redirect_to result.data, notice: t(".success")
else
handle_service_error(result)
end
end
private
def handle_service_error(result)
case result.code
when :empty_cart
redirect_to cart_path, alert: result.error
when :insufficient_inventory
flash.now[:alert] = result.error
render :new, status: :unprocessable_entity
when :payment_failed
redirect_to checkout_path, alert: result.error
else
flash.now[:alert] = result.error
render :new, status: :unprocessable_entity
end
end
endService Naming Conventions
| Pattern | Example | Use Case |
|---|---|---|
VerbNounService | CreateOrderService | Single action |
Namespace::VerbService | Orders::CreateService | Namespaced (preferred) |
NounVerbService | OrderCreatorService | Alternative style |
Checklist
- [ ] Single public method (
#call) - [ ] Returns Result object
- [ ] Dependencies injected via constructor
- [ ] Errors caught and wrapped
- [ ] Transaction for multi-model writes
- [ ] Typed error codes for handling
- [ ] Spec covers success and failure paths
State-as-Records Patterns
Philosophy
Instead of boolean columns (closed: true), create separate state record models that capture who, when, and why.
When to Use State Records vs Booleans
Use State Records When:
- You need to track WHO changed the state
- You need to track WHEN the state changed
- You need to track WHY (reason, notes)
- State changes are business-significant events
- You need an audit trail
Booleans Are OK When:
- It's a technical flag (
email_verified,terms_accepted) - No audit trail needed
- Simple on/off with no metadata
- Performance-critical hot paths
Pattern 1: Simple Toggle (Closure)
# Migration
class CreateClosures < ActiveRecord::Migration[8.0]
def change
create_table :closures do |t|
t.references :card, null: false, foreign_key: true
t.references :user, foreign_key: true
t.timestamps
end
add_index :closures, :card_id, unique: true
end
end
# app/models/closure.rb
class Closure < ApplicationRecord
belongs_to :card, touch: true
belongs_to :user, optional: true
validates :card, uniqueness: true
end
# app/models/concerns/closeable.rb
module Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
scope :open, -> { where.missing(:closure) }
scope :closed, -> { joins(:closure) }
end
def close(user: Current.user)
create_closure!(user: user)
end
def reopen
closure&.destroy!
end
def closed?
closure.present?
end
def open?
!closed?
end
def closed_at
closure&.created_at
end
def closed_by
closure&.user
end
end
# app/models/card.rb
class Card < ApplicationRecord
include Closeable
endPattern 2: State with Reason (Approval)
class CreateApprovals < ActiveRecord::Migration[8.0]
def change
create_table :approvals do |t|
t.references :approvable, polymorphic: true, null: false
t.references :user, null: false, foreign_key: true
t.text :notes
t.timestamps
end
add_index :approvals, [:approvable_type, :approvable_id], unique: true
end
end
class Approval < ApplicationRecord
belongs_to :approvable, polymorphic: true, touch: true
belongs_to :user
validates :approvable, uniqueness: { scope: :approvable_type }
end
module Approvable
extend ActiveSupport::Concern
included do
has_one :approval, as: :approvable, dependent: :destroy
scope :approved, -> { joins(:approval) }
scope :pending_approval, -> { where.missing(:approval) }
end
def approve!(user:, notes: nil)
create_approval!(user: user, notes: notes)
end
def unapprove!
approval&.destroy!
end
def approved?
approval.present?
end
def approved_by
approval&.user
end
def approved_at
approval&.created_at
end
endPattern 3: State with History (Publication)
When you need to track multiple state transitions over time:
class CreatePublications < ActiveRecord::Migration[8.0]
def change
create_table :publications do |t|
t.references :post, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.string :key, null: false
t.text :description
t.timestamps
end
add_index :publications, :post_id, unique: true
add_index :publications, :key, unique: true
end
end
class Publication < ApplicationRecord
belongs_to :post, touch: true
belongs_to :user
before_validation :generate_key, on: :create
private
def generate_key
self.key ||= SecureRandom.alphanumeric(12)
end
endCRUD Routing for State Records
# config/routes.rb
resources :cards do
resource :closure, only: [:create, :destroy]
end
resources :posts do
resource :publication, only: [:create, :destroy]
end
resources :documents do
resource :approval, only: [:create, :destroy]
end# app/controllers/closures_controller.rb
class ClosuresController < ApplicationController
before_action :set_card
def create
authorize @card, :close?
@card.close(user: Current.user)
redirect_to @card, notice: "Closed."
end
def destroy
authorize @card, :reopen?
@card.reopen
redirect_to @card, notice: "Reopened."
end
private
def set_card
@card = Card.find(params[:card_id])
end
endTesting State Records
# test/models/concerns/closeable_test.rb
require "test_helper"
class CloseableTest < ActiveSupport::TestCase
setup do
@card = cards(:open_card)
@user = users(:one)
end
test "#close creates a closure" do
assert_difference "Closure.count", 1 do
@card.close(user: @user)
end
assert @card.closed?
assert_equal @user, @card.closed_by
end
test "#reopen destroys the closure" do
@card.close(user: @user)
@card.reopen
assert @card.open?
end
test ".open scope excludes closed cards" do
@card.close(user: @user)
assert_not_includes Card.open, @card
end
test ".closed scope includes closed cards" do
@card.close(user: @user)
assert_includes Card.closed, @card
end
endTesting Strategy by Layer
Test Pyramid
/\
/ \ System Tests (few)
/----\
/ \ Controller/Integration Tests (moderate)
/--------\
/ \ Unit Tests (many)
--------------
Models, Services, Queries, Presenters, ComponentsUnit Tests
Model Tests
# test/models/event_test.rb
require "test_helper"
class EventTest < ActiveSupport::TestCase
test "requires name" do
event = Event.new(name: nil)
assert_not event.valid?
assert_includes event.errors[:name], "can't be blank"
end
test "requires event_date" do
event = Event.new(event_date: nil)
assert_not event.valid?
assert_includes event.errors[:event_date], "can't be blank"
end
test ".upcoming returns only future events" do
past_event = events(:past)
future_event = events(:upcoming)
results = Event.upcoming
assert_includes results, future_event
assert_not_includes results, past_event
end
test "#days_until returns days until event" do
event = Event.new(event_date: 5.days.from_now.to_date)
assert_equal 5, event.days_until
end
endService Tests
# test/services/orders/create_service_test.rb
require "test_helper"
class Orders::CreateServiceTest < ActiveSupport::TestCase
setup do
@user = users(:one)
@product = products(:widget)
@service = Orders::CreateService.new
end
test "returns success with valid params" do
result = @service.call(user: @user, items: [{ product_id: @product.id, quantity: 2 }])
assert result.success?
assert_kind_of Order, result.data
end
test "creates an order" do
assert_difference "Order.count", 1 do
@service.call(user: @user, items: [{ product_id: @product.id, quantity: 2 }])
end
end
test "returns failure with empty items" do
result = @service.call(user: @user, items: [])
assert result.failure?
assert_equal :empty_cart, result.code
end
test "does not create order on failure" do
assert_no_difference "Order.count" do
@service.call(user: @user, items: [])
end
end
endQuery Tests
# test/queries/active_events_query_test.rb
require "test_helper"
class ActiveEventsQueryTest < ActiveSupport::TestCase
setup do
@account = accounts(:one)
@other_account = accounts(:two)
@query = ActiveEventsQuery.new(account: @account)
end
test "returns active events for account" do
active = events(:active)
result = @query.call
assert_includes result, active
end
test "excludes inactive events" do
cancelled = events(:cancelled)
result = @query.call
assert_not_includes result, cancelled
end
test "excludes other account events (tenant isolation)" do
other_event = events(:other_account_event)
result = @query.call
assert_not_includes result, other_event
end
endPresenter Tests
# test/presenters/event_presenter_test.rb
require "test_helper"
class EventPresenterTest < ActiveSupport::TestCase
include ActionView::Helpers::TagHelper
test "delegates to model" do
event = events(:confirmed)
presenter = EventPresenter.new(event)
assert_equal event.name, presenter.name
end
test "#status_badge returns HTML-safe string" do
presenter = EventPresenter.new(events(:confirmed))
assert_predicate presenter.status_badge, :html_safe?
end
test "#status_badge includes status text" do
presenter = EventPresenter.new(events(:confirmed))
assert_match "Confirmed", presenter.status_badge
end
test "#formatted_date with date present" do
event = events(:confirmed)
presenter = EventPresenter.new(event)
assert_match event.event_date.year.to_s, presenter.formatted_date
end
test "#formatted_date with nil date" do
event = events(:no_date)
presenter = EventPresenter.new(event)
assert_match "TBD", presenter.formatted_date
end
endIntegration Tests
Controller Tests
# test/controllers/events_controller_test.rb
require "test_helper"
class EventsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
@event = events(:one)
sign_in_as @user
end
test "should get index" do
get events_url
assert_response :success
end
test "shows only own account events" do
get events_url
assert_response :success
other_event = events(:other_account_event)
assert_no_match other_event.name, response.body
end
test "should create event" do
assert_difference("Event.count") do
post events_url, params: { event: { name: "New Event", event_date: 1.week.from_now } }
end
assert_redirected_to event_url(Event.last)
end
test "renders form with errors for invalid params" do
post events_url, params: { event: { name: "" } }
assert_response :unprocessable_entity
end
endPolicy Tests
# test/policies/event_policy_test.rb
require "test_helper"
class EventPolicyTest < ActiveSupport::TestCase
test "owner can show" do
user = users(:one)
event = events(:one) # belongs to user's account
assert EventPolicy.new(user, event).show?
end
test "non-owner cannot show" do
user = users(:two) # different account
event = events(:one)
assert_not EventPolicy.new(user, event).show?
end
test "scope returns only own events" do
user = users(:one)
scope = EventPolicy::Scope.new(user, Event).resolve
assert_includes scope, events(:one)
assert_not_includes scope, events(:other_account_event)
end
endSystem Tests
# test/system/create_event_test.rb
require "application_system_test_case"
class CreateEventTest < ApplicationSystemTestCase
setup do
sign_in_as users(:one)
end
test "creates event successfully" do
visit new_event_url
fill_in "Name", with: "Company Party"
fill_in "Event date", with: 1.month.from_now.to_date
click_button "Create Event"
assert_text "Event was successfully created"
assert_text "Company Party"
end
test "shows validation errors" do
visit new_event_url
click_button "Create Event"
assert_text "can't be blank"
end
endComponent Tests
# test/components/event_card_component_test.rb
require "test_helper"
class EventCardComponentTest < ViewComponent::TestCase
test "renders event name" do
event = events(:one)
render_inline(EventCardComponent.new(event: event))
assert_text event.name
end
test "renders status badge" do
render_inline(EventCardComponent.new(event: events(:confirmed)))
assert_selector ".badge"
end
test "shows days until for upcoming events" do
event = events(:upcoming)
render_inline(EventCardComponent.new(event: event))
assert_selector "[data-days-until]"
end
endTest Helpers
# test/test_helper.rb
class ActiveSupport::TestCase
fixtures :all
def sign_in_as(user)
post session_url, params: { email: user.email_address, password: "password" }
end
def sign_out
delete session_url
end
endCoverage Requirements
| Layer | Minimum Coverage |
|---|---|
| Models | 90% |
| Services | 95% |
| Queries | 90% |
| Controllers | 80% |
| Overall | 85% |
Checklist
- [ ] Unit tests for all models (validations, scopes, methods)
- [ ] Service tests cover success/failure paths
- [ ] Query tests verify correctness and tenant isolation
- [ ] Controller tests for all endpoints
- [ ] Policy tests for authorization rules
- [ ] System tests for critical user flows
- [ ] Component tests for ViewComponents
- [ ] Fixtures with meaningful names
- [ ] Test helper with authentication methods