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
└─────────────┘
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) %>
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
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
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
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
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
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
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
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
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
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
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)