Ruby on Rails remains one of the most productive frameworks for building web applications and APIs. Claude Code handles Rails idioms correctly: understanding the ActiveRecord query interface, knowing when to use service objects vs. concerns, generating proper RSpec tests, and avoiding N+1 queries.
This guide covers Rails with Claude Code: model patterns, service objects, background jobs, API mode, and testing.
CLAUDE.md for Rails Projects
## Rails 7.1 Architecture
- Ruby 3.3, Rails 7.1 API mode
- PostgreSQL with ActiveRecord
- Authentication: Devise + devise-jwt (stateless JWT for API)
- Background jobs: Sidekiq + Redis
- Testing: RSpec + FactoryBot + Shoulda-Matchers
## Patterns
- Business logic in app/services/ — not in models or controllers
- Service objects return Result objects: OpenStruct with success?, value, errors
- Fat models are OK for query scopes and validations — not for business logic
- Controllers: 5-7 lines max, delegate everything to services
- N+1 prevention: always includes/eager_load in index queries
- Never skip validations in tests — use FactoryBot properly
Model Patterns
Create an Order model with validations, scopes, and associations.
It belongs to User and has_many OrderItems. OrderItems belong to Product.
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
has_many :order_items, dependent: :destroy
has_many :products, through: :order_items
enum status: {
pending: 'pending',
confirmed: 'confirmed',
shipped: 'shipped',
delivered: 'delivered',
cancelled: 'cancelled'
}, _prefix: true
validates :status, presence: true
validates :shipping_address_id, presence: true
validate :items_must_be_present, on: :create
# Monetary amount stored as cents
monetize :total_cents, as: :total, with_currency: :usd
scope :recent, -> { order(created_at: :desc) }
scope :active, -> { where.not(status: [:cancelled, :delivered]) }
scope :for_date_range, ->(start_date, end_date) {
where(created_at: start_date.beginning_of_day..end_date.end_of_day)
}
scope :with_items, -> { includes(order_items: :product) } # Prevent N+1
def cancellable?
status_pending? || status_confirmed?
end
def total_items
order_items.sum(:quantity)
end
private
def items_must_be_present
errors.add(:base, "must have at least one item") if order_items.empty?
end
end
# app/models/order_item.rb
class OrderItem < ApplicationRecord
belongs_to :order
belongs_to :product
validates :quantity, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates :price_cents, numericality: { greater_than: 0 }
validates :product_id, uniqueness: { scope: :order_id, message: "already in this order" }
before_validation :set_price_from_product, on: :create
def line_total_cents
price_cents * quantity
end
private
def set_price_from_product
self.price_cents ||= product&.price_cents
end
end
Service Objects
Create a service object for placing an order.
It should check stock, create the order in a transaction,
and enqueue a confirmation email.
# app/services/orders/place_order_service.rb
module Orders
class PlaceOrderService
Result = Struct.new(:success?, :order, :errors, keyword_init: true)
def initialize(user:, params:)
@user = user
@params = params
@errors = []
end
def call
validate_stock
return failure if @errors.any?
order = nil
ActiveRecord::Base.transaction do
order = create_order
deduct_stock
OrderConfirmationJob.perform_later(order.id)
end
success(order)
rescue ActiveRecord::RecordInvalid => e
failure(e.record.errors.full_messages)
rescue StockError => e
failure([e.message])
end
private
def validate_stock
@params[:items].each do |item|
product = Product.find(item[:product_id])
if product.stock < item[:quantity]
@errors << "#{product.name}: only #{product.stock} available (requested #{item[:quantity]})"
end
end
end
def create_order
order = @user.orders.new(
shipping_address_id: @params[:shipping_address_id],
status: :pending,
)
@params[:items].each do |item|
product = Product.find(item[:product_id])
order.order_items.build(
product: product,
quantity: item[:quantity],
price_cents: product.price_cents,
)
end
order.total_cents = order.order_items.sum { |i| i.price_cents * i.quantity }
order.save!
order
end
def deduct_stock
@params[:items].each do |item|
rows_updated = Product.where(id: item[:product_id])
.where('stock >= ?', item[:quantity])
.update_all('stock = stock - ?', item[:quantity])
raise StockError, "Insufficient stock for product #{item[:product_id]}" if rows_updated == 0
end
end
def success(order) = Result.new(success?: true, order: order, errors: [])
def failure(errs = @errors) = Result.new(success?: false, order: nil, errors: errs)
end
class StockError < StandardError; end
end
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < Api::V1::BaseController
def index
orders = current_user.orders.recent.with_items.page(params[:page]).per(20)
render json: OrderSerializer.new(orders, meta: pagination_meta(orders))
end
def create
result = Orders::PlaceOrderService.new(
user: current_user,
params: order_params,
).call
if result.success?
render json: OrderSerializer.new(result.order), status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
def destroy
order = current_user.orders.find(params[:id])
unless order.cancellable?
return render json: { error: "Cannot cancel #{order.status} order" }, status: :unprocessable_entity
end
order.update!(status: :cancelled)
render json: OrderSerializer.new(order)
end
private
def order_params
params.require(:order).permit(
:shipping_address_id,
items: [:product_id, :quantity],
)
end
end
Background Jobs with Sidekiq
Create a Sidekiq job that sends order confirmation emails
with retry logic and idempotency.
# app/jobs/order_confirmation_job.rb
class OrderConfirmationJob < ApplicationJob
queue_as :mailers
sidekiq_options retry: 3, dead: false
sidekiq_retries_exhausted do |msg, _ex|
order_id = msg['args'].first
Rails.logger.error("OrderConfirmationJob permanently failed for order #{order_id}")
Sentry.capture_message("Order confirmation email failed", extra: { order_id: order_id })
end
def perform(order_id)
order = Order.includes(:user, order_items: :product).find_by(id: order_id)
unless order
Rails.logger.warn("OrderConfirmationJob: order #{order_id} not found — may have been deleted")
return # Don't retry — order gone
end
# Idempotency: check if already sent
return if order.confirmation_sent_at.present?
OrderMailer.confirmation(order).deliver_now
# Mark as sent to prevent duplicate sends on retry
order.update_column(:confirmation_sent_at, Time.current)
end
end
# config/sidekiq.yml
:concurrency: 10
:queues:
- [critical, 4] # Password reset, payment alerts
- [mailers, 2] # Email delivery
- [default, 1] # Everything else
- [low, 1] # Reports, exports
RSpec Testing
Write RSpec tests for PlaceOrderService.
Cover success case, stock validation failure, and concurrent stock race condition.
# spec/services/orders/place_order_service_spec.rb
RSpec.describe Orders::PlaceOrderService do
let(:user) { create(:user) }
let(:product) { create(:product, stock: 10, price_cents: 2000) }
let(:address) { create(:shipping_address, user: user) }
let(:valid_params) do
{
shipping_address_id: address.id,
items: [{ product_id: product.id, quantity: 2 }],
}
end
subject(:service) { described_class.new(user: user, params: params) }
describe '#call' do
context 'with valid params and sufficient stock' do
let(:params) { valid_params }
it 'returns success' do
result = service.call
expect(result.success?).to be true
end
it 'creates the order with correct total' do
expect { service.call }.to change(Order, :count).by(1)
expect(result.order.total_cents).to eq(4000) # 2 × 2000
end
it 'deducts stock from product' do
expect { service.call }.to change { product.reload.stock }.by(-2)
end
it 'enqueues confirmation email' do
expect { service.call }.to have_enqueued_job(OrderConfirmationJob)
end
end
context 'when stock is insufficient' do
let(:params) { valid_params.merge(items: [{ product_id: product.id, quantity: 15 }]) }
it 'returns failure with error message' do
result = service.call
expect(result.success?).to be false
expect(result.errors).to include(match(/only 10 available/))
end
it 'does not create an order' do
expect { service.call }.not_to change(Order, :count)
end
it 'does not deduct stock' do
expect { service.call }.not_to change { product.reload.stock }
end
end
context 'concurrent stock race condition' do
let(:params) { valid_params.merge(items: [{ product_id: product.id, quantity: 9 }]) }
it 'raises StockError when stock depleted between validation and deduction' do
# Simulate race: change stock to 0 after validation passes
allow_any_instance_of(described_class).to receive(:create_order) do |service|
product.update!(stock: 0) # Another thread grabbed the stock
service.send(:__create_order_original) # Call original
end
# The atomic update_all WHERE clause catches this
result = service.call
expect(result.success?).to be false
end
end
end
def result
@result ||= service.call
end
end
For API authentication with Devise JWT and refresh tokens, see the auth guide. For background job patterns including dead letter queues and progress tracking, see the background jobs guide. The Claude Skills 360 bundle includes Rails skill sets covering API design, Hotwire/Turbo, and performance optimization. Start with the free tier to try Rails code generation.