Previously in this series:
- Part 1: The Service Object Graveyard: Why Your POROs Failed and Handlers Won
- Part 2: Constructor Hell and the "Owner" Bubble: Stealing from Browser JS
The Pattern Conflict
Take the paradox: Rails gives us ActionView::Helpers::FormBuilder - a thing literally named "Builder" - and yet complex forms still collapse into partial soup, param drift, and "where do errors render?" archaeology.
So let's pressure-test the premise.
- Rails FormBuilder is a Proxy: a yielded object that forwards calls to view helpers (a toolbox you operate).
- ActionForm (with Phlex) is Template Method: a rendering algorithm with named steps (an assembly line you override).
The difference is not "syntax." It's ownership of the rendering process.
Rails even says the quiet part out loud: FormBuilder "can be thought of as serving as a proxy for the methods in the FormHelper module" (Rails 8 FormBuilder docs).
1) The Pattern Conflict: Proxy vs Skeleton
Why did we spend a decade treating the form as a proxy for a model object instead of a skeleton of a rendering algorithm?
Because the ERB world is document-first.
You write a template; Rails yields a toolbox into it:
<%= form_with model: @person do |f| %>
<%= f.text_field :name %>
<%= f.checkbox :admin %>
<% end %>
The template owns sequencing, composition, and integration. The builder helps you emit tags, but it doesn't define the algorithm of "how a form is rendered."
2) The Source of Truth: Who Owns the Contract?
If a Rails FormBuilder is a proxy for helper methods, who owns the "data contract"?
In practice it gets split:
- Model owns validations (sometimes).
- Controller owns permitted params (always, if you use strong params).
- View owns what fields exist (implicitly, by emitting tags).
That's a three-body problem. When complex domains enter, the contract becomes a moving target: the UI can ask for fields the controller doesn't permit; the controller can permit keys the UI doesn't render; the model can validate attributes the UI never sends.
3) The Algorithm vs the Toolbox
Why is a rigid rendering flow more resilient than yielding a toolbox?
Because "toolbox" means every view is forced to re-invent ordering:
- where labels render
- where errors render
- how wrappers are structured
- what happens for nested collections
With Template Method, the base class owns the skeleton:
def view_template
render_form do
render_elements
render_submit
end
end
The structure is an algorithm. And the steps have names.
This is what Phlex enables: a view is not a string with holes - it is a Ruby object with a view_template entrypoint.
4) Surgical Overrides: Override a Step, Not a Document
How does Template Method let you override a single step—like render_label - without subclassing a FormBuilder and juggling glue like objectify_options?
Rails' extension story is: subclass FormBuilder, add helper methods, and be careful to call objectify_options so the model binding isn't silently severed (Rails 8 FormBuilder docs).
Template Method flips the problem: you don't add more tools; you override the algorithm.
Example: suppress all labels (or change their wrapper) without touching every field call site:
class NoLabelForm < ProductForm
def render_label(element)
# intentionally empty
end
end
The form stays a form. The rendering skeleton stays intact. Only one step changes.
5) The "Params Drift" Paradox
If FormBuilder is associated with a model, why is it so easy for parameters to drift away from the view definition?
Because "associated" is not "authoritative."
FormBuilder helps generate field names for an object. It does not produce the contract the controller permits. The contract is still written elsewhere, by hand, often long after the UI evolved.
ActionForm takes a different stance: the contract is derived from the form's declared elements.
That's the key inversion: instead of the UI being a projection of controller params, params become a projection of the UI's declared structure. (And because elements can be conditional—render?—it can generate a schema from the rendered structure when needed.)
The Form Elements DSL
How do you declare elements? ActionForm provides a DSL that defines both the UI structure and the data contract in one place:
class ProductForm < ActionForm::Base
element :title do
input type: :text
output type: :string, presence: true
end
element :price do
input type: :number, step: 0.01
output type: :decimal, presence: true, numericality: { greater_than: 0 }
end
element :description do
input type: :textarea, rows: 5
output type: :string
end
end
The DSL does three things at once:
-
Declares the input (
input) - what HTML attributes and type the field needs -
Declares the output (
output) - what type and validations the parameter contract expects -
Creates the element class - a Ruby object that can answer questions like
render?,readonly?,disabled?, orvalue
This is why "drift" becomes impossible: the form definition is the contract. The controller doesn't manually permit fields—it receives an EasyParams object generated from the form's output declarations.
Element Methods: Behavior as Code
Elements aren't just data structures—they're Ruby objects with methods you can override. This is where Template Method shines: you override behavior, not markup.
render? - Controls whether the element should be rendered:
element :admin_field do
input type: :text
def render?
object.admin? # Only render for admin users
end
end
readonly? - Controls whether the element is readonly:
element :email do
input type: :email
def readonly?
object.verified? # Readonly if email is verified
end
end
disabled? - Controls whether the element is disabled:
element :username do
input type: :text
def disabled?
object.persisted? # Disable for existing records
end
end
detached? - Indicates if the element is detached from the object (uses static values):
element :static_field do
input type: :text, value: "Static Value"
def detached?
true # This element doesn't bind to object values
end
end
tags - Access element metadata for conditional rendering:
element :priority_field do
input type: :text
tags priority: "high", section: "important"
end
# In your rendering algorithm:
def render_element(element)
render_label(element)
render_input(element)
render_inline_errors(element) if element.tags[:errors]
end
The tags hash automatically includes:
-
:input- The input type (e.g.,:text,:email,:select) -
:output- The output type (e.g.,:string,:integer,:bool) -
:options-trueif the element has options (for selects/radios) -
:errors-true/falsebased on validation state
These methods integrate with the rendering algorithm: render_elements can filter with select(&:render?), and render_input can check readonly? to add the readonly attribute. The element becomes a first-class participant in the rendering process, not just a data bag.
6) Composition vs Helpers
Why did we favor helpers for so long when Phlex-based composition lets UI elements be Ruby objects?
Because templates make composition look like copy/paste:
- partials
- locals
content_for- "just one more
if"
Composition (Phlex) makes it look like programming:
- objects
- methods
- inheritance
- tests against behavior
The important shift: UI parts can have state, methods, and boundaries. They can answer questions like "should I render?" without hiding that logic in template conditionals.
7) Handling Nesting: From Manual Chore to Structured Algorithm
Why is accepts_nested_attributes_for + templates so painful?
Because the UI needs a repeatable mechanism for:
- rendering each child
- rendering a "new child" template
- maintaining correct naming/indexing
In a toolbox world, you build that by convention and JS glue.
In an algorithm world, the base class can define the lifecycle: build instances, render instances, and even render a "NEW_RECORD" template consistently. Nesting becomes a first-class step, not a copy/pasted ritual.
8) Error Integration: Errors as a Built-In Phase
In proxy-based systems, error handling is typically "extra."
You decide:
- where to show inline errors
- how to style them
- how to aggregate them
Template Method makes errors a phase in the algorithm:
def render_element(element)
render_label(element)
render_input(element)
render_inline_errors(element) if element.tags[:errors]
end
That's not just convenience. It's enforcement: error rendering is now a default behavior of the skeleton, not an optional add-on scattered across templates.
9) The 6-Year Verdict
Is the FormBuilder proxy "enough" for simple CRUD? Yes.
But complex domains don't need more tools. They need a formal rendering algorithm:
- stable steps
- named override points
- consistent nesting and error phases
The "Industrial Assembly Line" Metaphor
Rails FormBuilder is a high-end toolbox (Proxy). It gives you the wrenches, but you decide the order of assembly every time you enter the garage.
ActionForm (with Phlex) is the assembly line (Template Method). The skeleton of the process is built into the floor. You can swap specific parts (override steps), but the line enforces sequencing: chassis before engine, inspection before shipping.
That's the difference between "UI as emitted strings" and "UI as an algorithm."
Resources
-
Libraries / examples
- SteelWheel - Handler framework
- ActionForm - Form DSL
- EasyParams - Type-safe parameters
- Real App - Production example
- Rails 8 FormBuilder docs
Ask yourself:
Are you shipping forms, or are you rebuilding the assembly line every time you render a field?
If the domain is complex, maybe the right move isn't "more helpers." Maybe it's ownership of the algorithm.
Top comments (0)