Ruby on Rails
Purpose
Build Rails applications with clean MVC separation, service objects, ActiveRecord optimization, API mode conventions, Sidekiq background jobs, and RSpec testing patterns.
Agent Protocol
Trigger
User request includes: Rails, Ruby on Rails, Rails API, ActiveRecord, Rails controller, Rails model, Rails service, Rails job, Rails spec, Rails migration.
Input Context
- Rails version (7.0+, 7.1+)
- Ruby version (3.1+, 3.2+, 3.3+)
- Database (PostgreSQL, MySQL, SQLite)
- Mode (API-only, full MVC)
- Auth (Devise, JWT, OAuth)
- Background jobs (Sidekiq, GoodJob, Solid Queue)
Output Artifact
Controller, model, service object, job class, migration, RSpec test.
Response Format
Produce artifact directly. No preamble, no postamble, no explanations.
Completion Criteria
- Controller delegates to service objects
- ActiveRecord model with scopes, validations, associations
- Service object encapsulates business logic
- Background job with retry configuration
- Request spec covers CRUD with JSON
- Migration with proper indexes
Max Response Length
4096 tokens
Architecture Decision Trees
Service Objects vs PORO Models vs Concerns
| Criterion | Service Object | Fat Model | Concern |
|---|---|---|---|
| Business logic location | app/services/ |
app/models/ |
app/concerns/ |
| Testability | Easy (isolated) | Moderate (needs DB) | Moderate |
| Reusability | High (injectable) | Low (AR callbacks) | Medium (mixed into models) |
| Complexity per unit | Low | High (grows unbound) | Medium |
| Rails conventions | No (manual) | Yes (default) | Yes (Rails 4+) |
Decision: Complex business rules → Service Object. Simple validations/scopes → Model. Cross-model behavior → Concern.
ActiveRecord vs ActiveRecord::Base vs Sequel
| Criterion | ActiveRecord | Sequel | ROM.rb |
|---|---|---|---|
| Rails integration | Native | Adapter | Adapter |
| Performance | Moderate | High | High |
| Query interface | Rich DSL | Sequel DSL | Relation-based |
| Associations | Declarative | Declarative | Explicit |
| Migration system | Built-in | Built-in | Standalone |
Decision: Rails project → ActiveRecord. Heavy data pipeline → Sequel. Hexagonal/clean arch → ROM.rb.
Workflow
Step 1: Rails API Setup
rails new my_api --api --database=postgresql --skip-test
cd my_api
Step 2: Directory Conventions (API Mode)
app/
controllers/
application_controller.rb
api/
v1/
users_controller.rb
orders_controller.rb
models/
user.rb
order.rb
services/
users/
create_service.rb
update_service.rb
orders/
place_order_service.rb
serializers/
user_serializer.rb
order_serializer.rb
jobs/
process_order_job.rb
policies/
user_policy.rb
config/
routes.rb
spec/
requests/
users_spec.rb
services/
users/
create_service_spec.rb
Step 3: Routes
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:index, :show, :create, :update, :destroy]
resources :orders, only: [:index, :show, :create]
end
end
end
Step 4: ActiveRecord Model
# app/models/user.rb
class User < ApplicationRecord
# Enums
enum :role, { user: 0, admin: 1, moderator: 2 }, default: :user
# Validations
validates :email, presence: true, uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true, length: { minimum: 2, maximum: 100 }
# Scopes
scope :active, -> { where(active: true) }
scope :by_role, ->(role) { where(role: role) }
scope :recent, -> { order(created_at: :desc) }
scope :search, ->(query) {
where('name ILIKE :q OR email ILIKE :q', q: "%#{sanitize_sql_like(query)}%")
}
# Associations
has_many :orders, dependent: :destroy
# Callbacks
after_create :send_welcome_email
private
def send_welcome_email
UserMailer.welcome(self).deliver_later
end
end
# db/migrate/xxxx_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users, id: :uuid do |t|
t.string :name, null: false
t.string :email, null: false
t.integer :role, default: 0, null: false
t.boolean :active, default: true, null: false
t.timestamps
end
add_index :users, :email, unique: true
add_index :users, :role
add_index :users, [:active, :role]
end
end
Step 5: Serializer
# app/serializers/user_serializer.rb
class UserSerializer
include JSONAPI::Serializer
attributes :name, :email, :role, :active
attribute :created_at do |user|
user.created_at.iso8601
end
has_many :orders, serializer: OrderSerializer
end
Step 6: Service Object
# app/services/users/create_service.rb
module Users
class CreateService < ApplicationService
attr_reader :params
def initialize(params)
@params = params
end
def call
validate_email_uniqueness!
user = User.new(user_attributes)
ActiveRecord::Base.transaction do
user.save!
create_audit_log(user)
end
Result.success(user)
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.record.errors.full_messages)
rescue ActiveRecord::RecordNotUnique
Result.failure(['Email has already been taken'])
end
private
def user_attributes
{
name: params[:name],
email: params[:email].downcase.strip,
role: params[:role] || :user,
}
end
def validate_email_uniqueness!
if User.exists?(email: params[:email])
raise ActiveRecord::RecordNotUnique, "Email taken"
end
end
def create_audit_log(user)
AuditLog.create!(action: 'user_created', target: user)
end
end
end
# app/services/application_service.rb
class ApplicationService
Result = Struct.new(:success?, :data, :errors, keyword_init: true)
def self.call(...)
new(...).call
end
end
Step 7: Thin Controller
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
before_action :authenticate_user!
before_action :set_user, only: [:show, :update, :destroy]
def index
users = User.active.recent.page(params[:page]).per(params[:per_page] || 20)
render json: UserSerializer.new(users).serializable_hash
end
def show
render json: UserSerializer.new(@user).serializable_hash
end
def create
result = Users::CreateService.call(user_params.to_h)
if result.success?
render json: UserSerializer.new(result.data).serializable_hash, status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
def update
result = Users::UpdateService.call(@user, user_params.to_h)
if result.success?
render json: UserSerializer.new(result.data).serializable_hash
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
def destroy
@user.update!(active: false)
head :no_content
end
private
def set_user
@user = User.find(params[:id])
end
def user_params
params.permit(:name, :email, :role)
end
end
end
end
Step 8: Background Job
# app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob
queue_as :default
retry_on StandardError, attempts: 3, wait: :exponentially_longer
discard_on ActiveRecord::RecordNotFound
def perform(order_id)
order = Order.find(order_id)
InventoryService.new.reserve_items(order)
order.update!(status: :processing)
OrderMailer.confirmation(order).deliver_later
rescue InsufficientInventoryError => e
order.update!(status: :failed, failure_reason: e.message)
raise
end
end
Implementation Patterns
Pattern: Query Object
# app/queries/user_orders_query.rb
class UserOrdersQuery
def initialize(relation = Order.all)
@relation = relation
end
def by_user(user_id)
@relation.where(user_id: user_id)
end
def with_status(status)
@relation.where(status: status)
end
def recent_first
@relation.order(created_at: :desc)
end
def paginated(page:, per_page: 20)
@relation.page(page).per(per_page)
end
end
Pattern: Policy Object
# app/policies/user_policy.rb
class UserPolicy
def initialize(current_user, target_user)
@current_user = current_user
@target_user = target_user
end
def show?
@current_user == @target_user || @current_user.admin?
end
def update?
@current_user == @target_user || @current_user.admin?
end
def destroy?
@current_user.admin?
end
end
Production Considerations
Database Performance
- Add
id: :uuidfor distributed systems,:bigintfor sequential - Index all foreign keys and frequently queried columns
- Use
includes(:associations)for eager loading — never lazy load in views - Use
pluckfor single column queries instead of loading full records - Use
find_each/in_batchesfor large data processing (notall.each) - Connection pooling:
pool: ENV.fetch('RAILS_MAX_THREADS', 5)in database.yml
Sidekiq Configuration
# config/sidekiq.yml
:concurrency: 5
:queues:
- critical
- default
- low
:schedule:
cleanup_job:
cron: "0 0 * * *"
class: CleanupJob
Anti-Patterns
| Anti-Pattern | Why | Fix |
|---|---|---|
| Callbacks for cross-model logic | Hidden dependencies, hard to test | Service objects |
| Fat models with > 200 lines | SRP violation, unmaintainable | Extract service/query objects |
| N+1 queries in serializers | 100+ queries per response | includes in controller |
all.each on large tables |
Memory exhaustion | find_each or in_batches |
Logic in before_action |
Hard to trace, test | Policy objects or direct checks |
| Strong params in controller | Repetitive across actions | Permitted params in service object |
Security Considerations
has_secure_passwordfor bcrypt — never store plain passwords- Devise or JWT for auth — never custom token implementation
- Strong parameters in every controller — prevent mass assignment
before_action :authenticate_user!on protected controllers- SQL injection: ActiveRecord is safe (parameterized), but
where("name = '#{param}'")is not config.force_ssl = truein production- CORS gem (
rack-cors) for API mode — restrict origins - Rate limiting with
rack-attackgem
Testing Strategies
# spec/requests/api/v1/users_spec.rb
RSpec.describe 'Users API', type: :request do
describe 'POST /api/v1/users' do
let(:valid_params) { { name: 'John', email: 'john@test.com' } }
it 'creates a new user' do
post '/api/v1/users', params: valid_params, as: :json
expect(response).to have_http_status(:created)
expect(json['data']['attributes']['email']).to eq('john@test.com')
end
it 'returns 422 with invalid params' do
post '/api/v1/users', params: { name: '' }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
Use factory_bot_rails for test data. Use shoulda-matchers for model specs. Use database_cleaner for test isolation. Use webmock/vcr for external HTTP. Use rspec-sidekiq for job testing.
Rules
- Controllers are thin — one line of business logic per action.
- Service objects in
app/services/— call with.call(params)returningResult. - ActiveRecord models under 150 lines — validations, scopes, associations only.
- Serializers via
jsonapi-serializeroractive_model_serializers. - Background jobs have
retry_onwith limits anddiscard_onfor known failures. - All database migrations have
add_indexfor foreign keys and filtered columns. - API mode Rails — no views, no cookies (token-based auth).
References
- references/rails-active-record.md — ActiveRecord Best Practices
- references/rails-api-conventions.md — Rails API Conventions
- references/rails-api-design.md — Rails API Design
- references/rails-background-jobs.md — Background Jobs
- references/rails-performance-tuning.md — Performance Tuning
- references/rails-performance.md — Rails Performance
- references/rails-security.md — Rails Security
- references/rails-testing.md — Testing Rails Applications
Handoff
Hand off to backend/universal/api-response/SKILL.md for API response formatting or backend/universal/backend-testing/SKILL.md for test patterns.
Implementation Patterns
Factory Pattern for Module Creation
function createModule<T>(config: ModuleConfig): T { const dependencies = initializeDependencies(config); const module = new Module(dependencies); module.hooks.onInit(); return module as T; }
Builder Pattern for Complex Configuration
class ConfigBuilder { private config: AppConfig = new AppConfig(); withDatabase(url: string): ConfigBuilder { ... } withCache(ttl: number): ConfigBuilder { ... } withLogging(level: string): ConfigBuilder { ... } build(): AppConfig { return this.config; } }
Production Considerations
Deployment Checklist
- Production build with optimizations enabled
- Environment variables configured per environment
- Health check endpoint responds correctly
- Error tracking and monitoring integrated
- Logging level configured (not debug in production)
- Resource limits configured
- Database migrations applied
- Static assets built and served from CDN or cache
- Feature flags toggled appropriately
- Rollback plan documented and tested
Monitoring and Alerting
| Metric | Threshold | Severity | Action |
|---|---|---|---|
| Error rate | > 1% | Critical | Rollback or fix |
| p95 latency | > 500ms | Warning | Profile and optimize |
| Uptime | < 99.9% | Critical | Investigate infrastructure |
| Memory usage | > 80% | Warning | Check for leaks |
| CPU usage | > 80% | Warning | Scale up or optimize |
Rules
- Prefer composition over inheritance
- Favor immutable data structures
- Use dependency injection for testability
- Keep functions pure when possible — no side effects
- Fail fast with clear error messages
- Don't repeat yourself (DRY) — extract shared logic
- Keep it simple (KISS) — avoid unnecessary complexity
- You aren't gonna need it (YAGNI) — build what's required
- Separate concerns — single responsibility per module
- Code to interfaces, not implementations
- Write self-documenting code — clear names over comments
- Prefer standard library over third-party dependencies
- Handle errors explicitly — no silent failures
- Validate inputs at boundaries
- Log at appropriate levels (debug, info, warn, error)