The Hidden Costs of UI-First Development: A Rails Workflow Bug Postmortem

Posted by DevGab on 9 March 2026

When the UI Promised More Than the Backend Could Deliver

The bug manifested innocuously: a button labeled "Submit for Review" that, when clicked, appeared to work — no error message, no stack trace, no indication that anything was wrong. But the record wasn't entering a review stage. It was being sent to an entirely different workflow step. Users assumed their action had succeeded, while data quietly accumulated in the wrong state.

The root cause was a UI-backend contract violation. A designer had updated the workflow visualisation to include an intermediate review step, reflecting how the business process actually worked. The UI was updated, the button was relabeled, and the view was copied from the existing approve button. What didn't happen was updating the route helper to point to a new action — or creating the backend infrastructure for that action: no route, no controller action, no model method, and no authorisation policy.

# What the view promised
= button_to "Submit for Review", pending_review_workflow_path(@record)

# What actually existed in routes.rb
resources :workflows do
  member do
    post :approve  # ✓ exists
    post :reject   # ✓ exists
    # pending_review? missing entirely
  end
end

In this case, the view had been copied from the existing approve button, and the route helper was never updated to point to the new action. Requests hit the approve action instead of a not-yet-created transition_to_review action. Records moved to the wrong workflow stage — no error was raised because every layer the request passed through was technically valid, just wrong.

What made diagnosis difficult was the cascade effect. The incorrect route was only one of several problems. Once we started investigating, we found five distinct but interrelated bugs: silent parameter drops, duplicate audit records, incorrect user attribution, and a missing policy method. Understanding these failures requires understanding Rails' four-layer contract for feature completeness: route, policy, controller, and model.

Anatomy of the Bug: Five Failures, One Root Cause

What looked like five independent bugs turned out to be symptoms of a single architectural failure: the UI was updated to reflect a new workflow step, but the complete backend stack was never implemented.

Here's how the failures cascaded:

1. The Wrong Action
The view was copied from the existing approve button. The route helper was never changed, so the "Submit for Review" button posted to transition_to_approved_path — a valid route that triggered the wrong workflow transition entirely. No error was raised.

2. The Authorization Gap
When we added the correct route, requests hit a NoMethodError because the policy class had no transition_to_review? method. Pundit doesn't silently deny when a method is missing — it raises an exception. This was actually the easiest failure to find because it was loud.

3. The Non-Existent Model Method
The controller action called @record.transition_to_review!, which didn't exist. Like the policy failure, this raised a clear NoMethodError.

4. The Silent Data Loss
The transition form used notes as the field name, but the controller read params[:transition_notes]:

What the form sent

{ "notes" => "Ready for final approval" }

What the controller read

params[:transition_notes] # => nil

ActiveRecord happily saved the record with blank notes. No error, no warning—just missing data.

5. The Duplicate Records
Once the action existed, each transition created two audit records: one from the controller's manual audit_records.build, another from a before_update callback that fired on status changes. Both paths assumed they were the only writer.

The Pattern

In authorization-driven Rails apps, a new user action requires four aligned layers:

  1. Route (resources :records do post :transition_to_review end)
  2. Policy method (def transition_to_review?)
  3. Controller action (def transition_to_review)
  4. Model method (def transition_to_review!)

Skip any layer, and you get failures ranging from loud exceptions (missing routes, missing methods) to genuinely silent data corruption (wrong param keys, duplicated callbacks). The most dangerous bugs are in the silent category — they don't trigger error trackers because no exception is raised. The UI worked perfectly; it just sent data to the wrong place.

The Four-Layer Alignment Requirement in Rails

In a well-architected Rails application, every user-facing feature requires coordination across four distinct layers, each serving a specific purpose in the request-response cycle. Understanding this dependency chain is critical for preventing the kind of UI-backend misalignment that caused our workflow bug.

The four layers and their responsibilities:

Complete four-layer alignment for a workflow transition:

1. Route

post 'records/:id/submit_for_review', to: 'records#submit_for_review'

2. Policy

