Form Objects, the missing abstraction in Rails
Stop putting the right code in the wrong place!
It’s easy to get carried away using Rails scaffolding, but sometimes a Plain Old Ruby Object (PORO) is more than appropriate. And Form Objects are very good at keeping models lean, controllers sharp, and grouping related code together in one object that’s easy to test.
Form Object basics
Form Objects get their name from their main use case: being the backend to a frontend form.
This can be a filtering mechanism backed by complex queries, or a registration form which needs specific validations and creates multiple records for instance.
In these canonical examples, we notice the need for an abstraction because:
- a model, its validations or callbacks grow out of hand,
- a controller action interacts with several models,
- queries, scopes or a group of related scopes coalesce in a model,
- testing becomes difficult, as more objects need to be instanciated or persisted.
While not actually deadly sins, these code smells indicate that we need to keep the code under control to prevent code rot —and contagion.
Form objects encapsulate form-related logic and validations. They typically act as an intermediary between the view and one or more models, handling data processing and validation before eventually persisting to the database.
Setting up Form Objects in a Rails project
The convention is to put Form Objects in an app/forms
folder. This will make it easy for new devs to see how they differ from regular models, and where to add new form objects.
Thanks to Rails autoloading, classes in that folder will automatically be available in controllers and elsewhere.
It’s also a good idea to create a base ApplicationForm
class, from which all form objects in your app will inherit, as per Rails conventions.
This base class would need to let you manage attributes and state, add validations, keep changes atomic, etc.
You can either use a gem for that, or build it yourself to avoid adding a new dependency.
Using a gem
The Form Object category in the Ruby Toolbox lists several projects. I haven’t used Reform, Virtus or Dry-validation, but I would probably avoid them since they are no longer being actively maintained, as they will probably break when Ruby or Rails syntax evolves.
One of the most-used is Makandra’s ActiveType, but I’m uneasy seeing that they overwrite ActiveRecord
’s attribute
method.
My personal experience with YAAF
was successful, though I had to augment it with nesting methods —which come natively in ActiveType
.
This was easily done using a base ApplicationForm
from which my forms inherited macros.
If you choose not to use a gem, you’ll need to build the foundation for managing attributes and state, performing atomic changes, and building scopes.
Managing attributes and state
Form Objects often need to validate params or attribute values. Fortunately, Rails makes this easy to achieve just by including ActiveModel::Model
.
After adding this line in a plain Ruby class, we can now declare attributes which will be typecast, as well as validations and callbacks, all using the familiar Rails syntax.
It’s also considered good practice to add a Rails-like public interface (save/save!
, create/create!
, etc) to form objects. There’s no need for controllers to know that the model it interacts with is a form object or a regular model.
By implementing a Rails-like interface, you may even be able to extract a Form Object from an existing model, without needing to change a single line of code in the associated controller.
Making sure changes are atomic
A frequent requirement in a user registration workflow is to create a user and associated account, and send a welcome email. Should the user or account creation fail, the welcome email should not be sent or enqueued.
Rails provides transaction blocks, which automatically rollback if any error is raised during the block.
You can use the generic ActiveRecord::Base.transaction
, or be more class-specific: Foo.transaction do … end
.
This last syntax is more readable —and more future-proof, now that Rails apps may have more than one database.
Beware that jobs enqueued inside a transaction used to be enqueued immediately, and could run before the transaction was committed, or pass through even if the transaction was eventually rolled back!
Complex queries
If a Form Object needs to filter records, it’s best to allow chaining, pagination, etc by returning an ActiveRecord::Relation
instead of an array.
An easy way to make sure you’re returning such a relation is to have an internal scope
, created using Foo.all
in the case of a model called Foo
, and augment it in each method.
You can also leverage the extending
Rails helper to add methods to an existing scope.
Demo time
Now that we’ve covered the basics, let’s apply the Form Object pattern to refactor and improve code.
Example legacy code
Imagine that the registration process mentioned earlier happened to use the following code:
# app/models/user.rb
class User < ApplicationRecord
attr_accessor :company_name
belongs_to :company
validates :email, presence: true
validates :password, presence: true, length: { minimum: 8 }
validates :company_name, presence: true, on: :create
before_create :set_company
after_create :send_slack_notification
def set_company
self.company ||= Company.find_or_create_by(name: company_name)
end
private
def send_slack_notification
SlackNotifier.self_congratulate
end
end
# app/controllers/users_controller.rb
def UsersController < ApplicationController
# POST /users
def create
@user = User.new(user_params)
if @user.save
UserMailer.with(user: @user).welcome.send_later
redirect_to dashboard_path
else
render :new
end
end
end
Here are two of the most salient problems:
- Setting the company pollutes the model (adding an accessor, a validation, callbacks, and private methods), though it is only needed when creating a user.
- Two different actions are triggered when a user signs up: a welcome email, and a Slack notification, each from a different code location. Should a batch of users need to be imported from the command line or a file, the Slack notifications would come flying, but none of the new users would receive a welcome email.
Adding tests
We need to make sure our code is properly covered by tests before refactoring, adding features or fixing bugs. This will also help us make sure we understand the problem and add just enough code to perform the task we set out to tackle.
Since we’ll move the responsibility of sending notifications to the Form Object, we’ll only need unit tests. To make sure the test only exercises the object we’re interested in, we’ll mock the external dependency on Slack Notifier. Ideally, we should mock persisting the user, which would greatly speed up the test runs, but I’ll leave that out in this article.
Rspec.describe UserRegistrationForm do
describe "#save" do
let(:params) do
{
user: {
email: Faker::Internet.email,
password: Faker::Alphanumeric.alphanumeric(number: 10)
},
company_name: Faker::Company.name
}
end
let!(:slack_notifier) { instance_double("SlackNotifier", self_congratulate: true) }
subject(:registration) { UserRegistrationForm.new(params) }
context "when params contains valid values" do
it "returns true" do
expect(registration.save).to eq true
end
it "enqueues a welcome email" do
expect { registration.save }.to have_enqueued_mail(UserMailer, :welcome)
end
it "sends a Slack notification" do
registration.save
expect(slack_notifier).to have_received(:self_congratulate)
end
end
context "when params contains invalid values" do
let(:params) { {} }
it "returns false" do
expect(registration.save).to eq false
end
it "doesn't enqueue a welcome email" do
expect { registration.save }.not_to have_enqueued_mail(UserMailer, :welcome)
end
it "doesn't send a Slack notification" do
registration.save
expect(slack_notifier).not_to have_received(:self_congratulate)
end
end
end
end
Refactoring using a Form Object
If you’re new to Form Objects, I recommend installing the YAAF gem to get avoid reinventing the wheel. Run bundle add yaaf
, then create a base ApplicationForm
inheriting from YAAF::Form
.
In case of invalid input, the YAAF
gem promotes errors, so calling .errors
on the form object returns the errors from the underlying models, all with the appropriate keys, ready to be displayed in the view.
Now let’s extract the user-creation code into a form object. We need to find a name for it first, and UserRegistrationForm
seems both accurate and self-explanatory.
Next, we’ll move registration-specific code from the User model and controller into this new class.
Here’s what we could arrive at:
# app/models/user.rb
class User < ApplicationRecord
belongs_to :company
validates :email, presence: true
validates :password, presence: true, length: { minimum: 8 }
end
# app/controllers/users_controller.rb
def UsersController < ApplicationController
# POST /users
def create
@user = UserRegistrationForm.new(user_params)
if @user.save
redirect_to dashboard_path
else
render :new
end
end
private
def user_params
params.require(:user).permit(:email, :password, :company_name)
end
end
# app/forms/application_form.rb
def ApplicationForm < YAAF::Form
# Added for consistency
def new_record?
true
end
end
# app/forms/user_registration_form.rb
def UserRegistrationForm < ApplicationForm
attr_accessor :user_attributes
attr_accessor :company_name
before_validation :set_company
after_commit :send_notifications
def initialize(attributes)
company_name = attributes.delete(:company_name)
super(user_attributes: attributes)
@models = [user]
end
private
def set_company
user.company = Company.find_or_create_by(name: company_name)
end
def send_notifications
UserMailer.with(user:).welcome.send_later
SlackNotifier.self_congratulate
end
end
Conclusion
While it may be tempting to follow the RESTful routing convention and create resources for each HTTP endpoint, some concepts don’t map to models or database tables. Even though Rails doesn’t actively encourage using Form Objects, it’s easy to leverage their powers using gems or native helpers.
Form objects are quite powerful because it’s easy to extract them from legacy code without changing their interface, yet they greatly improve readability and extendability. It’s also easier to test their behaviour without touching the database, reducing the time and cost of running the test suite. If you’re ever tempted to create several records inside an action, need to perform specific validation in a particular context, or respond to a complex filtering form, evaluate if a Form Object would be appropriate!
Bonus points: adding macros to YAAF
In a project using Form Objects, I found it practical to manage nested models using Rails-like macros. I’ve implemented them on top of YAAF by injecting a module into the class, just like Rails does. Here’s the code I used:
class ApplicationForm < YAAF::Form
class << self
# This is useful for I18n, dom_id, etc
def to_model
name.sub("Form", "").safe_constantize
end
# This allows adding Rails-like macros (has_one, accepts_nested_attributes_for…)
def association_macros
@association_macros ||= begin
mod = const_set(:GeneratedAssociationMacros, Module.new)
include mod
mod
end
end
# This adds [model]= , [model]_id, and [model]_id= methods, just like Rails does
def has_one(model, **options)
klass = options.fetch(:class, model.to_s.classify)
association_macros.class_eval <<-CODE, __FILE__, __LINE__ + 1
attr_writer :#{model}
delegate :id, to: :#{model}, prefix: true, allow_nil: true
def #{model}
@#{model} ||= #{klass}.new
end
def #{model}_id=(id)
@#{model} = #{klass}.find(id) if id.present?
end
CODE
end
# This allows setting attributes for a nested has_one model
def accepts_nested_attributes_for(model, **options)
klass = options.delete(:class) || model.to_s.classify
association_macros.class_eval <<-CODE, __FILE__, __LINE__ + 1
attr_reader :#{model}_attributes
def #{model}_attributes=(params = {})
@#{model} = #{klass}.new(params) if params.present?
end
CODE
end
end
end
And here is a spec file describing these macros and making sure that they work as expected.
# spec/forms/application_form_spec.rb
RSpec.describe ApplicationForm do
describe ".association_macros" do
it "includes the GeneratedAssociationMacros module" do
described_class.association_macros
expect(described_class.ancestors.second).to eq ApplicationForm::GeneratedAssociationMacros
end
end
describe ".has_one" do
let(:dummy) {
class DummyAssociation; end
class Dummy < ApplicationForm
has_one :dummy_association
end
Dummy
}
[:dummy_association, :dummy_association=, :dummy_association_id, :dummy_association_id=].each do |method|
it "adds the #{method} method" do
expect(dummy.instance_methods).to include(method)
end
end
end
describe ".accepts_nested_attributes_for" do
let(:dummy) {
class DummyAssociation; end
class Dummy < ApplicationForm
accepts_nested_attributes_for :dummy_association
end
Dummy
}
[:dummy_association_attributes, :dummy_association_attributes=].each do |method|
it "adds the #{method} method" do
expect(dummy.instance_methods).to include(method)
end
end
end
end