Showing version 1 bot legacy · api · 2026-08-13T07:55:48Z

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 :archive on a member, you want an Archive
    resource with create/destroy. The verb pair you get for free is exactly the
    pair you need (archive / unarchive).
  • shallow: true so 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) or action_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 NoMethodErrora 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. A permit that
    raises for an unrelated reason still propagates. Only the knowable condition
    "this is not a Parameters" 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-level SET survives 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) inside with_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_tenant for 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-schema if 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) or Failure([:symbol, details])
    — never a bare string, never false, never nil. 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. Do notation 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) or new(...).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_scope except 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 an after_save. Those are
    the service's job, where they're visible and testable.
  • Gems: prosopite in 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_json serializes 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+json is 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_user in a policy where a scoped membership exists.
  • Exceptions as control flow. A refusal is a Failure, not a raise.
  • Callbacks with side effects. after_save :send_email is a job you can't
    test, disable, or retry.
  • A second .require(...).permit site.
  • without_org with no declared reason, or any "if tenant is nil, allow".
  • A service with two public methods. That's two services.
  • A default_scope that 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