Claude Code for Ruby on Rails: Models, Controllers, Services, and Testing — Claude Skills 360 Blog
Blog / Development / Claude Code for Ruby on Rails: Models, Controllers, Services, and Testing
Development

Claude Code for Ruby on Rails: Models, Controllers, Services, and Testing

Published: August 12, 2026
Read time: 9 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free