Ruby’s Rack middleware model and Rails conventions make for productive web development, but the patterns that scale — service objects, form objects, background jobs with Sidekiq — require deliberate architecture. Claude Code writes idiomatic Ruby, understands Rails conventions deeply, and generates the Rack middleware, service objects, and Sidekiq worker patterns that keep large Rails codebases maintainable.
CLAUDE.md for Ruby/Rails Projects
## Stack
- Ruby 3.3, Rails 7.1
- Database: PostgreSQL via ActiveRecord
- Background jobs: Sidekiq 7 (Redis-backed)
- Testing: RSpec + FactoryBot + Shoulda-matchers
- Code style: Standard Ruby (rubocop-performance, no rubocop-rails)
- Service objects: app/services/, always return a Result object
- No fat models — business logic goes in service objects
- ActiveRecord scopes for reusable query building; no scope chains in controllers
- N+1 queries are bugs — use includes/preload/eager_load
Rack Middleware
# lib/middleware/request_id_middleware.rb
# Every request gets a unique ID — propagates to logs and downstream services
class RequestIdMiddleware
def initialize(app)
@app = app
end
def call(env)
request_id = env['HTTP_X_REQUEST_ID'] || SecureRandom.uuid
# Store in thread-local for access throughout request lifecycle
RequestContext.request_id = request_id
status, headers, body = @app.call(env)
headers['X-Request-Id'] = request_id
[status, headers, body]
ensure
RequestContext.clear
end
end
# config/application.rb
config.middleware.insert_before ActionDispatch::RequestId, RequestIdMiddleware
# lib/middleware/api_rate_limiter.rb — Rack-level rate limiting
class ApiRateLimiter
LIMIT = 100 # requests
WINDOW = 60 # seconds
def initialize(app, redis:)
@app = app
@redis = redis
end
def call(env)
request = Rack::Request.new(env)
return @app.call(env) unless api_request?(request)
identifier = request.env['HTTP_X_API_KEY'] || request.ip
key = "rate_limit:#{identifier}"
count = @redis.incr(key)
@redis.expire(key, WINDOW) if count == 1
if count > LIMIT
retry_after = @redis.ttl(key)
return [
429,
{
'Content-Type' => 'application/json',
'Retry-After' => retry_after.to_s,
'X-RateLimit-Limit' => LIMIT.to_s,
'X-RateLimit-Remaining' => '0',
},
['{"error":"Rate limit exceeded"}'],
]
end
status, headers, body = @app.call(env)
headers['X-RateLimit-Limit'] = LIMIT.to_s
headers['X-RateLimit-Remaining'] = [LIMIT - count, 0].max.to_s
[status, headers, body]
end
private
def api_request?(request)
request.path.start_with?('/api/')
end
end
Service Objects
# app/services/place_order_service.rb
# Service objects: single public method, return a Result
class PlaceOrderService
Result = Data.define(:success, :order, :errors)
def initialize(customer:, cart:, payment_method_id:)
@customer = customer
@cart = cart
@payment_method_id = payment_method_id
end
def call
return Result.new(success: false, order: nil, errors: ['Cart is empty']) if @cart.items.empty?
return Result.new(success: false, order: nil, errors: ['Account suspended']) if @customer.suspended?
order = nil
ActiveRecord::Base.transaction do
order = Order.create!(
customer: @customer,
total_cents: @cart.total_cents,
status: 'pending',
)
@cart.items.each do |item|
order.line_items.create!(
product: item.product,
quantity: item.quantity,
unit_price_cents: item.product.price_cents,
)
end
# Charge card
charge = StripeChargeService.new(
customer_id: @customer.stripe_id,
amount_cents: order.total_cents,
payment_method_id: @payment_method_id,
idempotency_key: "order-#{order.id}",
).call
raise ActiveRecord::Rollback unless charge.success
order.update!(stripe_charge_id: charge.charge_id, status: 'paid')
end
OrderConfirmationMailer.with(order: order).deliver_later
Result.new(success: true, order: order, errors: [])
rescue => e
Rails.logger.error "PlaceOrderService failed: #{e.message}"
Result.new(success: false, order: nil, errors: [e.message])
end
end
# Controller — thin, delegates to service
class OrdersController < ApplicationController
def create
result = PlaceOrderService.new(
customer: current_user,
cart: current_cart,
payment_method_id: params[:payment_method_id],
).call
if result.success
render json: OrderSerializer.new(result.order).serializable_hash, status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
end
ActiveRecord: N+1 Prevention
# Bad: N+1 — loads 1 query for orders, then N queries for customers
orders = Order.where(status: 'pending').limit(50)
orders.each { |o| puts o.customer.email } # 51 queries
# Good: eager load with includes
orders = Order.includes(:customer, line_items: :product).where(status: 'pending').limit(50)
orders.each { |o| puts o.customer.email } # 3 queries
# When you need WHERE on the association: use joins + references
orders = Order.joins(:customer)
.where(customers: { plan: 'enterprise' })
.includes(:line_items)
.order('orders.created_at DESC')
.limit(50)
# Scope for reusable query building
class Order < ApplicationRecord
scope :pending, -> { where(status: 'pending') }
scope :recent, -> { order(created_at: :desc) }
scope :with_details, -> { includes(:customer, line_items: :product) }
# Counter cache: avoid COUNT queries
belongs_to :customer, counter_cache: true
end
# Bulk operations: avoid model callbacks for large updates
# Bad: loads all records into memory
Order.pending.each { |o| o.update!(status: 'expired') }
# Good: single UPDATE statement
Order.pending.where('created_at < ?', 24.hours.ago).update_all(status: 'expired')
Sidekiq Workers
# app/workers/send_order_confirmation_worker.rb
class SendOrderConfirmationWorker
include Sidekiq::Job
sidekiq_options(
queue: 'emails',
retry: 5, # Retry up to 5 times with exponential backoff
backtrace: true,
)
def perform(order_id)
order = Order.includes(:customer, :line_items).find(order_id)
OrderConfirmationMailer.with(order: order).confirmation_email.deliver_now
rescue ActiveRecord::RecordNotFound => e
# Don't retry — the order was deleted
Sidekiq.logger.warn "Order #{order_id} not found, skipping confirmation email"
end
end
# Periodic jobs with sidekiq-cron
# config/initializers/sidekiq_cron.rb
Sidekiq::Cron::Job.load_from_hash({
'expire_pending_orders' => {
cron: '*/5 * * * *', # Every 5 minutes
class: 'ExpirePendingOrdersWorker',
},
'daily_revenue_report' => {
cron: '0 8 * * *', # 8am daily
class: 'DailyRevenueReportWorker',
},
})
# app/workers/expire_pending_orders_worker.rb
class ExpirePendingOrdersWorker
include Sidekiq::Job
sidekiq_options queue: 'default', retry: 3
def perform
# Batch processing — don't load all at once
Order.pending
.where('created_at < ?', 30.minutes.ago)
.in_batches(of: 500) do |batch|
batch.each do |order|
ExpireOrderService.new(order).call
end
end
end
end
RSpec Testing
# spec/services/place_order_service_spec.rb
RSpec.describe PlaceOrderService do
subject(:service) { described_class.new(customer:, cart:, payment_method_id: 'pm_test') }
let(:customer) { create(:customer, :active) }
let(:cart) { create(:cart, :with_items, customer:) }
describe '#call' do
context 'with valid inputs' do
before do
allow(StripeChargeService).to receive(:new).and_return(
instance_double(StripeChargeService, call: double(success: true, charge_id: 'ch_123'))
)
end
it 'creates an order with correct total' do
result = service.call
expect(result.success).to be true
expect(result.order.total_cents).to eq(cart.total_cents)
end
it 'enqueues a confirmation email' do
expect { service.call }.to change(ActionMailer::Base.deliveries, :count).by(1)
end
end
context 'when payment fails' do
before do
allow(StripeChargeService).to receive(:new).and_return(
instance_double(StripeChargeService, call: double(success: false))
)
end
it 'does not create an order' do
expect { service.call }.not_to change(Order, :count)
end
it 'returns failure result' do
result = service.call
expect(result.success).to be false
end
end
context 'with empty cart' do
let(:cart) { create(:cart, customer:) } # No items
it 'returns error without hitting Stripe' do
result = service.call
expect(result.errors).to include('Cart is empty')
expect(StripeChargeService).not_to have_received(:new)
end
end
end
end
For the Kafka event publishing that Rails applications can integrate with, see the Kafka guide. For the authentication patterns that protect Rails APIs, the OAuth2 guide covers session management applicable to Rails Devise configurations. The Claude Skills 360 bundle includes Ruby skill sets covering Rack middleware, service objects, Sidekiq workers, and RSpec patterns. Start with the free tier to try Rails architecture generation.