Showing version 1 bot backfill · api · 2026-06-23 07:11:37

Rails Production Playbook

Rails Production Playbook

A short, opinionated reference for building serious Rails apps. The principles
are language-agnostic — anyone can lift them — but the gems and snippets are
Rails-first. Default posture: boring, explicit, deny-by-default, observable.

Rule of thumb: reach for a pattern when the pain it removes is real, not
hypothetical. Every item below has a "use when" so you don't over-engineer.


Architecture & code design

Sandi Metz discipline — lean controllers, service objects, enums

Keep classes small and single-purpose. Controllers only orchestrate: authenticate,
authorize, call one object, render. Push logic into POROs ("service objects").

  • Guidelines: ≤100 lines/class, ≤5 lines/method, ≤4 params, one job per object.
  • Gems: plain Ruby first; interactor or dry-monads if you want a Result type.
  • Use when: always for controllers; extract a service the moment an action does
    more than one thing or touches more than one model.
  • Use Rails enum for finite states that are attributes; use a state machine
    (below) when transitions have rules.

Service objects + Result types (dry-monads / dry-contracts)

Make success/failure explicit instead of raising for control flow.

  • Gems: dry-monads (Success/Failure, Do notation), dry-validation
    / dry-contracts (typed, composable input validation at the boundary).
  • Use when: multi-step operations that can fail in known ways (payments,
    imports, signups). Validate untrusted input with a contract before the service.
class CreateInvoice
  include Dry::Monads[:result, :do]
  def call(params)
    attrs   = yield InvoiceContract.new.(params).to_monad   # Failure stops the chain
    invoice = yield persist(attrs)
    Success(invoice)
  end
end

Slice / vertical-feature development