class RecordPolicy
  def submit_for_review?
    user.reviewer? && record.draft?
  end
end

3. Controller

def submit_for_review
  authorize @record
  @record.submit_for_review!
  redirect_to @record
end

4. Model

def submit_for_review!
  update!(current_stage: 'under_review', status: 'pending')
end

When the workflow UI was updated to include a new intermediate step, only the view layer changed — and it was copied from an existing button. The route helper still pointed to the approve action, so the form silently sent records to the wrong workflow stage. Meanwhile, the three lower layers for the new transition didn't exist at all: no policy method, no controller action, and no model method. This created a situation where the initial failure was invisible (wrong action, no error) and the subsequent failures during the fix were a cascade of missing implementations.

sequenceDiagram actor User participant View as View Layer participant Router as Rails Router participant Controller as Controller participant Model as Model participant DB as Database User->>View: Click "Submit for Review" View->>Router: POST /records/42/approve Note over View,Router: View copied from approve button,<br/>route helper never updated Router->>Controller: records#approve Note over Controller: Approve action runs<br/>(not the intended review action) Controller->>Model: record.approve! Model->>DB: UPDATE status = approved Note over DB: Record moves to wrong stage rect rgb(200, 150, 150) Note over User,DB: What actually happened: valid request, wrong action, no error end rect rgb(150, 200, 150) Note over User,DB: What should have happened View->>Router: POST /records/42/transition_to_review Router->>Controller: records#transition_to_review Controller->>Model: record.transition_to_review! Model->>DB: UPDATE status = pending_review end

Request Flow: Wrong Route from Copied View

  1. Routes (config/routes.rb) — Define which HTTP verbs and paths are valid and map them to controller actions
  2. Policies (Pundit/authorization layer) — Determine whether the current user is allowed to perform the action
  3. Controllers — Orchestrate the business logic, handle parameters, and coordinate the response
  4. Models — Encapsulate business rules and perform the actual state transitions

Each layer serves a distinct purpose. A route without a policy creates a security hole — the action is accessible but unguarded. A route without a controller action raises AbstractController::ActionNotFound. A controller action without a corresponding model method raises NoMethodError. But a form pointing to the wrong existing route — where every layer is present but for a different action — produces no error at all.

Root Cause Analysis: How UI-First Development Goes Wrong

The bug cascade began with a deceptively simple change: updating button labels and workflow descriptions to reflect a new intermediate stage. The UI team made the frontend changes, shipped them to production, and moved on to the next sprint. The problem? Nobody added the corresponding backend infrastructure.

This is UI-first development in its most dangerous form. The pattern emerges from understandable organisational pressures: frontend teams moving faster than backend capacity, designers iterating on user flows without technical review, or "quick fixes" that touch only what's visible to users. In workflow-heavy applications, this creates a particularly insidious failure mode — the UI promises functionality that simply doesn't exist.

When users clicked the new "Submit for Review" button, they expected their records to enter a review stage. But the view had been copied from the existing approve button, and the route helper was never updated. The form action pointed to a valid but wrong route — the approve action. Records moved to the wrong workflow stage with every click. The application appeared to work — no error pages, no failed requests — but data was being routed to the wrong action entirely.

What the UI implied:

<%= form_with url: submit_for_review_path(@record) do |f| %>

What the view actually used (copied from the approve button):

post '/records/:id/approve' # Valid route, wrong action

In Rails applications with authorisation layers, a complete feature requires alignment across four levels: routes, policies, controller actions, and model methods. Some misalignments fail loudly — a missing controller action raises an exception, a missing policy method raises NoMethodError. But others fail silently: a form pointing to the wrong existing route executes the wrong action with no error, and a param name mismatch drops data without complaint.

The real danger isn't the initial breakage — it's the silent degradation. Users learn to work around the broken feature. Data accumulates in incorrect states. By the time someone investigates, the trail of inconsistency runs through thousands of records.

