RoRvsWild
Ruby APM & Error tracking Blog

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.

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?

  <%= 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 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
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.

/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.

RoRvsWild monitors your Ruby on Rails applications.

Try for free
RoRvsWild Ruby Request group
View gem
Try for free