Showing version 4 bot legacy · api · 2026-08-19T06:46:24Z

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/*.

  • Use when: always as a habit. The tooling to enforce it is a separate,
    much later decision — see the ladder below.

Modular boundaries — namespaces first, Packwerk maybe never

Boundary enforcement is the most over-adopted idea in Rails architecture. The
honest position: almost every app that reaches for Packwerk should have stopped
one rung lower.
Climb only when the rung you're on has demonstrably failed.

Rung 1 — namespaced modules in the directories you already have. Free, zero
tooling, zero config, works today:

app/models/billing/invoice.rb        → Billing::Invoice
app/services/billing/charge.rb       → Billing::Charge
app/policies/billing/invoice_policy.rb

A module is a boundary. Billing:: reaching into Catalog:: internals is visible
in a diff and greppable in review. Most teams never need more than this, and the
ones that do usually needed ownership rather than enforcement.

  • Use when: always. This is the default and the destination for most apps.

Rung 2 — a domain folder under app/. When a slice has enough of its own
models, services and policies that scattering them across app/models/billing,
app/services/billing, app/policies/billing costs more than it buys, invert it:

app/billing/
├─ invoice.rb
├─ charge.rb
├─ invoice_policy.rb
└─ contracts/create_contract.rb

The gotcha that bites everyone here. Rails autoloads every subdirectory of
app that exists at boot (except assets, javascript, views), and — per the
Rails guides — those directories "represent the root namespace: Object". So
app/billing/invoice.rb defines Invoice, not Billing::Invoice. The folder
name does nothing. Fix it explicitly:

# config/initializers/autoloading.rb
module Billing; end
Rails.autoloaders.main.push_dir("#{Rails.root}/app/billing", namespace: Billing)
  • Use when: a slice is big enough that the layer-first split hurts. Costs one
    initializer line per domain and buys a real front-to-back folder.

Rung 3 — Packwerk. Static analysis of constant references that fails the build
when a package reaches somewhere it declared it wouldn't. Actively maintained
(v3.3.0, May 2026) and genuinely good at the one thing it does: holding a line
you have already drawn.
It will not draw the line for you.

  • Use when: all of these are true — multiple teams, a repeatedly violated
    boundary you can name, evidence of breakage from that coupling, and a person
    who owns the migration. Miss the last one and you get a todo file nobody
    burns down.
  • Don't use when: the motivation is "we might scale", tidiness, or a rewrite
    that's really about disliking the current structure. Packwerk charges rent
    immediately and pays out only after the boundary work is finished.

Read Shopify's own retrospective before adopting it. They wrote it, and their
assessment is unusually candid:

  • Privacy checks were removed in v3.0. They "introduced several problems",
    broke Rails conventions, and turned Packwerk "into something it was never
    intended to be: an API design tool."
  • They discussed removing Packwerk from their monolith, "given the costs it
    incurs and the weaknesses and blind spots."
  • It is blind to anything outside Zeitwerk. Code loaded via require,
    autoload or ActiveSupport::Autoload is invisible, so "a package that is
    well-defined according to Packwerk may actually crash with name errors when its
    code is executed."
  • It tells you a violation exists, never how to fix it.
  • Domain-based grouping failed; functional grouping worked. Developers "group
    code into packages based strongly on semantic clues that in many cases have
    little relation to how the code actually runs."
  • They believe they were "likely the first Packwerk user to completely work
    through an entire package todo file, years after its initial release" — which
    tells you how often adoption actually finishes.

The line worth keeping: "It is much harder to bend this behavior to fit your
mental models than it is to bend your mental models to fit what a codebase
actually does."

Rung 4 — engines, then separate services. Engines drag routes, fixtures and
initializers along and Packwerk "doesn't help with sorting through" any of them.
Extract a service only when a slice needs independent scaling or deploys — not for
modularity, which rungs 1–3 already bought.

packs/ is a convention, not a requirement — don't use it

Packwerk's package_paths default is **/ — meaning any directory containing a
package.yml is a package. The packs/ top-level folder comes from the
surrounding gem ecosystem, not from Packwerk, and it is the wrong choice.

Put packages under app/:

