examples/rails-app-CLAUDE.md
Real-world example for a Rails 8 monolithic web application with Hotwire, ViewComponent, and the Solid stack. Copy this to your project root and customize for your service.
Stack: Ruby 3.3+, Rails 8.x, PostgreSQL 16, SolidQueue, SolidCache, SolidCable, Hotwire (Turbo + Stimulus), ViewComponent, Tailwind CSS, RSpec, FactoryBot, Capybara, Kamal
Architecture: Full-stack Rails monolith. Server-rendered with Hotwire for interactivity rather than an SPA. Database-backed Solid stack replaces Redis for background jobs, cache, and WebSockets. ViewComponent for testable view logic. Service objects for business operations. Deployed via Kamal to self-managed Linux hosts.
# frozen_string_literal: true at the top of every Ruby filekey:) over hash rockets (:key =>) unless the key is not a symbolbin/ wrappers (bin/rails, bin/rspec, bin/rubocop) instead of bundle exec directlyputs, pp, debugger, or binding.pry in committed code; use Rails.logger.<level> for loggingdefault_scope; use named scopes that callers opt into.includes, .preload, or .eager_load depending on need to avoid N+1 querieshas_many where the count is displayed in listsbefore_validation :normalize_email); anything with side effects belongs in a service# BAD: N+1 query
posts = Post.published
posts.each { |post| post.author.name } # one query per post
# GOOD: Single query with eager load
posts = Post.published.includes(:author)
posts.each { |post| post.author.name }
bin/rails generate authentication) or Devise for more complex flowsauthorize call or an explicit skip_authorization with a documented reasonparams.permit!ActiveJob::DeserializationError when records are deleted between enqueue and executeperform methods must be idempotent; assume they will run more than onceretry_on and discard_onSendInvoiceJob, ExportAccountingJob), not by nounwith_lock) for high-concurrency scenariosApplicationCable::Connection#connect; never trust the client to identify itselfsubscribed method before calling stream_frombroadcasts_to, broadcast_replace_later_to) for view updates over hand-written channelsProduction deploys via Kamal:
config/deploy.yml is the source of truth for servers, registry, and environment config.kamal/secrets references secrets from the host environment; the file is committed, the secrets are notapp/errors/ or lib/errors/, autoloaded as configured); one error class per failure moderescue_from sparingly in controllers; let the default Rails error handling do its jobapp/services/, namespaced by domain (Invoices::Create, not InvoiceCreator)app/
models/ # ActiveRecord models. Persistence and domain logic close to the data.
controllers/ # HTTP request handling. Thin orchestration only.
views/ # ERB templates. No business logic.
components/ # ViewComponent classes. View logic that needs tests.
services/ # Service objects under domain namespaces.
forms/ # Form objects for multi-model forms.
queries/ # Query objects for reusable, composable ActiveRecord queries.
jobs/ # Background jobs. SolidQueue or Sidekiq.
mailers/ # ActionMailer classes.
channels/ # ActionCable channels. Real-time WebSocket connections.
policies/ # Pundit authorization policies, one per resource.
errors/ # Custom domain error classes.
config/
routes.rb
database.yml
credentials/
production.yml.enc # Encrypted production credentials.
deploy.yml # Kamal deploy configuration.
db/
migrate/ # Migrations, committed and reversible.
seeds.rb
spec/
models/
services/
components/
system/ # Capybara system tests.
factories/ # FactoryBot definitions.
support/ # Shared spec helpers.
# app/services/invoices/create.rb
module Invoices
class Create
Result = Data.define(:success?, :invoice, :errors)
def self.call(...) = new(...).call
def initialize(params:, user:)
@params = params
@user = user
end
def call
invoice = build_invoice
ApplicationRecord.transaction do
invoice.save!
end
begin
send_notifications(invoice)
rescue StandardError => e
Rails.logger.error("Notification dispatch failed for invoice #{invoice.id}: #{e.message}")
end
Result.new(success?: true, invoice: invoice, errors: nil)
rescue ActiveRecord::RecordInvalid => e
Result.new(success?: false, invoice: e.record, errors: e.record.errors)
end
private
attr_reader :params, :user
def build_invoice
invoice = user.invoices.new(params.except(:line_items))
invoice.line_items.build(params[:line_items])
invoice.total = invoice.line_items.sum(&:amount)
invoice
end
def send_notifications(invoice)
InvoiceMailer.created(invoice).deliver_later
ExportAccountingJob.perform_later(invoice.id)
end
end
end
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
before_action :require_authentication # Rails 8 generator default; use authenticate_user! with Devise
def create
authorize Invoice
result = Invoices::Create.call(params: invoice_params, user: current_user)
if result.success?
redirect_to result.invoice, notice: "Invoice created"
else
@invoice = result.invoice
render :new, status: :unprocessable_entity
end
end
private
def invoice_params
params.require(:invoice).permit(:customer_id, line_items: %i[description amount])
end
end
# app/queries/invoices/overdue.rb
module Invoices
class Overdue
def self.call(...) = new(...).call
def initialize(scope: Invoice.all, as_of: Time.current)
@scope = scope
@as_of = as_of
end
def call
scope
.where(status: :sent)
.where(due_date: ..as_of)
.where.not(id: paid_invoice_ids)
.includes(:customer, :line_items)
end
private
attr_reader :scope, :as_of
def paid_invoice_ids
Payment.where(created_at: ..as_of).pluck(:invoice_id)
end
end
end
# app/jobs/export_accounting_job.rb
class ExportAccountingJob < ApplicationJob
queue_as :exports
retry_on AccountingApi::TransientError, wait: :polynomially_longer, attempts: 5
discard_on AccountingApi::PermanentError
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
return if invoice.exported_at.present? # local idempotency check
idempotency_key = "invoice-export-#{invoice.id}"
AccountingApi.export(invoice, idempotency_key: idempotency_key)
invoice.update!(exported_at: Time.current)
end
end
# spec/services/invoices/create_spec.rb
require "rails_helper"
RSpec.describe Invoices::Create do
let(:user) { create(:user) }
let(:customer) { create(:customer, user: user) }
let(:params) do
{
customer_id: customer.id,
line_items: [{ description: "Consulting", amount: 100_000 }] # $1,000.00 in cents
}
end
describe ".call" do
it "creates an invoice with the expected total" do
result = described_class.call(params: params, user: user)
expect(result).to be_success
expect(result.invoice).to be_persisted
expect(result.invoice.total).to eq(100_000)
end
it "enqueues a notification email" do
expect {
described_class.call(params: params, user: user)
}.to have_enqueued_mail(InvoiceMailer, :created)
end
it "returns errors when validation fails" do
result = described_class.call(params: params.merge(customer_id: nil), user: user)
expect(result).not_to be_success
expect(result.errors[:customer]).to include("must exist")
end
end
end
# Rails
RAILS_ENV=production
RAILS_MASTER_KEY= # decrypts config/credentials/production.yml.enc
SECRET_KEY_BASE= # auto-generated; never commit
# Database
DATABASE_URL=postgres://user:pass@host:5432/myapp_production
# SolidQueue, SolidCache, SolidCable
# These default to the primary database; configure a separate one for higher load:
QUEUE_DATABASE_URL=postgres://user:pass@host:5432/myapp_queue
CACHE_DATABASE_URL=postgres://user:pass@host:5432/myapp_cache
# Kamal deploy
KAMAL_REGISTRY_PASSWORD=
KAMAL_DEPLOY_USER=
# Application secrets (also storable in Rails encrypted credentials)
STRIPE_API_KEY=
SENTRY_DSN=
For most secrets, prefer Rails encrypted credentials (bin/rails credentials:edit -e production) over environment variables. ENV vars are appropriate for infrastructure config that varies per host; credentials are appropriate for application secrets that travel with the codebase.
# Run the full suite
bin/rspec
# Run a single file or directory
bin/rspec spec/services/invoices/
bin/rspec spec/services/invoices/create_spec.rb
# Run only the last failures
bin/rspec --only-failures
# Run with random ordering (default) seeded for reproducibility
bin/rspec --seed 12345
# Run system tests
bin/rspec spec/system/
# Coverage report (SimpleCov)
COVERAGE=true bin/rspec
Coverage target is 90% line coverage as a floor, not a goal. Sharp tests with 85% beat exhaustive tests with 100%. System tests use Capybara with the rack_test driver by default and switch to headless Chrome only when JavaScript is required.
# Planning
/plan "Add invoice PDF export with line item subtotals"
# Test-first development
/tdd # RSpec-based TDD workflow
# Review
/code-review # General quality check
/security-scan # Brakeman + dependency audit
# Verification
/verify # Lint, type-check, test, security scan in one pass
main, named <type>/<short-description> (e.g., feat/invoice-pdf-export, fix/n-plus-one-on-dashboard)feat: new features, fix: bug fixes, refactor: code changesmain; review according to your team's policy and CI must be green to mergemain; force-pushing feature branches is fine