Advanced Domain Modeling Techniques for Ruby on Rails – Part 3
Supercharge Your Ruby on Rails Forms With Form Builders and Form Objects
Custom form helpers and builders can help reduce fragmentation if every developer in the team solves the same problem differently.
Have you come across instances where inside your Rails forms you need to fall back to plain input tags, like this form using a honeypot to prevent spam signups?
<%= form_with model: @signup do |f| %>
<%= f.email_field :email %>
<input type="hidden" name="utm_source" value="<%= params[:utm_source] %>">
<!-- Honeypot: should stay blank -->
<div class="hp" aria-hidden="true">
<label for="signup_company">Company</label>
<input type="text" id="signup_company" name="company" tabindex="-1" autocomplete="off">
</div>
<% end %>
…or forms that need consistent wrappers and classes for every field?
<%= form_with model: @user do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: "input input--email" %>
<small class="hint">We will never share this.</small>
<span class="error"><%= @user.errors[:email].first %></span>
<%= f.label :name %>
<%= f.text_field :name, class: "input input--text" %>
<small class="hint">First and last name.</small>
<span class="error"><%= @user.errors[:name].first %></span>
<% end %>
…or forms that need real-time validation (like checking subdomain availability)?
<%= form_with model: @account do |f| %>
<%= f.text_field :subdomain,
data: {
controller: "availability",
action: "input->availability#check",
availability_url_value: check_subdomain_path
} %>
<span data-availability-target="status"></span>
<% end %>
…or forms that should auto-submit on change?
<%= form_with url: search_path, method: :get, data: { controller: "autosubmit", action: "change->autosubmit#submit" } do |f| %>
<%= f.select :sort, [["newest", "new"], ["popular", "popular"]] %>
<%= f.check_box :only_available %> only available
<% end %>
While in small projects you might be able to deal with this on a case-to-case basis, the larger the project (and the team) gets, the more it pays off to organize such use cases into a pattern library. Custom form helpers and builders can help reduce fragmentation if every developer in the team solves the same problem differently.
Rails Form Helpers to the Rescue
Let’s look at custom form helpers and builders, with the main narrative focused on the most common pain point first: duplicated field markup for labels, hints, and errors.
Custom Form Builders
When your forms implement a certain design system, or use a component library like WebAwesome, it’s beneficial to abstract away the boilerplate this introduces. Instead of repeating the structure again and again, which is error prone and reduces clarity:
<%= form_with model: @user do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: "input input--email" %>
<small class="hint">We will never share this.</small>
<span class="error"><%= @user.errors[:email].first %></span>
<%= f.label :name %>
<%= f.text_field :name, class: "input input--text" %>
<small class="hint">First and last name.</small>
<span class="error"><%= @user.errors[:name].first %></span>
<% end %>
It would be preferable if we could just write this:
<%= styled_form_with model: @user do |f| %>
<%= f.email_field :email %>
<%= f.text_field :name %>
<% end %>
To make this work, we will use a custom form builder class called StyledFormBuilder:
# app/helpers/styled_form_builder.rb
class StyledFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
styled_field(method, options,
input_class: "input input--text"
) { super(method, options) }
end
def email_field(method, options = {})
styled_field(method, options,
input_class: "input input--email"
) { super(method, options) }
end
private
def styled_field(method, options, input_class:)
hint = options.delete(:hint)
options[:class] = [input_class, options[:class]].compact.join(" ")
@template.safe_join([
label(method),
yield,
hint_tag(hint),
error_tag(method),
])
end
def hint_tag(hint)
return "".html_safe if hint.blank?
@template.content_tag(:small, hint, class: "hint")
end
def error_tag(method)
message = object&.errors&.[](method)&.first
return "".html_safe if message.blank?
@template.content_tag(:span, message, class: "error")
end
end
We use a shared styled_field method here that creates the joined structure via @template.safe_join. Inside, we assemble label, hint and error tags and yield to the calling method.
In this shortened case, these methods are just the text_field and email_field wrappers, which call the superclass’s implementations to display the actual <input> tags.
Now all that’s left to do is wrap this form builder in its own helper:
# app/helpers/form_helper.rb
module FormHelper
def styled_form_with(**options, &block)
options[:builder] = StyledFormBuilder
form_with(**options, &block)
end
end
If we want to make our form builder the default one, we can do that in an initializer. Note that I wouldn’t encourage this, as it obscures the fact that we are using a custom builder at all.
# config/initializers/form_builder.rb
# Optional: make StyledFormBuilder the default for form_with/form_for.
ActionView::Base.default_form_builder = StyledFormBuilder
Side note: Rails wraps fields with errors in a field_with_errors element by default via ActionView::Base.field_error_proc, so your labels/inputs may be wrapped when validations fail, and the desired HTML structure will not be obeyed. You can override this in an initializer if needed:
# config/initializers/field_error_proc.rb
# Example: remove the default wrapper.
ActionView::Base.field_error_proc = proc { |html_tag, _instance| html_tag }
Use Form Objects to Handle Non-Model User Input
You might ask “where’s the domain modeling part”? Custom forms are better framed as application-layer constructs that model user interaction. They still reflect the structure of the domain, but they are not domain entities themselves.
But we can go further than that, by inserting an additional logic layer if the boundaries between the persistence, controller, and view layers become too fuzzy.
This sounds both intriguing and cryptic - what do I mean by that? Well, we already lightly touched on input fields that are not part of the model’s interface the form is dealing with. But also coupling models too tightly to the view is a flag, because the model now carries UI-specific concerns that don’t belong to its domain behavior. Consider this example that could stem from a custom CRM, a Contact model that mixes persistence with UI-facing behavior:
class Contact < ApplicationRecord
validates :name, presence: true, if: :follow_up
validates :email, presence: true
attribute :should_send_welcome_email, :boolean, default: false
attribute :follow_up, :boolean, default: false
after_create_commit :send_welcome_email, if: :should_send_welcome_email
end
This Contact model handles persistence and validation, but it also drives view-facing behavior: it infers whether name is required and optionally triggers a welcome email based on a checkbox. That mix of concerns is what makes the boundary feel fuzzy.
Here’s the accompanying form:
<%= form_with model: @contact do |f| %>
<%= f.text_field :name %>
<%= f.email_field :email %>
<%= f.checkbox :should_send_welcome_email %>
<%= f.submit %>
<% end %>
The controller boilerplate could look like this:
class ContactsController < ApplicationController
def new
@contact = Contact.new
end
def create
@contact = Contact.new(contact_params)
if @contact.save
redirect_to @contact
else
render :new
end
end
private
def contact_params
params.require(:contact).permit(:name, :email, :should_send_welcome_email)
end
end
As you can have probably noticed, the form already exhibits some indistinct coupling between view and model:
- the
nameattribute is subtly coupled to the view (or route) chosen because the form follows different validation logic depending on whether a follow-up flow has started (then he/she is asked to fill in the name him/herself). should_send_welcome_emailis a transitive attribute without database backing. In fact, we run into an issue here already: If validation fails, the form will be re-rendered, losing the checkbox’s value if we don’t store it in the session/a cookie.
Why is this problematic? Should a model know about UI concerns such as re-rendering a checkbox, or preparing the correct view state? No! In the MVC pattern, it’s not allowed to go up the architecture stack.
This problem could probably still be dismissed if it didn’t occur so often. In fact, generic forms (i.e. like the ones scaffolded by Rails controllers) aren’t the standard. You’re much more likely to come across occurrences where concerns are mixed like above. The extreme case of this is when you have handle multiple models of different types in one form.
Typical signals to watch for are:
- use of
before|after_create|updatehooks with side effects - conditional validation based on application state
- reaching out to mutate other models from within a model’s logic (violates model boundaries)
Form Objects as Intermediate Logic Layer
How do we deal with this situation? We could move some of this code to the respective controller(s), but over time this will lead to duplication and make it brittle.
In the introduction to this part we already mentioned the possibility of using an intermediate logic layer. Let’s put this into practice now by creating a PORO for this in app/forms, but let’s first consider what we want this object to encapsulate:
- it should handle the conditional validation transparently,
- it should mimic an ActiveRecord model, so it can be interchangeably used in the views and controllers,
- it should trigger all side effects occurring when a contact is created.
In short: it should be responsible for responding to user interaction with a contact form, and it should be the only place for such interaction-specific business logic.
Build the First Form Object
Now let’s start with the implementation. First we include the ActiveModel::Model and ActiveModel::Attributes modules, which give us
- an attribute schema builder
- mass assignment of these attributes in an initializer
- validations
class ContactForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :name, :string
attribute :email, :string
attribute :should_send_welcome_email, :boolean, default: false
attribute :follow_up, :boolean, default: false
validates :name, presence: true, if: :follow_up
validates :email, presence: true
def save
return false unless valid?
ActiveRecord::Base.transaction do
Contact.create(
name: name,
email: email,
follow_up_started_at: (follow_up ? Time.current : nil)
)
deliver_welcome_email!
end
end
private
def deliver_welcome_email!
ContactMailer.welcome(name, email).deliver_later if should_send_welcome_email
end
end
The model itself can now be dramatically simplified:
class Contact < ApplicationRecord
end
Extract an ApplicationForm Base Class
Before we continue along this path, let’s extract an ApplicationForm base class. One reason to do this is to move all includes and generic code there. But we can also put more verbose setup code there, for example the setup of model callbacks. In our case we’ll just define an after_save callback we can then use in our ContactForm to more expressively handle the sending of emails:
class ApplicationForm
include ActiveModel::Model
include ActiveModel::Attributes
extend ActiveModel::Callbacks
define_model_callbacks :save, only: :after
class << self
def after_save(...)
set_callback(:save, :after, ...)
end
end
def save
return false unless valid?
ActiveRecord::Base.transaction do
run_callbacks(:save) { yield }
end
end
end
The contact form looks much simpler and contains only code to describe the actual user interaction:
class ContactForm < ApplicationForm
attribute :name, :string
attribute :email, :string
attribute :should_send_welcome_email, :boolean, default: false
attribute :follow_up, :boolean, default: false
validates :name, presence: true, if: :follow_up
validates :email, presence: true
after_save :deliver_welcome_email!, if: :should_send_welcome_email
delegate :to_param, :id, to: :contact, allow_nil: true
def save
super do
contact.save
end
end
def contact
@contact ||= Contact.new(
name: name,
email: email,
follow_up_started_at: (follow_up ? Time.current : nil)
)
end
private
def deliver_welcome_email!
ContactMailer.welcome(name, email).deliver_later
end
end
Make It Quack Like ActiveRecord
Now it finally starts quacking like an ActiveRecord instance. We’re almost ready to swap our @contact controller variable for an instance of ContactForm. One critical missing piece is that route helpers and other methods like redirect_to expect a class that exposes a model_name class method. We can just add this to ApplicationForm and fall back to convention over configuration:
class ApplicationForm
# ...
class << self
def model_name
ActiveModel::Name.new(
self, nil, name.sub(/Form$/, "")
)
end
end
# ...
end
We strip Form from the class name and thus mimic a model of type Contact. Now we can use it in both controller and view:
class ContactsController < ApplicationController
def new
@contact_form = ContactForm.new
end
def create
@contact_form = ContactForm.new(contact_params)
if @contact_form.save
redirect_to @contact_form
else
render :new
end
end
private
def contact_params
params.require(:contact).permit(:name, :email, :should_send_welcome_email, :follow_up)
end
end
<%= form_with model: @contact_form do |f| %>
<%= f.text_field :name %>
<%= f.email_field :email %>
<%= f.checkbox :should_send_welcome_email %>
<%= f.submit %>
<% end %>
Note that implicitly, form_with will now point to /contacts, and redirect_to @contact_form will send the browser to /contacts/{id}.
Encode the Follow-Up Context
One important question remains: how do we decide when the follow-up flow starts? A simple approach is to pass a lightweight follow_up flag through the form (for example via a query param or hidden field) and let the form translate it into follow_up_started_at when it persists the Contact. If you want all submits to go through the same controller, the controller can stay very lean:
class ContactsController < ApplicationController
def new
@contact_form = ContactForm.new
end
def create
@contact_form = ContactForm.new(contact_params)
# save and redirect ...
end
private
def contact_params
params.require(:contact).permit(:name, :email, :should_send_welcome_email, :follow_up)
end
end
This keeps follow_up_started_at out of params while still letting you reuse ContactsController. The follow_up attribute is automatically cast to a boolean by ActiveModel::Attributes, so values like "1" or "true" behave as expected.
Bubble Up Model-Level Errors
One remaining detail is how to surface model-level validation errors in the form. A simple pattern is to build the Contact instance inside the form, validate it, and then copy any contact.errors into the form’s own errors so the view can render them.
class ContactForm < ApplicationForm
validate :contact_is_valid
def contact
@contact ||= Contact.new(
name: name,
email: email,
follow_up_started_at: (follow_up ? Time.current : nil)
)
end
private
def contact_is_valid
return if contact.valid?
errors.merge!(contact.errors)
end
end
Now a failed model validation (like missing email) shows up on @contact_form.errors and can be rendered in the form just like any other validation.
Optional: A Factory for Strong Params
To keep the controller lean, you can add a small factory method and inline strong params there:
class ContactForm < ApplicationForm
class << self
def for(params)
new(params.permit(:name, :email, :should_send_welcome_email, :follow_up))
end
end
end
Now your controller can be even smaller:
class ContactsController < ApplicationController
def new
@contact_form = ContactForm.new
end
def create
@contact_form = ContactForm.for(params.require(:contact))
# save and redirect ...
end
end
Now our implementation is complete: it covers interaction-specific validation, context, and side effects, while keeping persistence logic in the model and request handling in the controller.
Conclusion
This article ties together two complementary approaches: form builders to standardize presentation and reduce boilerplate, and form objects to isolate interaction-specific rules, validation context, and side effects. The result is a clean split of responsibilities: views stay consistent, controllers stay thin, and models focus on persistence rather than UI orchestration.
If you only take one thing away, let it be this: treat the form as its own application-layer boundary. Once you do, you can grow form complexity safely—adding custom inputs, dynamic behavior, or follow-up flows—without contaminating your ActiveRecord models with view-dependent logic.
Appendix: Two Lightweight Helper Patterns
If you need smaller tactical abstractions before introducing a full custom builder, these two patterns can help.
1) Wrap form_with
The simplest case is when you can just reuse Rails’ own form_with helper by enclosing it in your own wrapper. For example, if you only need to add or modify a couple of attributes of the form element, you could do this in a helper module:
module FormsHelper
def auto_submit_form_with(**attributes, &)
data = attributes.delete(:data) || {}
data[:controller] = ["autosubmit", data[:controller]].compact.join(" ")
data[:action] = ["change->autosubmit#submit", data[:action]].compact.join(" ")
form_with **attributes, data: data, &
end
end
We have to be careful in this case, though, not to overwrite any existing data attributes, so we insert the autosubmit controller and the change->autosubmit#submit actions into the existing ones.
Applying it to the example above, you can now conveniently abbreviate the call to build your auto submitting form:
<%= auto_submit_form_with url: search_path, method: :get do |f| %>
<%= f.select :sort, [["newest", "new"], ["popular", "popular"]] %>
<%= f.check_box :only_available %> only available
<% end %>
Note that it’s not really feasible to stack such wrappers, i.e. you can’t achieve real polymorphism. Sometimes it’s just simple enough, though. You now have a consistent way of creating such self-submitting forms across your application.
The mentioned sidecar Stimulus controller, for completeness’ sake, could look like this:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.requestSubmit()
}
}
2) Custom Form Inputs
Let’s go further up the complexity ladder. If you need a special kind of form input that can’t be derived from an existing one, the approach from above won’t cut it - but monkey patching ActionView::Helpers::FormBuilder will.
Looking at the availability checking example from above, we could concoct a validate_availability_field (for lack of a better name). Put this in an initializer and wrap it in an ActiveSupport.on_load(:action_view) hook so the class is loaded (we reuse some of the data attribute mangling from above):
ActiveSupport.on_load(:action_view) do
ActionView::Helpers::FormBuilder.class_eval do
def validate_availability_field(attribute, options = {})
data = options.delete(:data) || {}
data[:controller] = ["availability", data[:controller]].compact.join(" ")
data[:action] = ["input->availability#check", data[:action]].compact.join(" ")
data[:availability_url_value] = options.delete(:availability_url)
options[:data] = data
text_field(attribute, options) + @template.content_tag(:span, nil, data: { availability_target: "status" })
end
end
end
We can now conveniently apply this new form field along with its custom option:
<%= form_with model: @account do |f| %>
<%= f.validate_availability_field :subdomain, availability_url: check_subdomain_path %>
<% end %>
These helpers are useful, but the primary patterns for maintainability remain: custom builders for presentation and form objects for interaction logic.
One of the most frequent code smells in Rails is an excessive use of inheritance. A serious drawback from using inheritance to achieve polymorphism is the implicit coupling it creates between parent and child classes.