Rails Engineering: Request to Model
Rails Engineering: Request to Model
An opinionated reference for the layers between the router and a database row.
Companion to the Rails Production Playbook
— that one is organised by concern (security, persistence, observability); this
one is organised by the path a request takes, in order. Default posture:
deny-by-default, explicit failure, one job per object.
Rule of thumb: every layer below must answer exactly one question. If you can't
say which question a class answers in one sentence, it's two classes.
Stock Rails names two places between the socket and the table: controller and
model. Everything else — where authorization lives, where a malformed body is
refused, where the tenant is pinned, what a failure is — the framework leaves
to you. That silence is why "fat model, skinny controller" was ever a debate: no
opinion ships in the box, so every codebase invents one, late, under pressure.
Here is the opinion. Nine seams, each with one job:
Request
│
├─ 1 Router thin, resourceful, no logic
├─ 2 ApplicationController deny-by-default: authN + verify_authorized
├─ 3 permitted_params is this body even the right SHAPE?
├─ 4 within_tenant whose data may this transaction see?
├─ 5 Policy may THIS identity do this to THIS record?
├─ 6 Contract are the values valid in isolation?
├─ 7 Service do the thing → Success | Failure
├─ 8 Query a read that is more than a scope
├─ 9 Model domain truth, shared definitions
│
└─ Presenter → props/JSON or problem+json on refusal
The single sentence the rest of this document elaborates:
A controller maps HTTP to a Result and nothing else. A service does one thing
and returns a Result. A model knows its own truth.
1. Routes — resourceful, shallow, no logic
Routes name resources. They do not branch, they do not authorize, and a custom
verb is a smell that a noun is missing.
# config/routes.rb
resources :projects, only: %i[index show create update] do
resources :comments, only: %i[create update destroy], shallow: true
resource :archive, only: %i[create destroy] # not `post :archive`
end
namespace :api do
namespace :v1 do
resources :projects, only: %i[index show create]
end
end
- Rule: if you want
post :archiveon a member, you want anArchive
resource withcreate/destroy. The verb pair you get for free is exactly the
pair you need (archive / unarchive). shallow: trueso nested members don't carry a parent id they never use.- Use when: always. The browser namespace and the machine namespace (
/api,
/mcp) are separate route trees with separate base controllers, because they
have different auth, different error shapes and different lifecycles.
2. ApplicationController — protection is inherited, bypass is written
The first decision is not whether to authenticate, it's how it's attached —
because that decides what a mistake costs.
class ApplicationController < ActionController::Base
# Deny-by-default. EVERY controller is authenticated and tenant-scoped unless it
# explicitly opts out. A forgotten `include` can no longer default a route open.
include Authentication # before_action :authenticate!, around_action :within_tenant
include Pundit::Authorization
include ProblemDetails # RFC 9457 rescue_from mappings
# No authorize! ran during the action? Raise. The omission fails in dev and in
# every request spec, instead of silently shipping an open route.
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
private
# Authorize the SCOPED identity, never the raw account. See §5.
def pundit_user = Current.membership
end
# A public controller opts OUT, visibly, in one place:
class Public::SessionsController < ApplicationController
skip_before_action :authenticate!
skip_around_action :within_tenant
skip_after_action :verify_authorized
end
- Rule: if protection is something a controller adds, forgetting it ships an
open route and nothing complains. If protection is inherited and bypass must be
written, the same mistake ships a locked route — visible immediately, safe
while visible. Choose loud-and-closed over silent-and-open. - Gems:
pundit(verify_authorized) oraction_policy(same helper name). - Use when: always, from commit one. Retrofitting deny-by-default means
auditing every existing action.
Know your framework's silent default. ActionPolicy ships default_rule :manage? with manage? => false, so a typo'd rule name (authorize! to: :sohw?)
denies exactly like a deliberate refusal. Fail-closed, correct — and silent. Know
which of fail-closed and fail-loud you have.
3. Params — exactly one place may call .permit
params.require(:project) returns whatever sits at that key once it is present:
a String for {"project":"boom"}, an Array for {"project":["a"]}. Neither
answers .permit, so the idiomatic chain raises NoMethodError — a 500 on a
body the server should have refused, reachable before authentication.
# ApplicationController — the ONLY site that may chain .permit onto a .require
def permitted_params(key, *filters, **nested)
scope = params.require(key)
raise ActionController::ParameterMissing.new(key, params.keys) unless scope.is_a?(ActionController::Parameters)
scope.permit(*filters, **nested)
end
# spec/security/param_shape_guard_spec.rb — the rule, enforced
it "routes every .permit through permitted_params" do
offenders = Dir["app/controllers/**/*.rb"].select { |f| File.read(f).match?(/\.require\([^)]*\)\.permit/) }
expect(offenders).to be_empty
end
- Why a type guard, not a
rescue: nothing is swallowed. Apermitthat
raises for an unrelated reason still propagates. Only the knowable condition
"this is not aParameters" is decided, and it's decided before.permit. - Why
ParameterMissing: a wrong-shaped value is the same condition as a
missing one, Rails already maps it to 400, and callers already handle it. - Rule: a convention with a test behind it, or it isn't a convention. This is
the pattern for every rule in this document — the spec is what makes it real. - Use when: any app with an untrusted client. That is all of them.
4. Tenancy — the database refuses before the code does
App-level scoping is one forgotten where from a cross-tenant leak. Put the
boundary in Postgres and let the app layer be the suspenders.
# app/models/concerns/tenant_context.rb
module TenantContext
extend ActiveSupport::Concern
ORG_GUC = "app.current_org_id"
class_methods do
def with_org(organization)
transaction do
previous = connection.select_value("SELECT current_setting('#{ORG_GUC}', true)")
set_guc(organization.id.to_s)
# A savepoint on re-entry, so the previous GUC is restored on exit —
# otherwise the app layer returns to A while Postgres stays pinned to B.
begin
ActsAsTenant.with_tenant(organization) { yield }
ensure
set_guc(previous.to_s)
end
end
end
private
# is_local => true: TRANSACTION-scoped, so it dies with the transaction.
def set_guc(value)
connection.execute("SELECT set_config('#{ORG_GUC}', #{connection.quote(value)}, true)")
end
end
end
-- The half that actually enforces it
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY; -- applies to the owner too
CREATE POLICY org_isolation ON projects
USING (organization_id = current_setting('app.current_org_id')::bigint);
Four decisions, each a bug avoided:
is_local => true(transaction-scoped). A session-levelSETsurvives the
connection returning to the pool — under PgBouncer transaction pooling that is
tenant A's GUC serving tenant B's next query. Most dangerous line in a
multi-tenant Rails app, and it's one boolean.- Nesting-safe.
with_org(B)insidewith_org(A)opens a savepoint; the
inner block must capture and restore the previous GUC on exit. Otherwise the app
layer returns to A while Postgres stays pinned to B — app and database
disagreeing about who is asking. - Server-derived. The org comes from the session, never a parameter. A tenant
id the client can influence is not a boundary. - Two roles. RLS is decoration if the app connects as the table owner —
owners bypass policies.app(NOBYPASSRLS, no ownership) at runtime,
migrator(owner) for migrations only. See the playbook's role split.
The escape hatch must be hostile to casual use:
# Every reason a caller may drop OUT of tenant context. Deliberately tiny —
# each entry names a surface, not a convenience.
PLATFORM_REASONS = %i[identity_audit].freeze
def without_org(reason:)
raise ArgumentError, "undeclared reason: #{reason}" unless PLATFORM_REASONS.include?(reason)
set_guc("") # no tenant pinned; RLS policies match nothing
yield
end
- Rule: an exemption is declared by name from a central list, never
received by omission. Treating a missing tenant as "exempt" silently exempts
every future bug that happens to run without one — that moves the hole, it
doesn't close it. - Gems:
acts_as_tenantfor the app layer; RLS is plain Postgres. - Use when: shared-table multi-tenancy. Decide before writing schema.
5. Authorization — authorize the scoped identity, not the account
The most transferable line in the whole path:
def pundit_user = Current.membership # NOT Current.user
An Account is a global identity — an email, a login. A Membership is that
account inside one organization, carrying role, capabilities and clearance.
Every authorization question in a multi-tenant app is about the second. Pass the
raw account and each policy must re-derive "…but in which org?" — the one that
forgets is the leak.
class ApplicationPolicy
attr_reader :membership, :record
def initialize(membership, record)
@membership = membership
@record = record
end
# Deny-by-default, written out. Nothing is permitted by inheritance.
def index? = false
def show? = false
def create? = false
def new? = create?
def update? = false
def edit? = update?
def destroy? = false
# Re-checks status on EVERY call: a deactivated membership is refused on its
# next action, not at its next login. Authorization is a live question.
def capability?(name)
membership.is_a?(Membership) && membership.active? &&
membership.role&.capabilities.to_h[name.to_s] == true
end
class Scope
def initialize(membership, scope)
@membership = membership
@scope = scope
end
def resolve = raise(NotImplementedError) # deny-by-default here too
end
end
class ProjectPolicy < ApplicationPolicy
def show? = capability?(:projects_read) && record.visible_to?(membership)
def create? = capability?(:projects_write)
def update? = create? && !record.archived?
class Scope < ApplicationPolicy::Scope
def resolve = @scope.where(organization_id: @membership.organization_id)
end
end
- Rule: authorization is never cached across a request. Re-run the gate on
every call so a mid-session downgrade is refused on the next action. - Rule: policies answer "may this identity do this", never "does this exist" —
existence is §9's problem, and conflating them leaks the existence of records
through 403-vs-404. - Gems:
pundit,action_policy. - Use when: always. No policy means no access.
6. Contracts — shape at the boundary, invariants on the model
Validation splits in two. Shape is a boundary concern and belongs in a
contract, checked before any side effect. Invariants belong on the model, where
the database can back them.
# app/contracts/projects/create_contract.rb
class Projects::CreateContract < ApplicationContract
MAX_NAME = 100
params do
required(:name).filled(:string)
optional(:description).maybe(:string)
optional(:starts_on).maybe(:date)
end
# filled(:string) already rejects nil/""; this catches whitespace-only
# (non-empty, so it passes the schema) and over-long.
rule(:name) do
stripped = value.to_s.strip
key.failure("can't be blank") if stripped.empty?
key.failure("is too long (maximum #{MAX_NAME})") if stripped.length > MAX_NAME
end
rule(:starts_on) do
key.failure("can't be in the past") if value && value < Date.current
end
end
- Rule: no uniqueness checks in a contract. Anything needing a database
round-trip is a different layer's question, and a uniqueness check here races
anyway — that's a unique index's job. - Rule: the contract runs before the write, so rejection precedes side
effects. - Gems:
dry-validation(dry-schemaif you only need shape). - Use when: any surface with machine callers or a public API. A small
internal-only app can validate on the model and add contracts when the first
external caller appears — but know you're deferring, not skipping.
7. Services — one thing, and a Result
Failure is a return value, not an exception. Callers pattern-match instead of
rescuing, so the ways an operation can fail are visible in its signature rather
than discovered in production.
# app/services/application_service.rb
# Two things, deliberately no more. A base class that grows behaviour becomes
# the place logic hides from review.
class ApplicationService
include Dry::Monads[:result]
def self.call(...) = new.call(...)
end
# app/services/projects/create.rb
module Projects
# Projects::Create.call(actor:, params:) #=> Success(Project) | Failure(...)
class Create < ApplicationService
def call(actor:, params:)
contract = Projects::CreateContract.new.call(params)
return Failure([:invalid, contract.errors]) if contract.failure?
return Failure(:forbidden) unless ProjectPolicy.new(actor, Project).create?
ApplicationRecord.with_org(actor.organization) do
project = Project.create!(**contract.to_h, organization: actor.organization, creator: actor)
Audit::Record.call(action: "project.create", actor:, target: project, decision: "allow")
Projects::Notify.call(project:)
Success(project)
end
end
end
end
Orchestration only. Gate, then tenant entry, then the write, then the
consequences, then Success. Each step is a named collaborator. If this method
grows past ~10 lines, the consequences want their own service.
- Rule: one public method,
#call. Private methods are the steps, not more
entry points. - Rule: a service returns
Failure(:symbol)orFailure([:symbol, details])
— never a bare string, neverfalse, nevernil. The controller maps the
symbol; humans never pattern-match on prose. - Rule: side effects follow the object, not the actor. A comment on someone
else's project belongs in that project's tenant. Getting this backwards
writes rows the restricted role can't even insert. - Gems:
dry-monads.Donotation when a service has ≥3 fallible steps:
class Payments::Capture < ApplicationService
include Dry::Monads[:result, :do]
def call(order:)
charge = yield gateway.charge(order) # Failure short-circuits the rest
receipt = yield Receipts::Issue.call(charge:)
yield Orders::MarkPaid.call(order:, receipt:)
Success(receipt)
end
end
- Pick one constructor shape and never mix them.
new.call(...)(stateless,
args to#call) ornew(...).call(args to the constructor). Both work; a
service written for one will not run under the other, so decide once per
organisation, not per app.
8. Queries — a read with a name
A controller assembling a multi-step read is a controller doing work. Give the
read an object.
# app/queries/projects/visible_to.rb
module Projects
class VisibleTo
def self.call(...) = new.call(...)
def call(membership:, limit: 50)
ApplicationRecord.with_org(membership.organization) do
# The group closure is computed ONCE and reused for every check —
# not re-derived per row. This is why the object exists.
refs = Permissions.subject_refs(membership)
Project.active
.includes(Project::PRELOAD)
.select { |p| Permissions.allow?(refs, p, :read) }
.first(limit)
end
end
end
end
- Rule: the picker and the gate share a resolver. The list a user chooses from
and the check that re-validates their choice on submit must be the same source
of truth, or a forged option eventually gets through. - Rule: a scope that fits on one line stays a scope. Extract when the read has
a batching decision, a permission fold, or more than two steps. - Use when: index pages, pickers, dashboards, exports.
9. Models — thin, and the keeper of shared truth
By the time execution reaches a model, authorization, tenancy, shape and
orchestration are decided. What's left is genuine domain truth — and the
definitions that would otherwise be copied.
class Project < ApplicationRecord
include TenantScoped # default_scope + organization_id validation
belongs_to :organization
belongs_to :creator, class_name: "Membership"
has_many :comments, dependent: :destroy
# Fixed value set, no transition rules → enum. Explicit string mapping, never
# positional integers: the DB stays readable and the values stay stable.
enum :visibility, { private_to_org: "private_to_org", public_in_org: "public_in_org" },
default: :private_to_org
# Guarded lifecycle → state machine owns the column (see the playbook).
include AASM
aasm column: :status do
state :draft, initial: true
state :active, :archived
event(:activate) { transitions from: :draft, to: :active }
event(:archive) { transitions from: :active, to: :archived }
end
scope :active, -> { where(status: "active") }
validates :name, presence: true, length: { maximum: 100 }
end
The pattern worth stealing — the preload set travels with the predicate that
needs it:
# app/models/concerns/playable.rb
module Playable
extend ActiveSupport::Concern
# TWO levels, not one: `playable?` touches the asset AND its published
# revision, so preloading :asset alone leaves the second belongs_to to be
# fetched per row — N items, N revision lookups. Declared once because the
# index page and the detail page both preload it, and those drifting apart is
# exactly how the N+1 gets in.
PRELOAD = { asset: :published_revision }.freeze
included do
scope :with_playability, -> { includes(PRELOAD) }
end
def playable? = asset&.playable? || false
end
Then the call site is one word: Project.active.with_playability.order(:name).
- Rule: a predicate and the preload it requires are one fact and live in
one file. Any other arrangement drifts. - Rule: no
default_scopeexcept tenancy. Everything else is a named scope. - Rule: callbacks only for data the record owns (normalising a slug). Never
for side effects — no emails, no jobs, no HTTP from anafter_save. Those are
the service's job, where they're visible and testable. - Gems:
prosopitein test to fail the build on an N+1.
10. Presentation — never render a model
# app/serializers/project_serializer.rb
class ProjectSerializer
def self.summary(project)
{ id: project.public_id, name: project.name, status: project.status,
visibility: project.visibility, updated_at: project.updated_at.iso8601 }
end
def self.detail(project, membership:)
summary(project).merge(
description: project.description,
can: { update: ProjectPolicy.new(membership, project).update?,
archive: project.may_archive? },
)
end
end
- Rule: a model never reaches a renderer.
as_jsonserializes every column —
password digests, internal ids, soft-delete flags. This is a security
boundary, not a style preference; ban bare model rendering in review. - Rule: expose
public_id(a UUID or ULID), never the bigint primary key.
Sequential ids leak volume and invite enumeration. can:travels with the payload so the client renders buttons from
server-computed permissions instead of re-encoding rules it will get wrong.- Naming: pick one of presenter / serializer / prop_builder for the whole
organisation. Three names for one job is pure tax.
11. Refusal — one shape, argued status codes
module ProblemDetails
extend ActiveSupport::Concern
included do
rescue_from Pundit::NotAuthorizedError, with: :deny_forbidden
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
rescue_from ActiveRecord::RecordInvalid, with: :render_invalid
# Rails' default renders raw HTML 422 — a JSON client gets a body with no
# `title` and can only show a generic failure, for what is actually an
# EXPIRED SESSION the user fixes by reloading. CSRF runs BEFORE authN, so a
# page held open across a session reset lands here, not on the 401.
rescue_from ActionController::InvalidAuthenticityToken, with: :render_stale_session
end
private
def problem(status:, title:, detail: nil, **extensions)
render status:, content_type: "application/problem+json", json: {
type: "https://errors.example.com/#{title.parameterize}",
title:, status: Rack::Utils.status_code(status), detail:,
instance: request.path, trace_id: Current.correlation_id, **extensions
}
end
end
And the controller — find, guard, call, map, nothing else:
class ProjectsController < ApplicationController
def create
result = Projects::Create.call(actor: current_membership, params: project_params.to_h)
case result
in Success(project) then render json: ProjectSerializer.detail(project, membership: current_membership), status: :created
in Failure([:invalid, errors]) then problem(status: :unprocessable_entity, title: "Validation failed", errors: errors.to_h)
in Failure(:forbidden) then problem(status: :forbidden, title: "Not permitted")
end
end
private
def project_params = permitted_params(:project, :name, :description, :starts_on)
end
- Argue the status. 403 because it is a decision — not 500, which claims
the server malfunctioned, and not 404, which claims the thing doesn't exist.
Reserve 404 for genuine absence and for existence you must not disclose. - Every error carries
trace_id. Same correlation id as the logs and the
audit row, so the user quotes it and one grep spans click → controller →
service → job. - HTML routes keep human error pages.
problem+jsonis for machine callers.
12. Jobs — the same path, minus the request
A job is a service with a schedule. It gets no session, so tenancy must be
declared, never inferred.
class ApplicationJob < ActiveJob::Base
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
discard_on ActiveJob::DeserializationError
# An UNDECLARED tenancy contract RAISES rather than defaulting to permissive.
# Silence is never consent.
def within_declared_tenancy(organization_id: nil, reason: nil)
raise ArgumentError, "declare organization_id: or reason:" if organization_id.nil? && reason.nil?
return ApplicationRecord.without_org(reason:) { yield } if organization_id.nil?
ApplicationRecord.with_org(Organization.find(organization_id)) { yield }
end
end
class Projects::ReindexJob < ApplicationJob
# Pass IDs, never AR objects: the record may be gone, changed, or from another
# tenant by the time this runs.
def perform(organization_id:, project_id:)
within_declared_tenancy(organization_id:) do
project = Project.find_by(id: project_id) or return
Search::Index.call(project:)
end
end
end
- Rule: jobs are idempotent or they are broken — retries are a certainty, not
an edge case. Back money-moving work with a unique index on an idempotency key. - Rule: enqueue
after_commit, never mid-transaction. A job that starts
before the transaction commits reads a row that doesn't exist yet.
Anti-patterns (reject in review)
- Business logic in a controller. If it isn't find / guard / call / map, it's
in the wrong file. - A model handed to a renderer. Leaks every column. Security, not style.
current_userin a policy where a scoped membership exists.- Exceptions as control flow. A refusal is a
Failure, not araise. - Callbacks with side effects.
after_save :send_emailis a job you can't
test, disable, or retry. - A second
.require(...).permitsite. without_orgwith no declared reason, or any "if tenant is nil, allow".- A service with two public methods. That's two services.
- A
default_scopethat isn't tenancy. - Enqueuing a job inside the transaction that creates its subject.
Review checklist
- Does a forgotten line here fail closed and loud?
- Is the new action covered by
verify_authorized? - Does the policy read the scoped membership, not the account?
- Is the gate re-run on this call, or cached from an earlier one?
- Does the service return
Failure(:symbol)for every known failure? - Are side effects filed against the object's tenant, not the actor's?
- Does the predicate's preload set live beside the predicate?
- Is the response built by a serializer, not the model?
- Does the error carry a
trace_id? - Is the job idempotent, ID-passing, and enqueued
after_commit? - Any rule added without a test that reddens when it's broken?
Quick reference
| Layer | Reach for | Use when |
|---|---|---|
| Routes | resourceful + shallow: true |
always; a custom verb means a missing noun |
| Base controller | include Authentication + verify_authorized |
always, from commit one |
| Params | one permitted_params + a guard spec |
any untrusted client |
| Tenancy | with_org + RLS, is_local => true |
shared-table multi-tenancy |
| DB roles | app (NOBYPASSRLS) vs migrator (owner) |
any app using RLS |
| Authorization | pundit / action_policy on the membership |
always |
| Contract | dry-validation at the boundary |
machine callers / public API |
| Service | ApplicationService + dry-monads Result |
any multi-step fallible op |
Do notation |
include Dry::Monads[:do] |
≥3 fallible steps |
| Query | app/queries PORO |
batched reads, pickers, dashboards |
| Model | thin + concerns; enum / aasm |
domain truth only |
| Preloads | PRELOAD constant beside its predicate |
any predicate crossing 2 associations |
| N+1 | prosopite failing the build |
index/list endpoints |
| Presentation | serializer, public_id, can: |
every response |
| Errors | RFC 9457 problem+json + trace_id |
machine callers |
| Jobs | declared tenancy, IDs, after_commit |
all async work |
The framework gives you a controller and a model. Everything worth arguing
about lives in the nine layers between them — and the argument is settled by
what fails loudly when someone forgets.
See also
- Rails Production Playbook — the same convictions organised by concern rather than by path
- React Production Playbook (Inertia + Rails) — what happens after the serializer, once props reach React
- Sandi Metz' Rules — the object-size discipline every layer here assumes
- Scaling Rails: Vertical & Horizontal — what to do when a layer here becomes the bottleneck