DEV Community

DevGab
DevGab

Posted on • Originally published at devgab.com

Rails' Four-Layer Contract: Why Every Feature Needs a Route, Policy, Controller, AND Model Method

Here's a scenario that should terrify you: a user clicks a button, sees no error, and walks away assuming their action succeeded. Meanwhile, on the server, something quietly did nothing — or worse, did the wrong thing.

This isn't a hypothetical. It's what happens when you add a UI element to a Rails app without completing what I call the four-layer contract : Route → Policy → Controller → Model. Miss any single layer, and you get silent failures that won't show up in your logs or error trackers.

Let's break this down layer by layer, look at exactly how each one fails, and build a checklist you can use every time you add a new action to your app.

The Four Layers, Visualized

Every user-initiated action in a Rails app travels through this chain:

Browser Request
       │
       ▼
┌─────────────┐
│    Route    │ Does this URL + verb map to an action?
└──────┬──────┘
       │
       ▼
┌─────────────┐
│    Policy   │ Is this user allowed to do this?
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  Controller │ Orchestrate: parse params, call model, render
└──────┬──────┘
       │
       ▼
┌─────────────┐
│    Model    │ Execute the actual business logic
└─────────────┘
Enter fullscreen mode Exit fullscreen mode

The critical thing to understand: each layer fails differently. Some fail loudly (a 500 error), some fail silently (a 403 you never see), and some fail invisibly (data gets saved but wrong). Let's go through each one.

Layer 1: The Route

What happens when it's missing

If you reference a named route in a view that doesn't exist in routes.rb, Rails raises a NoMethodError on the route helper itself — but only when the view renders. If the button is conditionally rendered (e.g., only for certain roles), this can stay hidden in development and only surface in production for specific users.

<%# This line will raise NoMethodError if the route doesn't exist %>
<%= button_to "Submit for Review", transition_to_review_workflow_path(@record) %>
Enter fullscreen mode Exit fullscreen mode

The sneakier failure is a copy-paste error: you meant to write transition_to_review_workflow_path but pasted transition_to_approved_workflow_path instead. Both are valid helper methods, no error is raised, but your button now triggers the wrong action entirely. No exception anywhere — just a workflow that silently does the wrong thing.

The fix

# config/routes.rb
resources :workflows do
  member do
    post :approve
    post :reject
    post :transition_to_review # ← add this explicitly
  end
end
Enter fullscreen mode Exit fullscreen mode

Run rails routes | grep workflow after every new action to verify the route exists before writing any other code.

Layer 2: The Policy (Pundit)

What happens when it's missing

This one catches people off guard. If you're using Pundit and your policy class doesn't define a transition_to_review? method, you get a NoMethodError — a 500, not a 403. That's actually the good failure mode because it's loud and obvious.

The truly devious failure is when the policy method exists but has the wrong logic — typically from copying another method and forgetting to update the conditions:

# app/policies/workflow_policy.rb
class WorkflowPolicy < ApplicationPolicy
  def approve?
    user.admin?
  end

  def reject?
    user.admin? || user.reviewer?
  end

  # Copied from approve? but forgot to update the conditions
  # Authors should be able to submit for review, but this only allows admins
  def transition_to_review?
    user.admin?
  end
end
Enter fullscreen mode Exit fullscreen mode

Now your authors click "Submit for Review" and get redirected with a "You're not authorised" flash message. The bug gets filed as a permissions issue. Someone spends an hour checking role assignments before realising the policy method is simply wrong. Meanwhile, a missing method would have pointed directly to the problem in seconds.

The fix

# app/policies/workflow_policy.rb
class WorkflowPolicy < ApplicationPolicy
  def transition_to_review?
    # Be explicit about who can trigger this action
    user.author? && record.draft?
  end
end
Enter fullscreen mode Exit fullscreen mode

Every route you add in routes.rb should have a corresponding policy method with its own logic — never copy from another method without reviewing the conditions. Make this a code review requirement.

Layer 3: The Controller Action

What happens when it's missing (or wrong)

A missing controller action raises a AbstractController::ActionNotFound — this one is at least loud. But the silent failure mode is more interesting: the controller action exists but reads the wrong param key.

# What the form sends:
# { "notes" => "Ready for review", "record_id" => "42" }

def transition_to_review
  authorize @record

  # BUG: reads :transition_notes, but form sends :notes
  note_text = params[:transition_notes]

  @record.transition_to_review!(note: note_text)
  redirect_to @record, notice: "Submitted for review."
