Form Object Pattern
There are a number of design patterns that will help improve your Rails app. One such pattern is the Form Object. At its core a Form Object is just a Plain Old Ruby Object (PORO), but we can design the object in such a way that it will help us to maintain a consistent API in our code. Let's dive in and see how we can leverage a Form Object to improve our Rails App.
The messaging feature
Let's say our boss asks us to add a messaging feature to our application. They tell us the message shouldn't be a database table, so no model. It just needs to be a form where a user can select another user, add a subject and body, and then send out an email. Sounds easy enough, let's get that added. (for simplicity let's say our app already has a User
model with first_name
, last_name
, and email
attributes, and a DummyMailer
with a message
method that takes to
, subject
, and body
as keyword arguments)
First let's add the messages controller.
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def show; end
def create
DummyMailer.message(message_params).deliver_later
redirect_to messages_path, notice: "Your message was sent!"
end
private
def message_params
params.permit(:to, :subject, :body)
end
end
Then we can add our view/form
# app/views/messages/show.html.erb
<h1>New Message</h1>
<h3><%= notice %></h3>
<%= form_with url: messages_path do |form| %>
<div class="field">
<%= form.label :to %>
<%= form.select :to, User.all.pluck(:email), include_blank: "Select a recipient" %>
</div>
<div class="field">
<%= form.label :subject %>
<%= form.text_field :subject %>
</div>
<div class="field">
<%= form.label :body %>
<%= form.text_area :body %>
</div>
<div class="actions">
<%= form.submit "Create Message" %>
</div>
<% end %>
After we add resource :messages, only: %i[show create]
to our routes file we can start up our server and checkout our new form (forgive the lack of styling).
If we select a user, add a subject, and a body
We'll see things work out pretty well.
Close but no cigar.
That is until we show it to our boss. First thing she does is to try submitting the form with no details and frowns when she sees "Your message was sent!" on the screen. She asks us to show an error message if any of the information is missing. Ok, slightly more complicated but still doable. Let's update our controller.
Something like:
class MessagesController < ApplicationController
def show; end
def create
if message_params[:to].present? && message_params[:subject].present? && message_params[:body].present?
DummyMailer.message(message_params).deliver_later
redirect_to messages_path, notice: "Your message was sent!"
else
flash.now[:notice] = "Please include all fields"
render :show
end
end
private
def message_params
params.permit(:to, :subject, :body)
end
end
More problems...
This works but our boss spots another problem almost immediately. If we include some of the fields those fields are cleared out when we re-render the form. Our boss also wants us to limit the length of the subject
, highlight the fields with errors, and list the issues when submitting an invalid form. Yikes! This is a lot more logic than we should have in a controller, but without a model where should we put it? Well, in a model of sorts. See, a model doesn't have to inherit from ActiveRecord or be backed by the database. We can add a PORO, include ActiveModel::Model
, and be off to the races.
Form Object to the rescue!
# app/models/message.rb
class Message
include ActiveModel::Model
attr_accessor :to, :subject, :body
validates :to, :subject, :body, presence: true
validates :subject, length: { maximum: 255 }
end
You'll notice I put this in the models
directory. This is personal preference. It could have just as easily have been placed in a forms
directory in app
or even models
. There's absolutely nothing wrong with having "models" (which this arguably is) in your models
directory. They don't all have to be database-backed.
Armed with our new form object, let's update our form.
<h1>New Message</h1>
<h3><%= notice %></h3>
<%= form_with model: @message, url: messages_path do |form| %>
<div class="field">
<%= form.label :to %>
<%= form.select :to, User.all.pluck(:email), include_blank: "Select a recipient" %>
</div>
<div class="field">
<%= form.label :subject %>
<%= form.text_field :subject %>
</div>
<div class="field">
<%= form.label :body %>
<%= form.text_area :body %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
Notice that we added model: @message
to our form_with
call. This is what will help give us all the form behavior that we've come to expect with Rails. It also allows us to remove the specific "Create Message" label from the submit action since passing in a model will populate this for us. To make this actually work though we'll need to make some updates to our controller.
class MessagesController < ApplicationController
def show
@message = Message.new
end
def create
@message = Message.new(message_params)
if @message.valid?
DummyMailer.message(message_params).deliver_later
redirect_to messages_path, notice: "Your message was sent!"
else
flash.now[:notice] = @message.errors.full_messages.join(", ")
render :show
end
end
private
def message_params
params.require(:message).permit(:to, :subject, :body)
end
end
Nice. This is looking a lot more like a controller we'd expect to see in a Rails app. We're leveraging Strong Params correctly and letting our "model" take care of all the validation logic. With no other changes our form is now working as our boss requested:
Bonus round: Making a really expected controller.
Theres one small part of our controller that may stick out. The call of valid?
vs save
is different than what we'd expect to see in a "normal" Rails controller. I'm also not a fan of having the mailer call in the controller (but that may just be me).
class MessagesController < ApplicationController
def show
@message = Message.new
end
def create
@message = Message.new(message_params)
if @message.save
redirect_to messages_path, notice: "Your message was sent!"
else
flash.now[:notice] = @message.errors.full_messages.join(", ")
render :show
end
end
private
def message_params
params.require(:message).permit(:to, :subject, :body)
end
end
If we try this now you'll get a "NoMethodError" error because, though ActiveModel::Model
give us a lot of model logic, it does not give us a save
message. No worries though, the simple act of adding a save
method to our "model" will fix that, with the added bonus of giving us a good place for our mailer call (if you're into that kinda stuff).
class Message
include ActiveModel::Model
attr_accessor :to, :subject, :body
validates :to, :subject, :body, presence: true
validates :subject, length: { maximum: 255 }
def save
return false unless valid?
DummyMailer.message(to: to, subject: subject, body: body).deliver_later
end
end
If we try it again now every thing will work. 🎉
Though this was a fairly simple example I hope it illustrated the awesomeness of a Form Object. Allowing our controllers to look, function, and behave in an expected way is a huge win and paramount to keeping our application stable and easily upgrade/expandable. We also now have a much more familiar form design. Validation is performed using battle-tested and well-known methods, not by recreating that logic. Any additional changes to our "Message" feature is now incredibly easy and will feel no different than adding features to a model backed by the database.
Top comments (1)
Great article! Thanks for writing it all up. Note, though, that if you don't include a status in your else branch, the flash notice won't appear. I.e.,