# packwerk.yml
package_paths:
  - .           # the root package
  - app/*       # one package per domain folder
app/
├─ billing/
│  ├─ package.yml          # dependencies: ["app/platform"]
│  ├─ invoice.rb
│  └─ charge.rb
├─ catalog/
│  ├─ package.yml
│  └─ product.rb
└─ platform/
   └─ package.yml

Three reasons app/ wins:

  1. Zeitwerk already autoloads it. Every subdirectory of app is an autoload
    path at boot. A packs/ tree is outside app, so it needs autoload wiring
    added — which is most of what the packs-rails-shaped gems exist to do. Choose
    app/ and that entire dependency disappears.
  2. It keeps one answer to "where does code live". packs/billing/app/models/
    and app/models/ is two Rails apps in one repo, and every new file becomes a
    placement argument. Rung 2's layout is the same shape with none of that.
  3. It survives abandoning Packwerk. Delete the package.yml files and the
    gem, and app/billing/ is still a perfectly ordinary namespaced Rails
    directory. Rip Packwerk out of a packs/ layout and you own a migration.

If you'd rather not invert the layer split at all, the smallest possible version
is a package.yml beside a namespace inside the standard directories —
app/models/billing/package.yml. Same enforcement, zero structural change, and
the diff to adopt or revert is a handful of files.

  • Rule: whatever you pick, one layout for the whole app. A half-migrated
    packs/ tree beside a populated app/ is worse than either alone.

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);

Two database roles: app (restricted) and migrator (owner)

RLS is only as strong as the role that connects. A table's owner, any
SUPERUSER, and any role with BYPASSRLS silently ignore every policy
— so if
your app connects as the same role that ran the migrations, RLS is decoration.
Split the roles:

  • myapp_migrator — owns the schema, runs migrations, creates policies.
    Used by rails db:migrate / deploy tasks only. Never by the running app.
  • myapp_app — owns nothing, NOSUPERUSER NOBYPASSRLS, holds only
    SELECT/INSERT/UPDATE/DELETE on tables plus USAGE on sequences. This is the
    role Puma and Sidekiq connect as. Policies actually apply to it.
CREATE ROLE myapp_migrator LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
CREATE ROLE myapp_app      LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;

-- migrator owns the schema; app only uses it
GRANT USAGE ON SCHEMA public TO myapp_app;
ALTER DEFAULT PRIVILEGES FOR ROLE myapp_migrator IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO myapp_app;
ALTER DEFAULT PRIVILEGES FOR ROLE myapp_migrator IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO myapp_app;

-- belt: FORCE makes the policy apply even to the table owner
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE  ROW LEVEL SECURITY;

Add FORCE ROW LEVEL SECURITY anyway — it closes the hole if someone ever points
the app at the owning role by mistake.

Wire it in Rails. Two URLs, one per role; migrations use the privileged one:

# config/database.yml
default: &default
  adapter: postgresql
  url: <%= ENV["DATABASE_URL"] %>            # myapp_app — runtime
  migrations_paths: db/migrate

production:
  primary:
    <<: *default
  primary_migrator:                           # rails db:migrate uses this
    <<: *default
    url: <%= ENV["MIGRATOR_DATABASE_URL"] %>  # myapp_migrator
    migrations_paths: db/migrate
    database_tasks: false

Then set the tenant per request, transaction-scoped (set_config(..., true))
so a pooled connection can never leak one tenant's id into the next checkout:

class ApplicationRecord < ActiveRecord::Base
  def self.with_tenant(account_id)
    transaction do
      connection.exec_query(
        "SELECT set_config('app.current_account_id', $1, true)",
        "set_tenant", [account_id.to_s]
      )
      yield
    end
  end
end

Use the restricted role in development too

The most common way RLS fails in production is that nobody ever exercised it.
Locally everyone connects as their own superuser (postgres, or the macOS
username Homebrew created), every policy is bypassed, and a query missing its
tenant scope looks perfectly fine — until prod, where it returns zero rows or,
worse, the policy was never right and prod is the first place it's tested.

  • Dev and test connect as myapp_app, same as production. Create the role in
    bin/setup / a seed task so a fresh clone gets it automatically.
  • Only db:migrate, db:prepare, and db:schema:load use myapp_migrator
    in every environment, so dev and prod fail the same way.
  • Payoff: a forgotten with_tenant blows up on your laptop, in a failing
    spec, instead of in an incident. Write a spec that connects without a tenant set
    and asserts the query returns no rows — that test is the real proof RLS is on.
  • Gotcha: if dev used to run as owner/superuser, expect a wave of permission
    errors the first time you switch (missing GRANTs on new tables, sequences,
    extensions). That's the point — those same gaps were latent in prod.

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.

Static security analysis — one command, bin/ci

Security scanning that only runs on someone's laptop when they remember doesn't
run. Put every static check behind one entrypoint developers and CI both call,
so "did it pass?" has a single answer.

Rails 8.1 ships this natively: bin/ci driven by a config/ci.rb DSL. On older
Rails, write bin/ci as a shell script that exits non-zero on the first failure —
the value is the single entrypoint, not the DSL.

# config/ci.rb
CI.run do
  step "Setup",     "bin/setup --skip-server"
  step "Style: Ruby",   "bin/rubocop"
  step "Security: Gem audit",      "bin/bundler-audit"
  step "Security: Importmap audit", "bin/importmap audit"
  step "Security: Brakeman",
       "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
  step "Tests: Rails",  "bin/rails test"
  step "Tests: System", "bin/rails test:system"
end

The GitHub Actions workflow then shrinks to one line — run: bin/ci — which means
CI can't drift from what you run locally.

What to put in it:

  • brakeman — Rails-aware SAST: SQL injection, mass assignment, unsafe
    render/redirect_to, XSS, command injection, weak crypto. Run with
    --exit-on-warn; triage real findings and check in config/brakeman.ignore
    (with a reason per entry) rather than lowering confidence thresholds.
  • bundler-audit — gem dependencies against the Ruby Advisory DB.
    bundle audit check --update.
  • ruby_audit — the same for the Ruby and RubyGems versions themselves,
    which bundler-audit doesn't cover.
  • bin/importmap audit — JS dependency CVEs when you use importmaps; use
    yarn npm audit / pnpm audit if you bundle instead.
  • rubocop + rubocop-rails/-rspec/-performance — style is not
    security, but consistent code makes the security diffs readable. Add
    erb_lint for template-level escaping issues.
  • Secret scanning: gitleaks (or trufflehog) over the diff and the
    history — a rotated key still in git history is still leaked. Pair with a
    pre-commit hook so it never lands in the first place.
  • Workflow hardening: actionlint + zizmor if you're on GitHub Actions —
    CI itself is an attack surface (unpinned actions, pull_request_target,
    injectable ${{ }} expressions).
  • Dependabot / Renovate for the upgrade side; the audit steps above are the
    gate. You want both — one opens PRs, the other fails the build.
  • semgrep when you need custom org rules ("never call this helper",
    "no raw SQL outside app/queries") that Brakeman doesn't model.

Rules that keep it honest:

  • Fail the build, don't warn. A scanner whose output is advisory is ignored
    within two sprints.
  • Every suppression carries a reason and an owner. Ignore files are fine;
    anonymous ignore files are technical debt with a security label.
  • Run it on the PR, and on a schedule. New CVEs land against unchanged code —
    a nightly bin/ci run catches what the PR gate structurally can't.
  • Keep it fast. If bin/ci takes 20 minutes nobody runs it locally, and the
    local run is the whole point.
  • Static analysis finds classes of bugs, not your bugs. It does not replace
    authorization policies, RLS, or a review of the actual threat model.

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.

Versioning & releases

Every deploy should carry a real, traceable version — not just a git SHA in your head.
If you deploy with Kamal (Basecamp's own, very Rails-idiomatic tool), auto-bump a
semantic version from your Conventional Commits and tag the release only on a healthy
deploy
, via .kamal/hooks/ pre/post-deploy hooks. Full recipe:
Kamal Auto-Versioning.

  • Tooling: svu (Conventional Commits → semver: feat:→minor, fix:→patch,
    !/BREAKING CHANGE:→major), a pre-deploy hook to preview/guard, a post-deploy
    hook to git tag after success.
  • Surface it: expose the version + git SHA on your deep /health (above) so a running
    instance self-reports exactly what shipped — invaluable during an incident. (In Rails,
    read it from an ENV/initializer rather than the Go ldflags trick in the reference.)
  • Use when: any Kamal-deployed app — so every release is tagged in git and traceable.

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 — same pass also yields the QoE numbers (Web Vitals,
    console errors, page weight), which no request spec will ever give you. Recipe:
    QA and QoE Testing with Playwright MCP;
    the case for measuring it: QA and QoE.
  • 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
Modular boundaries namespaced modules in app/ always — start and usually stop here
Boundary enforcement Packwerk, packages under app/* (never packs/) multiple teams + a named, repeatedly violated boundary + an owner
Multi-tenancy acts_as_tenant + Postgres RLS shared-table SaaS
DB roles app (NOBYPASSRLS) vs migrator (owner) any app using RLS — in dev too
Authorization pundit / action_policy, deny-by-default always
Static analysis bin/cibrakeman, bundler-audit, gitleaks always; fail the build
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
Versioning svu + Kamal pre/post-deploy hooks → git tag traceable releases (see Kamal Auto-Versioning)
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

Sources