flowchart TD A["Routes Layer<br/>URL Mapping"] --> B["Policies Layer<br/>Authorisation"] B --> C["Controller Layer<br/>Orchestration"] C --> D["Model Layer<br/>Business Logic"] E["Missing Route<br/>No matching path"] -.->|Loud| F["ActionController::RoutingError"] G["Missing Policy Method<br/>No query method"] -.->|Loud| H["NoMethodError"] I["Missing Controller Action<br/>Route exists but no action"] -.->|Loud| J["ActionNotFound"] K["Wrong Route<br/>Valid but incorrect action"] -.->|Silent| L["Data Corruption"] D --> M["Response Returned"] style A fill:#e1f5ff style B fill:#f3e5f5 style C fill:#fff3e0 style D fill:#e8f5e9 style F fill:#ffebee style H fill:#ffebee style J fill:#ffebee style L fill:#ffebee style M fill:#c8e6c9

Four-Layer Rails Alignment Architecture

Debugging Multi-Layer Failures: A Systematic Approach

When a multi-layer framework like Rails hides complexity, bugs often manifest far from their source. The workflow bug appeared as a button sending records to the wrong stage, but the root cause was four missing layers of implementation. Here's a systematic approach for diagnosing these failures:

Start with the data layer. When behavior seems wrong, verify what's actually persisted. Use rails console to inspect the database state directly:

What actually got saved?

Record.last.inspect Record.last.audit_records.order(:created_at).pluck(:from_stage, :to_stage, :notes)

This immediately revealed duplicate transition records—a smoking gun pointing to overlapping logic paths.

Trace the request path backward. Follow the data from view to database:

post transition_record_path(@record), params: { notes: "Test note" } assert_equal "Test note", @record.audit_records.last.notes

  1. View: What parameters does the form submit? Check name= attributes.
  2. Route: Does rails routes | grep transition show the expected path?
  3. Controller: Add logging before and after the action: Rails.logger.debug "Params: #{params.inspect}"
  4. Model: Does the method exist? Is a callback firing? Add Rails.logger.debug to callbacks.

The silent notes parameter loss was caught by comparing form field names against controller parameter keys. No error occurred because params[:transition_notes] simply returned nil.

Check all four layers for new actions. In authorization-heavy Rails apps, every user-facing action requires alignment across:

  • Route definition (resources :records do ... end)
  • Policy method (def transition_to_review?)
  • Controller action (def transition_to_review)
  • Model method (def transition_to_review!)

Missing layers produce a mix of loud and silent failures. Missing routes and methods raise exceptions — those are easy to find. But a form wired to the wrong action, or a param key that doesn't match, produces no error at all. The original bug stayed hidden precisely because the request succeeded — it just did the wrong thing.

Use integration tests to catch silent failures. Unit tests passed because each layer worked in isolation. Only end-to-end tests would have caught the parameter name mismatch or the wrong route:

The Complete Implementation Checklist

When implementing a new workflow step or any feature that spans multiple layers of your Rails application, use this checklist to ensure complete, aligned implementation:

Routes & Policies

  • [ ] Add route entry in config/routes.rb for each new action
  • [ ] Add corresponding policy method in app/policies/*_policy.rb
  • [ ] Verify policy test coverage in spec/policies/*_policy_spec.rb
  • [ ] Confirm authorization check in controller (authorize @resource)

Controller Layer

  • [ ] Implement action in appropriate controller
  • [ ] Match parameter names between form fields and strong parameters
  • [ ] Add explicit integration test verifying param names match: expect(Record.last.notes).to eq("test notes")
  • [ ] Set callback suppression flags if model callbacks duplicate controller logic
  • [ ] Verify acting user attribution (@record.acting_user = current_user)

Model & Business Logic

  • [ ] Add transition method (e.g., approve!, submit_for_review!)
  • [ ] Ensure method signature matches calling pattern (param-less bang methods for form actions)
  • [ ] Review before_update/after_update callbacks for potential duplication
  • [ ] Add attr_accessor flags for callback control (skip_audit_callback)
  • [ ] Confirm database columns exist for all assigned attributes (rails dbconsole, \d table_name)

View Layer

  • [ ] Verify form action: path matches new route (not a similar existing route)
  • [ ] Confirm button labels reflect actual destination, not aspirational workflow
  • [ ] Match form field names to controller parameters exactly
  • [ ] Add request spec that submits the form and verifies the full round trip

Integration Verification

  • [ ] Click through the UI workflow in staging/development
  • [ ] Inspect database records to confirm all fields persisted
  • [ ] Check audit trail attribution and metadata completeness
  • [ ] Verify no duplicate records created from overlapping callbacks

The most insidious bugs come from param name mismatches — params[:wrong_key] returns nil with no error raised. Note that mass-assigning to non-existent columns does raise ActiveModel::UnknownAttributeError, so Rails catches that class of mistake. The truly silent failures are wrong param keys and duplicated callback logic. Always verify persistence with database-level assertions.

Prevention Strategies: Process and Tooling

Preventing UI-backend misalignment requires both process discipline and strategic automation. The workflow bug revealed gaps at four distinct layers—routes, policies, controllers, and models—suggesting that traditional code review alone isn't sufficient when frontend and backend changes span multiple pull requests.

Code Review Checklists

For feature work touching user-facing workflows, reviewers should verify all four layers exist before approving:

✅ Complete feature implementation

config/routes.rb

post 'records/:id/submit_for_review', to: 'records#submit_for_review'

app/policies/record_policy.rb

def submit_for_review? user.can_submit?(record) end

app/controllers/records_controller.rb

def submit_for_review authorize @record @record.submit_for_review!(current_user) end

app/models/record.rb

def submit_for_review!(user) update!(current_stage: 'under_review', acting_user: user) end

Integration Testing Strategy

Silent failures from parameter mismatches demand integration tests that verify the complete request-response-persistence cycle:

spec/requests/workflow_transitions_spec.rb

it "persists transition notes" do
  post submit_for_review_record_path(@record), 
       params: { notes: "Ready for approval" }

  expect(@record.reload.audit_records.last.notes)
    .to eq("Ready for approval")
end

Team Workflow Adjustments

Consider requiring that UI mockups or Figma updates trigger a technical design discussion before implementation begins. When Product updates a workflow diagram, Engineering should generate a quick architectural checklist: "This adds one button, which means we need one route, one policy method, one controller action, and one model state transition." Treating UI changes as API contracts prevents the drift that creates these cascading failures.

Feature flags can also help by allowing backend scaffolding to deploy before UI exposure, ensuring all layers exist before users encounter new workflows.

Lessons Learned and Broader Implications

This incident highlights a fundamental tension in Rails development: the framework's convention-over-configuration philosophy encourages rapid UI iteration, but multi-layer features require coordinated changes across routes, policies, controllers, and models. When these layers drift apart, bugs range from loud exceptions to silent data corruption.

The most insidious aspect was how some layers failed silently while others failed loudly. Missing routes and missing methods raised clear exceptions that were easy to find. But params[:wrong_key] returning nil, a copied route helper pointing to the wrong action, and duplicated callback logic — these produced no exceptions at all. The application appeared functional while quietly corrupting data.

This experience crystallised a mental model we now apply to every workflow feature: the four-layer checklist. Before marking any state transition as complete, we verify:

1. Route exists

resources :records do member { patch :advance_to_review } end

2. Policy permits action

def advance_to_review? user.reviewer? && record.submittable? end

3. Controller action exists and handles params correctly

def advance_to_review @record.acting_user = current_user @record.advance_to_review!(notes: params[:transition_notes]) end

4. Model method implements business logic

def advance_to_review! update!(current_stage: 'review', status: 'pending') end

We've also become more skeptical of "quick UI fixes." When a PM requests changing a button label, we now treat it as a trigger to audit the entire feature stack. Often, the mismatch between UI labels and backend behavior reveals drift that's been accumulating for months.

The broader lesson is that stateful applications require constant vigilance. Callbacks, in particular, create implicit dependencies that break when assumptions change. The skip_audit_callback pattern works but signals that your callback's preconditions may be too broad. In retrospect, extracting audit logic to a service object would have made the dual-path pattern explicit rather than hidden in accessor flags.