Organize by feature, not by layer. A change should touch one slice
front-to-back rather than smearing across app/*. Enforce boundaries in a modular
monolith with Packwerk (packs/), split to services only when a slice needs
independent scaling or deploys.

  • Use when: the app outgrows "one big app/" and teams step on each other.

EventBus / pub-sub

Decouple side effects (emails, audit, search reindex) from the core write.

  • In-process, cheap: ActiveSupport::Notifications or wisper.
  • Durable / event-sourced: rails_event_store.
  • Use when: one action fans out to ≥2 unrelated reactions. Keep the write
    transactional; emit events after_commit so listeners never see rolled-back data.

Enums — named values, never magic strings

Raw strings scattered through code ("pending", "active", where(status: "shipped"))
are typo-prone and have no single source of truth. Centralize the allowed values.

  • Rails enum: maps a column to named values; gives you scopes
    (Order.shipped), predicates (order.shipped?), and setters (order.shipped!).
    Define the mapping explicitly so values are stable — never rely on positional
    integers, and prefer a string-backed column for readability in the DB.
  • Richer enums (behavior/metadata/i18n per value): enumerize, or a small PORO
    registry / value object when each value carries logic. Translate display labels
    via i18n — don't hardcode human strings either.
  • Use when: any attribute drawn from a fixed, known set (status, role, kind,
    priority). One definition, referenced everywhere.
class Order < ApplicationRecord
  enum :status, { draft: "draft", paid: "paid", shipped: "shipped" }, default: :draft
  # → Order.paid, order.paid?, order.shipped!, Order.statuses
end
  • Relationship to state machines: an enum lists the values; a state
    machine
    governs the transitions between them. If moving between values has
    rules, guards, or callbacks (draft → paid → shipped, never draft → shipped),
    let the state machine own the column — it becomes the single source of truth for
    both the allowed states and the legal moves. Plain enum is the right tool only
    when any value can follow any other.

State machines (AASM)

Model lifecycles with explicit, guarded transitions and callbacks.

  • Gems: aasm (attribute-based, ergonomic — status lives on the model);
    statesman (Gusto — stores each transition as a row in its own table, giving a
    built-in audit trail and history, at the cost of more setup);
    state_machines-activerecord (another attribute-based option).
  • Pick: aasm for simple lifecycles on the record; statesman when you need the
    full transition history / audit of how it got to a state.
  • Use when: an object moves through statuses with rules (draft → published → archived), not just a flat enum.

Data & persistence

IDs: bigint internal, UUID external, friendly_id for URLs

  • bigint primary keys for internal joins (fast, compact).
  • uuid (a separate public_id) for anything exposed in APIs/URLs — never leak
    sequential counts or enable ID enumeration.
  • friendly_id for human-readable slugs (/users/jane-doe, /articles/the-title).
  • Use when: any app with a public surface. Add the UUID column from day one.

Multi-tenancy: RLS / ActsAsTenant as first class

Make tenant isolation a property of the data layer, not something each query
remembers to add.

  • acts_as_tenant: sets a current tenant and auto-scopes every query.
  • Postgres RLS (row-level security): the strongest option — the database itself
    refuses cross-tenant rows even if app code forgets. Belt and suspenders.
  • Use when: B2B / SaaS with shared tables. Decide before writing schema;
    retrofitting tenancy is painful. If you don't need tenancy, don't add it.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (account_id = current_setting('app.current_account_id')::bigint);

JSONB for metadata

Use a jsonb column for sparse, schemaless, or fast-evolving attributes —
not as an excuse to avoid real columns for things you query or index heavily.

  • Rails: store_accessor :metadata, :referrer, :utm_source; GIN-index when you
    query inside it.
  • Use when: flexible per-record extras (settings, integration payloads,
    feature config). Promote a key to a real column once you filter/sort on it.

Transactions — when & how

Wrap writes that must succeed or fail together.

  • Do: group related INSERT/UPDATEs; use DB constraints as the real guarantee;
    put side effects in after_commit, never mid-transaction.
  • Don't: make HTTP/network calls inside a transaction (holds locks, can't roll
    back the remote side); don't span a transaction across user think-time.
  • Use advisory locks / with_lock for "check-then-act"; pick an isolation level
    deliberately when you have read-modify-write races.

Auditing (paper_trail / audited / logidze)

Track who changed what, when — for compliance, debugging, and undo.

  • paper_trail: version history with whodunnit, restore previous versions.
  • audited: lighter change log.
  • logidze: Postgres-trigger based (no app-layer overhead).
  • Use when: regulated data, financial records, or anything users dispute.

Security

Policies, zero trust, deny-by-default

Authorization is a first-class layer, not scattered if current_user.admin?.

  • Gems: pundit or action_policy (policy objects per resource);
    rack-attack (rate limit / block); brakeman + bundler-audit in CI.
  • Posture: deny by default — no policy means no access. Authorize every
    action; never trust client-supplied IDs, scopes, or roles. Strong params always.
  • Use when: always. Add a default deny and opt routes in.

Secrets vs config — credentials vs ENV vs YAML

Three buckets, don't mix them:

  • .yml-backed config objects for non-secret, environment-shaped values:
    URLs, database names, endpoints, timeouts, feature defaults.
    Gems: config (Settings.x.y) or anyway_config.
  • Rails encrypted credentials for secrets: DB passwords, integration API
    keys, signing keys (rails credentials:edit, committed encrypted).
  • ENV only for runtime-start values that differ per host/boot and aren't
    secret by nature (e.g. which credentials key to load, RAILS_MAX_THREADS).
  • Rule: if leaking it is a breach → credentials. If it just shapes the
    environment → yml. If the platform injects it at boot → ENV.

Reliability & resilience

HTTP errors the RFC way (RFC 9457 problem+json)

Return machine-readable errors with a stable shape, not ad-hoc JSON.

  • Shape: type, title, status, detail, instance (+ errors, trace_id).
  • Rails: rescue_from → render application/problem+json. No dedicated gem
    needed; a thin renderer is enough.
  • Use when: any API with external consumers (incl. your own SPA / agents).
rescue_from Pundit::NotAuthorizedError do |e|
  render json: { type: "/errors/forbidden", title: "Forbidden",
                 status: 403, detail: e.message, trace_id: request.request_id },
         status: :forbidden, content_type: "application/problem+json"
end

Idempotency

Make retried writes safe — same request, same result, no duplicates.

  • How: accept an Idempotency-Key header; store key + response; replay on
    repeat; reject body mismatch (422). Back it with a unique index.
  • Gems: roll your own table, or sidekiq-unique-jobs for job-level dedup.
  • Use when: payments, order creation, anything a client/network may retry.

Circuit breakers

Stop hammering a failing dependency; fail fast and recover automatically.

  • Gems: stoplight (general), semian (Shopify — for net/http, MySQL, Redis),
    faraday-retry for retries with backoff.
  • Use when: you call flaky third parties or internal services over the network.

Feature flags

Decouple deploy from release; kill-switch risky paths; gradual rollout.

  • Gem: flipper (+ flipper-active_record, flipper-ui).
  • Use when: trunk-based dev, canary releases, per-account betas, ops kill-switch.

/health — real readiness, not just "the web server is up"

Verify the whole boot: DB reachable, migrations current, Redis/queue up,
required integrations reachable. Separate liveness (am I running?) from
readiness (can I serve traffic?).

  • Rails 7.1+: /up exists but only proves the process booted — extend it.
  • Gems: okcomputer or health_check for multi-dependency checks.
  • Use when: any real deployment — load balancers and orchestrators gate on it.

Scalability

  • Vertical: bigger box first — simplest win until a single node is maxed.
  • Horizontal: more app servers behind a balancer; tune Puma workers/threads
    and the DB connection pool together; move work to background jobs
    (Sidekiq / GoodJob / Solid Queue).
  • Database: read replicas + Rails multi-DB (connects_to), then sharding only
    when a single primary is the bottleneck. Cache aggressively (Solid Cache,
    Russian-doll fragment caching).
  • Use when: scale to the next bottleneck you can measure — not preemptively.

Observability

You can't fix what you can't see. Aim for the three pillars + errors.

  • Logs: structured/JSON (lograge + custom fields, request id, tenant id).
  • Metrics: yabeda → Prometheus → Grafana; instrument with
    ActiveSupport::Notifications.
  • Traces: OpenTelemetry (opentelemetry-ruby) across web → job → DB → HTTP.
  • Errors: Sentry / Honeybadger with release + user/tenant context.
  • Use when: before you need it. Wire request-id + tenant-id through every log
    line so an incident is greppable end-to-end. (Plug your own stack in here.)

Correlation IDs — one thread through everything

A single id that follows one logical operation end-to-end: inbound request →
background jobs → outbound HTTP → emitted events → every log line. It turns "what
actually happened to this request?" from archaeology into a single grep.

  • Vs. request id: a request/trace id is per-hop; a correlation id spans the
    whole business operation across processes and async boundaries. Often you carry
    both.
  • How: accept it at the edge (X-Correlation-ID header) or mint one; stash it in
    ActiveSupport::CurrentAttributes so any code can read it without threading it
    through every method; then propagate it deliberately —
    • into every structured log line (and your metrics/trace context),
    • echoed back in the response and in problem+json (trace_id),
    • copied into background job arguments/metadata (e.g. a Sidekiq client
      middleware), so async work keeps the same id,
    • forwarded as a header on outbound service calls,
    • attached to emitted events (see EventBus) so reactions stay traceable.
  • Gems/Rails: ActiveSupport::CurrentAttributes, ActionDispatch::RequestId
    (X-Request-Id); a small Sidekiq middleware pair to set/read it on jobs.
  • Use when: anything that crosses more than one process, job, or service — i.e.
    every non-trivial system. Pairs naturally with idempotency keys and the
    EventBus.

Testing

The suite (rspec, factories, fakes, end-to-end)

  • rspec-rails as the framework; table-driven where it fits.
  • factory_bot + faker for test data; prefer factories over fixtures.
  • Fakes/stubs for I/O: webmock / vcr for HTTP, in-memory fakes for adapters
    — never hit real third parties in tests.
  • End-to-end user-story tests: drive real flows (sign up → do the thing → see
    result). capybara + cuprite, or Playwright via MCP for browser-level
    journeys an agent can run.
  • Use when: unit-test logic-heavy POROs; integration-test the slice; reserve a
    handful of slow E2E tests for critical revenue paths.

Performance & N+1 testing

Catch slow queries and fan-out in CI, not in production.

  • Gems: prosopite (N+1 detection — strict, low false positives; pair with
    pg_query), bullet (alternative), test-prof (let_it_be, factory profiling),
    rspec-benchmark / derailed_benchmarks for memory & boot.
  • Use when: any list/index endpoint, any view that loops over associations.
    Fail the build on a new N+1.

User feedback

Errors and guidance are product, not afterthoughts.

  • Errors: human-readable HTML pages for browsers, problem+json for APIs
    (above). Tell the user what to do next, surface a trace id for support.
  • Flash / notifications: noticed for multi-channel (in-app, email) notices.
  • Onboarding / tutorials: shepherd.js / intro.js product tours.
  • Use when: every user-facing failure and every first-run experience.

Async & agentic engineering

  • Background work: Sidekiq / GoodJob / Solid Queue for anything slow,
    retryable, or fan-out. Jobs should be idempotent (see above) and small.
  • Agentic subagents: decompose AI work into bounded, single-purpose subagents
    with pre-fetched context; orchestrate sequentially or in parallel waves;
    verify outputs with an independent pass (trust-but-verify). Treat an LLM call
    like any flaky network dependency — wrap it (timeouts, retries, circuit breaker)
    and make the surrounding job idempotent.
  • Use when: long-running, parallelizable, or AI-driven work that shouldn't
    block the request cycle.

Quick reference

Concern Reach for Use when
Multi-tenancy acts_as_tenant + Postgres RLS shared-table SaaS
Authorization pundit / action_policy, deny-by-default always
Secrets Rails credentials leaking = breach
Config config / anyway_config (yml) non-secret env shape
Runtime boot ENV per-host/per-boot
API errors RFC 9457 problem+json any external API
Idempotency Idempotency-Key + unique index retryable writes
Circuit breaker stoplight / semian flaky dependencies
Auditing paper_trail / audited / logidze regulated/disputed data
Flexible attrs jsonb + store_accessor sparse/evolving metadata
Feature flags flipper decouple deploy/release
Enums Rails enum / enumerize fixed value set, no transition rules
State machine aasm / statesman guarded lifecycles (owns the enum column)
Result types dry-monads + dry-contracts fail-able multi-step ops
Events ActiveSupport::Notifications / rails_event_store fan-out side effects
Health okcomputer (deep /health) every deployment
N+1 / perf prosopite, test-prof list/index endpoints
Testing rspec, factory_bot, vcr, Playwright(MCP) everywhere
Observability OTel, yabeda, lograge, Sentry before you need it
Correlation ID X-Correlation-ID + CurrentAttributes trace one op across processes
Background/agents Sidekiq/Solid Queue, bounded subagents slow/parallel/AI work
IDs bigint internal · uuid external · friendly_id public surfaces
Scale vertical first, then horizontal + replicas at a measured bottleneck