end
Enter fullscreen mode Exit fullscreen mode

note_text is nil. The record saves. The audit log has blank notes. No exception is raised. This is the category of bug that takes 3 hours to track down because everything appears to work.

The fix: use Strong Parameters defensively

def transition_to_review
  authorize @record

  # Explicitly permit and alias if needed
  transition_params = params.require(:workflow).permit(:notes)

  @record.transition_to_review!(note: transition_params[:notes])
  redirect_to @record, notice: "Submitted for review."
end

private

def set_record
  @record = Workflow.find(params[:id])
end
Enter fullscreen mode Exit fullscreen mode

Using params.require() means Rails will raise an ActionController::ParameterMissing error if the top-level key is absent — making the contract between form and controller explicit and enforced.

Layer 4: The Model Method

What happens when it's missing

This is the most visible failure: a clean NoMethodError pointing directly at the missing method. Celebrate this one — it's honest. The problem is what comes after you add the method without thinking about side effects.

# The naive implementation
def transition_to_review!(note:)
  update!(status: :pending_review, review_notes: note)
end
Enter fullscreen mode Exit fullscreen mode

If you also have a before_update callback that creates audit records on status changes, you now have two code paths both writing audit records. The controller might also manually build an audit record. Result: every transition creates duplicate audit entries, and your audit trail is permanently corrupted.

The fix: make the model method the single source of truth

# app/models/workflow.rb
def transition_to_review!(note:)
  transaction do
    update!(status: :pending_review)
    audit_records.create!(
      action: :transition_to_review,
      notes: note,
      performed_by: Current.user
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

Then remove any duplicate audit logic from callbacks or controllers. One method, one responsibility, one writer.

The Four-Layer Checklist

Print this out and tape it to your monitor:

New Rails Action Checklist
==========================

□ Route added to routes.rb
  → Run: rails routes | grep <action_name>

□ Policy method defined
  → Matches exact action name: def <action_name>?
  → Tested with correct and incorrect user roles

□ Controller action implemented
  → Params read with correct keys (verify against form field names)
  → authorize called before any data mutation
  → Single exit path (no branching redirects without coverage)

□ Model method implemented
  → Wrapped in transaction if multiple writes
  → Audit record written in ONE place only
  → Callbacks audited for conflicts
Enter fullscreen mode Exit fullscreen mode

Catching This Class of Bug Earlier

Beyond the checklist, there are two Rails-native techniques that surface layer mismatches before production:

1. Route-aware integration tests. Write a request spec for every new action that asserts a 200 (or redirect) response. A missing route causes the spec to fail immediately rather than waiting for a user report.

# spec/requests/workflows_spec.rb
RSpec.describe "Workflows", type: :request do
  describe "POST /workflows/:id/transition_to_review" do
    it "transitions the workflow and returns a redirect" do
      sign_in author_user
      post transition_to_review_workflow_path(workflow), params: {
        workflow: { notes: "Ready for review" }
      }
      expect(response).to redirect_to(workflow_path(workflow))
      expect(workflow.reload.status).to eq("pending_review")
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

2. Policy spec coverage for every action. Using pundit-matchers, write a spec that explicitly asserts which roles can and cannot access each action. A missing policy method causes the spec to fail loudly.

# spec/policies/workflow_policy_spec.rb
describe WorkflowPolicy do
  subject { described_class.new(user, workflow) }

  context "transition_to_review" do
    context "when user is author and workflow is draft" do
      let(:user) { build(:user, :author) }
      let(:workflow) { build(:workflow, :draft, author: user) }

      it { is_expected.to permit_action(:transition_to_review) }
    end

    context "when user is a reviewer" do
      let(:user) { build(:user, :reviewer) }
      it { is_expected.to forbid_action(:transition_to_review) }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The Mental Model Shift

The real lesson here isn't about Rails mechanics — it's about how we think about "done." When a designer updates a workflow UI and the button looks right in the browser, it feels done. But the UI is just a promise. The backend is where you keep it.

Every new button, every new form, every new link is really four tasks masquerading as one. Treat it that way, and silent failures become a lot rarer.


This article builds on a deeper exploration at The Hidden Costs of UI-First Development: A Rails Workflow Bug Postmortem.

What's your approach to preventing this kind of layer mismatch? Do you use code generation, templates, or strict PR checklists? I'd love to hear how your team handles it. 👇

Top comments (0)