DEV Community

loading...
Cover image for Rails Design Patterns: Form Object

Rails Design Patterns: Form Object

Drew Bragg
Full Stack Dev && Single Dad && Board Game Geek && Hockey Player
・5 min read

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
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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

Our New Form

If we select a user, add a subject, and a body

user, subject, and body

We'll see things work out pretty well.

It works!

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
Enter fullscreen mode Exit fullscreen mode

Well, that's something

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
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Looking good!

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)