Excerpt

Advanced Domain Modeling Part 3
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?
```plain text
<%= 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?
```plain text
<%= form_with model: @user do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: "input input--email" %>
<small class="hint">We will never

Advanced Domain Modeling Part 3
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?
```plain text
<%= 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?
```plain text
<%= 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)?
```plain text
<%= 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?
```plain text
<%= 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:
```plain text
<%= 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:
```plain text
<%= 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:
```plain text
# 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:
```plain text
# 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.
```plain text
# 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:
```plain text
# 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:
```plain text
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:
```plain text
<%= 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:
```plain text
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 name attribute 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_email is 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|update hooks 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
```plain text
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:
```plain text
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:
```plain text
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:
```plain text
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:
```plain text
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:
```plain text
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
```
```plain text
<%= 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:
```plain text
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.
```plain text
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:
```plain text
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:
```plain text
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:
```plain text
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:
```plain text
<%= 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:
```plain text
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):
```plain text
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:
```plain text
<%= 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.

Julian Rubisch
/blog Advanced Domain Modeling Techniques for Ruby on Rails – Part 2 Polymorphism with Strategies
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.