The Hook: A PORO Mustache on a Fat Controller
I spent years stuffing logic into app/services. Felt productive. Felt like I was doing "proper architecture."
Then I looked at what I'd actually built:
# app/services/products/create_service.rb
class Products::CreateService
def initialize(params, current_user)
@params = params
@current_user = current_user
end
def call
return failure("Unauthorized") unless authorized?
return failure("Invalid params") unless valid_params?
product = Product.new(product_params)
if product.save
add_to_stock(product)
notify_team(product)
log_creation(product)
success(product)
else
failure(product.errors)
end
end
private
def authorized?
@current_user.can_create_products?
end
def valid_params?
# ... 50 lines of manual validation
end
def product_params
@params.permit(:title, :description, :price) # ← Still manual, still drifts
end
def add_to_stock(product)
# ... 30 lines
end
def notify_team(product)
# ... 20 lines
end
def log_creation(product)
# ... 15 lines
end
def success(product)
OpenStruct.new(success?: true, product: product)
end
def failure(error)
OpenStruct.new(success?: false, error: error)
end
end
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def create
result = Products::CreateService.new(params, current_user).call
if result.success?
redirect_to result.product
else
@errors = result.error
render :new
end
end
end
What did I actually achieve?
I moved a 500-line controller method into a 400-line service object. Put a mustache on it. Called it "architecture."
The controller was thinner. But everything else—params drift, scattered validation, implicit contracts, duplication between new and create—remained.
I'd hidden the problem. Not solved it.
The Vibe Coding Reality: We Walked Halfway and Stopped
Let's examine what happened.
Around 2015, the Rails community realized: "Fat controllers are bad." Service objects emerged as the solution.
The promise:
- Thin controllers
- Testable business logic
- Clear separation of concerns
- Reusable operations
What we actually built:
- ✅ Business logic extracted
- ❌ Params still manual (
permitstill drifts) - ❌ Validation still scattered (model? service? both?)
- ❌ No standard interface (what does
callreturn?) - ❌ No structured error handling (strings? hashes? objects?)
- ❌ No form integration (still writing ERB)
- ❌ No action grouping (
newandcreatestill separate)
We built the framing for a modern house—then stopped. Threw a tarp over it. Called it "good enough."
I lived under that tarp for years, getting wet every time it rained, calling it "minimalism."
Take this paradox: Service objects were supposed to make code clearer. But I still couldn't answer basic questions:
- What data format does this operation expect? (Implicit)
- Where does validation happen? (Scattered)
- How do I handle different error types? (Manual conditionals everywhere)
- Why am I duplicating setup between
newandcreate? (¯\(ツ)/¯)
The community walked halfway toward "Thin Controllers" but forgot to build the infrastructure to support them.
The Sanity Tax: Settling for Params Drift
Here's the tax I paid every month:
Saturday, 11:47 PM. Production bug.
User reports profile not saving. I investigate. They filled out the form: name, email, phone, bio. Clicked save. Success message. But only email saved.
The archaeology begins:
- Check the view (
_form.html.erb) →phoneandbiofields exist ✓ - Check the model (
User) → validations forphoneandbioexist ✓ - Check the service object (
UpdateUserService) → validation logic forphoneandbioexists ✓ - Check the controller (
params.permit) → Missing:phoneand:bio✗
Someone added the fields six months ago. Updated three files. Forgot the fourth.
# Controller (the silent killer)
def user_params
params.require(:user).permit(:email) # ← Forgot :phone, :bio
end
<%# View (looks fine) %>
<%= f.text_field :phone %>
<%= f.text_area :bio %>
# Service (validates fields that never arrive)
class UpdateUserService
validates :phone, presence: true
validates :bio, length: { maximum: 500 }
end
The dispersed architecture: View defines fields. Controller filters params. Model validates data. Service enforces business rules. Four files. Four places for drift.
The paradox: I extracted business logic to make it testable. But the test passed because it used a hash, not controller params. Production failed because params filtering happened before the service.
Service objects don't solve params drift. They just move where you forget to update things.
I patched it. Happened again next month with a different field. And again.
Why was I settling for this? Because everyone else was. We'd normalized the sanity tax.
The Unified Contrast: Form as a Solid Thing
The structure of submitted parameters mirrors the structure of the form. They aren't separate concerns - they're different states of the same contract. In the Handler+Form approach, this entire bug class disappears:
class UpdateUserForm < ActionForm::Base
element :email { output type: :string, presence: true }
element :phone { output type: :string, presence: true }
element :bio { output type: :string, length: { maximum: 500 } }
end
One file. One source of truth.
- The form defines its fields (UI)
- The form defines validation (contract)
- The form auto-generates the params schema (security)
- The handler uses the form (business logic)
When you add a field, you add it in one place. The params filtering, the validation, and the UI are no longer three separate responsibilities that can drift—they're a unified declaration.
The test change: Instead of mocking four layers and hoping they align, you test the form as a cohesive unit. If the form test passes, the integration works. No archaeology required.
The Lifecycle: How the Solid Form Actually Works
Here's the mechanical flow that eliminates the drift:
Step 1: Handler defines the form template (class definition)
class UsersHandler < SteelWheel::Base
def form_class
UpdateUserForm # The template
end
end
Step 2: GET request — Create empty form instance
@form = UsersHandler.form_class.new(object: @user)
# Form renders itself with current user data
Step 3: User populates form — Browser submits params
Step 4: POST request — Form creates params instance from params template (which is reflection of form structure) and user's inputs
@params = UsersHandler.form_class.params_definition.new(params)
# Auto-generated schema validates input
Step 5: Validation feedback — Create form with errors
@form = @params.create_form(object: @params)
# Form instance now contains validation errors
# Renders itself with inline error messages
The key insight: The form is both the UI definition and the params schema generator. When you add a field to the form, you simultaneously:
- Define its rendering
- Define its validation
- Auto-generate its params filter
No drift. No archaeology. One solid thing.
The Reformation: SteelWheel as Validation Machine
Here's what changed when I stopped treating service objects as "a call method in a void" and started using structured handlers:
# app/handlers/products/create_handler.rb
class Products::CreateHandler < ApplicationHandler
# 1. URL validation (400 Bad Request if wrong)
url_params do
string :format
end
# 2. Form binding (generates params validation automatically)
form Products::ModelForm
# 3. Dependency validation (404 if missing)
finder :category, -> { Category.find_by(id: form_params.category_id) }
# 4. Form params validation (422 if invalid)
# form_params automatically validated by form definition
# 5. Business logic (only runs if all validation passes)
def on_validation_success
call if current_action.create?
end
def call
product = Product.create!(form_params.to_h)
add_to_stock(product)
notify_team(product)
end
private
def add_to_stock(product)
PointOfSale.find_each do |pos|
PosProductStock.create!(pos: pos, product: product, on_hand: 0.0)
end
end
def notify_team(product)
ProductMailer.created(product).deliver_later
end
end
# Controller becomes declarative traffic cop
class ProductsController < ApplicationController
action :new, handler: :create do |handler|
@product = handler.product
@form = handler.form
end
action :create do |handler|
handler.success { redirect_to handler.product }
handler.failure { @product = handler.product; @form = handler.form; render :new }
end
end
What's different?
Not just "business logic extracted." The entire validation hierarchy is explicit:
- URL params (400): Wrong format, invalid action
- Dependencies (404): Resource doesn't exist
- Form params (422): Invalid data format
- Business logic: Only runs if 1-3 pass
Service objects collapse all of this into if result.success?
You can't tell if it failed because of invalid params, missing dependencies, or business logic. One bucket. Manual error handling everywhere.
Handlers separate concerns by HTTP semantics. Different failure modes get different responses.
Leveling Up: The Validation Hierarchy I Was Missing
Let me show you why this matters.
With service objects:
result = CreateProductService.new(params, current_user).call
if result.success?
redirect_to result.product
else
# result.error could be:
# - "Unauthorized"
# - "Invalid params"
# - "Category not found"
# - "Product already exists"
# - Model validation errors
#
# All failures look the same. How do I respond?
# Manual conditionals everywhere.
flash[:error] = result.error # ← String? Hash? Object?
render :new
end
With handlers:
action :create do |handler|
# url_params invalid? → 400 (automatic, before block runs)
# category not found? → 404 (automatic, finder fails)
# form_params invalid? → 422 (automatic, form validation fails)
handler.success { redirect_to handler.product } # ← Only business logic success
handler.failure { render :new } # ← Form errors automatically in @form
end
The mechanics:
- 400: Client sent garbage → reject before processing
- 404: Resource doesn't exist → can't proceed
- 422: Data format wrong → validation errors
- 200: Everything valid, business logic executed
Service objects treat all failures as equal. You manually sort them out.
Handlers treat failures by HTTP semantics. The framework sorts them out.
This isn't just cleaner code. It's correct failure modes mapped to HTTP semantics.
I spent years writing manual conditionals to distinguish these cases. The hierarchy was always there—I just wasn't making it explicit.
The Symmetry Revelation: One Handler, Multiple Actions
Here's a paradox I lived with for years:
new and create are two sides of the same coin. Same domain concept. Same data format. Same setup.
Yet I treated them as separate silos:
# Traditional approach
def new
@product = Product.new
@category = Category.find(params[:category_id])
authorize @product
setup_defaults(@product)
end
def create
@product = Product.new(product_params)
@category = Category.find(product_params[:category_id]) # ← Duplicated
authorize @product # ← Duplicated
setup_defaults(@product) if @product.valid? # ← Duplicated
if @product.save
redirect_to @product
else
render :new
end
end
# With service objects
class CreateProductService
def initialize(params, current_user)
@params = params
@current_user = current_user
@category = Category.find(params[:category_id]) # ← Still duplicated
authorize! # ← Still duplicated
end
def call
product = Product.new(product_params)
# ...
end
end
# But NEW action still does all the setup separately
def new
@product = Product.new
@category = Category.find(params[:category_id]) # ← STILL DUPLICATED
authorize @product # ← STILL DUPLICATED
end
Service objects don't fix this. They only handle create. new still duplicates setup.
Why? Because we think of them as separate actions. But they're not:
-
new(GET): Display the contract -
create(POST): Fulfill the contract
Same contract. Same setup. Same handler.
class Products::CreateHandler < ApplicationHandler
form Products::ModelForm
finder :category, -> { Category.find_by(id: url_params.category_id || form_params.category_id) }
verify memoize def product
Product.new(form_params.to_h)
end
def on_validation_success
call if current_action.create? # ← Only execute on POST
end
def call
product.save!
end
end
# Controller
action :new, handler: :create do |handler|
@product = handler.product # ← Same product setup as create
@form = handler.form # ← Same form
@category = handler.category # ← Same category finding
end
action :create do |handler|
handler.success { redirect_to handler.product }
handler.failure { render :new }
end
One handler. Two actions. Zero duplication.
Category finding happens once. Authorization happens once. Product setup happens once.
new displays the form. create executes the logic. But they share the exact same soul and data contract.
This applies to show/edit/update/destroy too—one UpdateHandler for all four.
I spent years treating these as separate because controllers made me think in actions. Handlers made me think in domain operations.
The Declarative Traffic Cop
The controller becomes a declarative traffic cop:
# Before: Imperative coordination
def create
@product = Product.new(product_params) # ← Manual setup
@category = Category.find(params[:category_id]) # ← Manual dependency
authorize @product # ← Manual authorization
if @product.invalid? # ← Manual validation
@errors = @product.errors
render :new
return
end
if @product.save # ← Manual persistence
add_to_stock(@product) # ← Manual side effects
notify_team(@product)
redirect_to @product
else
render :new
end
end
# After: Declarative delegation
action :create do |handler|
handler.success { redirect_to handler.product }
handler.failure { render :new }
end
The controller doesn't coordinate. It delegates to a handler that has an explicit validation hierarchy.
The handler doesn't just execute business logic. It defines the entire contract:
- What URL params are valid
- What dependencies are required
- What form data is expected
- What business logic runs
The form generates params validation. Change the form, params update automatically.
No drift. One source of truth. Impossible to forget.
Service objects got me halfway: "extract business logic." Handlers finished the job: "make the contract explicit and automatic."
Completing the Abandoned Task: The Infrastructure We Forgot
Let's examine what the community actually did.
2012-2014: "Fat controllers are bad"
2015-2017: "Service objects are the answer"
2018-2020: "Wait, this still doesn't solve params drift / validation scatter / duplication"
2021-2026: ...crickets...
We identified the problem. We started the solution. Then we stopped before building the infrastructure.
Service objects are POROs. "Plain Old Ruby Objects." By definition: no framework support.
Which means:
- No standard interface (everyone invents their own)
- No automatic params generation (manual drift)
- No validation hierarchy (one
ifstatement for everything) - No form integration (still writing ERB)
- No action grouping (still duplicating
new/create) - No HTTP-semantic error handling (strings everywhere)
We called this "simplicity." It's not simplicity. It's incompleteness.
The "Rails Way™" didn't evolve because addressing these requires admitting:
- Forms should generate their own params (tight coupling is good here)
- Validation has a hierarchy (URL → Dependencies → Form → Business)
- Related actions share a contract (
new/createare one operation) - Controllers should be declarative, not imperative
This challenges core Rails assumptions.
So we lived with params drift. We duplicated setup. We manually sorted failure modes. We called it "pragmatic."
But it's not pragmatic when your app grows past 50 controllers and you're debugging params drift every month.
Is it time to admit traditional Rails patterns fail when the domain gets messy? And finally finish the architectural work we started?
The Half-Finished House Analogy
Refactoring with handlers is like moving into a house where the previous owners (the community) put up beautiful framing for "Thin Controllers" but left before installing the plumbing or the roof.
I spent years getting wet every time it rained (params drift, validation inconsistency, duplication bugs), calling it "minimalism," until I realized: properly applied architecture isn't over-engineering. It's finishing the house.
The framing (service objects):
- Extract business logic ✓
- Make it testable ✓
The plumbing (handlers):
- Explicit data contract (form + params)
- Structured validation hierarchy (400/404/422/200)
- Action grouping (one handler, multiple actions)
- Automatic params generation (no drift)
The roof (complete infrastructure):
- Declarative controllers (traffic cops)
- Form-driven development (contract-first)
- HTTP-semantic error handling
- Zero duplication by design
Service objects got us the framing. Handlers install the plumbing and roof.
You can live in a house with just framing. I did, for years. But you'll get wet when it rains. And eventually you'll wonder: why am I tolerating this?
The Paradox We Ignored
Here's the final paradox:
We extracted business logic to "simplify."
But we left the contract implicit, validation scattered, params manual, and actions duplicated.
The extraction didn't simplify. It just moved complexity around.
Handlers complete the simplification:
- Contract explicit (form)
- Validation structured (hierarchy)
- Params automatic (generated)
- Actions grouped (shared context)
True simplicity isn't "less code." It's explicit constraints that prevent entire classes of bugs.
I spent six years learning this. Maybe you can learn it faster.
Resources
- SteelWheel - Handler framework
- ActionForm - Form DSL
- EasyParams - Type-safe parameters
- Real App - Production example
If you're still living under the tarp, ask yourself:
Is params drift acceptable? Is validation scatter acceptable? Is new/create duplication acceptable?
Or is it time to finish installing the plumbing?
The framing has been up for a decade. Let's finish the house.
Top comments (1)
Thanks for the article