In this post I’ll make the case for why form-backing objects help keep your app’s codebase clean and maintanable, and
I’ll show how to create, use and test a form-backing object.
Imagine that you are building a website where users can register and your stories look something like this:
- When a visitor registers a User record and Business record are created
- The user must accept the terms of service
- The user must correctly answer a simple math or logic question to ward off bots
- The site should log the IP address the of the user who registered
- After the registration is complete, the user should get an email
- If there are any problems or validation errors during registration, no User or Business should be created and nothing should be logged
These are pretty straightforward requirements, so you start coding:
Take 1: The Fat Controller
The first version might look something like this:
class UsersController < ApplicationController
def new
@question = AntiBotQuestion.random
@user = User.new
end
def create
@business = Business.new(params[:business])
@user = User.new(params[:user])
@question = AntiBotQuestion.find_by_id(params[:anti_bot_question_id])
objects_were_saved = false
begin
ActiveRecord::Base.transaction do
@business.save!
@user.save!
@business.memberships.create! :user => @user
end
objects_were_saved = true
rescue
end
if objects_were_saved && @question.answer == params[:anti_bot_question][:answer]
UserMailer.deliver_user_registered(@user)
IpLogger.log(request.remote_ip)
redirect_to root_path
else
render :action => "new"
end
end
end
If you’re like most Rails developers I know, you probably vomited a little in your mouth after looking at that code.
At first glance its biggest issue is that is violates the skinny-controller-fat-model guideline that’s become popular
recently, so following that guideline you decide to put your controller on a diet:
Take 2: The Fat Model
In an effort to make the controller skinny, you move most of the registration logic to the User model like so:
class UsersController < ApplicationController
def create
@user = User.new(params[:user])
if @user.save
redirect_to root_path
else
render :action => "new"
end
end
end
class User < ActiveRecord::Base
belongs_to :membership
accepts_nested_attributes_for :membership
validates_confirmation_of :terms_of_service
validate :on => :create do
errors[:base] << "Incorrect!" if answer != question.answer
end
attr_accessor :ip_address, :question, :answer
after_create do
UserMailer.deliver_user_registered
IpLogger.log(ip_address)
end
end
Skinny controller, fat model, ship it – right? Well, if I had to make the choice, I would choose the fat-controller option any day.
While the fat-controller code is ugly, at least it’s isolated. The fat model code:
- pollutes the entire model – tightly coupling User to Business
- adding network calls in models
- adding controller concerns like IP addresses to the model
Practically speaking, it adds overhead to every User creation throughout the app. Let’s say for example that you merge
codebases with another site (like through an acquisition) and you need to add all the other site’s users to your user
base – do you add fake IP addresses and confirmations, or do you add conditional logic to skip those callbacks? In addition,
you have overhead when creating a User in your test suite.
After looking at that, you might decide to introduce a form-backing object.
Take 3: Skinny Controller, Skinny Model, and Form-backing Object
Form-backing objects, also known as Presenters (not to be confused with the concept of view presenters), are objects
whose sole purpose is to take user-entered form data and perform some unit of work. Creating and testing form-backing objects
is simple. In this situation, you might add a Registration object.
The controller remains very simple:
class RegistrationsController < ApplicationController
def new
@registration = Forms::Registration.new
end
def create
@registration = Forms::Registration.new(params[:registration].merge(:ip_address => request.remote_ip))
if @registration.save
redirect_to root_path
else
render :action => "new"
end
end
end
The form becomes much simpler than either of the cases above, since there are no nested forms or multiple instance variables:
<%= form_for @registration, :url => registrations_path, :as => :registration do |f| %>
<%= f.error_messages %>
<%= f.label :name %>
<%= f.text_field :name %>
<%= f.label :email %>
<%= f.text_field :email %>
<%= f.label :anti_bot_answer, f.object.anti_bot_question.text %>
<%= f.text_field :anti_bot_answer %>
<%= f.check_box :terms_of_service %>
<%= f.label :terms_of_service %>
<%= f.hidden_field :anti_bot_question_id, :value => f.object.anti_bot_question.id %>
<%= f.submit %>
<% end %>
The form-backing object must conform to the ActiveModel interface, in addition to whatever interface you defined in your controller.
This is an example of an object that does everything necessary:
class Forms::Registration
# ActiveModel plumbing to make `form_for` work
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
def persisted?
false
end
# Custom application code
ATTRIBUTES = [:name, :email, :terms_of_service, :anti_bot_question_id, :anti_bot_answer, :ip_address]
attr_accessor *ATTRIBUTES
def initialize(attributes = {})
ATTRIBUTES.each do |attribute|
send("#{attribute}=", attributes[attribute])
end
end
validates :terms_of_service, :acceptance => true
validate do
if anti_bot_answer != anti_bot_question.answer
errors[:anti_bot_answer] << "Incorrect answer - are you a bot?"
end
end
validate do
[user, business, membership].each do |object|
unless object.valid?
object.errors.each do |key, values|
errors[key] = values
end
end
end
end
def anti_bot_question
if anti_bot_question_id
AntiBotQuestion.find_by_id(anti_bot_question_id)
else
AntiBotQuestion.random
end
end
def user
@user ||= User.new(:email => email)
end
def business
@business ||= Business.new(:name => name)
end
def membership
@membership ||= business.memberships.build(:user => user)
end
def save
return false unless valid?
if create_objects
UserMailer.deliver_user_registered(user)
IpLogger.log(user, ip_address)
else
false
end
end
private
def create_objects
ActiveRecord::Base.transaction do
user.save!
business.save!
membership.save!
end
rescue
false
end
end
In my opinion this type of form-backing object combines the best of all worlds – it keeps the controller skinny and the
view simple but it does’t pollute the domain at all.
Testing form-backing objects
Form-backing objects are just plain ruby objects, so testing them is very straightforward with unit tests. The only
thing that you probably want to do is make sure that your form-backing object is compatible with form_for
by using
the ActiveModel::Lint::Tests. With Test::Unit it’s as simple as:
# http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/
class RegistrationTest < ActiveModel::TestCase
include ActiveModel::Lint::Tests
def setup
@model = Forms::Registration.new
end
end
If you prefer RSpec, you can easily add a shared example that calls through to ActiveModel::Lint::Tests like so:
# Taken from https://blog.pivotal.io/users/mgehard/blog/articles/making-sure-you-implement-the-activemodel-interface-fully
#
# spec/
# ├── spec_helper.rb
# └── support
# └── shared_examples
# └── active_model.rb
shared_examples_for "ActiveModel" do
require 'test/unit/assertions'
require 'active_model/lint'
include Test::Unit::Assertions
include ActiveModel::Lint::Tests
before do
@model = subject
end
ActiveModel::Lint::Tests.public_instance_methods.map { |method| method.to_s }.grep(/^test/).each do |method|
example(method.gsub('_', ' ')) { send method }
end
end
Once that’s in place, your spec is pretty simple:
require 'spec_helper'
describe Forms::Registration do
# if your form backing object has required parameters
# you can add them by overriding `subject` like so:
#
# let(:subject) { Forms::Registration.new(:email => 'some@email.com') }
it_behaves_like "ActiveModel"
end
Staying Dry
If you have a lot of these form-backing objects, you can easily move all of that plumbing to a base class or module. Or
you can use an off-the-shelf gem like Josh Susser’s Informal or James Golick’s
Active Presenter
About the Author