A case against form objects

Based on Ruby on Rails examples, the core idea should apply to other languages and frameworks.

Jun 24th, 2020
By Fernando Martínez

Note: The current article is based on Ruby on Rails examples, but the core idea should apply to other languages and frameworks.

The first

Let’s start trying to define some concepts:

  • What’s a form object?
  • What’s their architectural purpose?

According to the articles linked at the bottom and our own experience working on several Rails codebases using form objects, there’s a variety of definitions and objectives. To enumerate a few:

What are they? what do they do?

  • A plain old ruby object that runs validations on data input
  • Virtual models that represent the aggregation of other model objects
  • Replacement for strong parameters (input params whitelist)
  • A way to refactor model life-cycle callbacks
  • A form object is an object designed specifically to be passed to form_for
  • This type of object is used to make the life of a controller a bit easier and take params processing responsibility out of it. Creating form objects means making proper type coercions and introducing simple validations
  • Encapsulating the aggregation of multiple ActiveRecord models might be updated by a single form submission
  • The Form Object quacks like an ActiveRecord, so the controller remains familiar
  • An object that lets you manage complex behavior easily

Why use them? What’s their main purpose?

  • Extracting business logic from controller and/or model layers
  • Providing view-backing helper methods (eg: providing options for complex selects)
  • Easily implementing Rails conventions on complex forms that do not map directly into a single AR model
  • Moving responsibilities to the form object reduces decoupling between the controller, model (including multiple models), and the view template.
  • As you can see a form object is a broad, non-uniform idea. There’s no consensus on what they are, when to use them, or what their responsibilities are.

As you can see a form object is a broad, non-uniform idea. There’s no consensus on what they are, when to use them, or what their responsibilities are.

This is the first problem: the Form Object concept is communicatively poor. When I need to work on a codebase that uses form objects, I don’t know which of all these ideas I will find behind them, or, more frequently, what mix of them.

As a summary (and keep this idea in mind as we will review it later) we can say that the general idea is that form objects are a good way to refactor code complexity and condensation of responsibilities either in some part of the system (usually the model and/or controller layers).

An example

Most of the Form Objects that I came across are used like this. And according to the articles linked below, it might be considered the most popular implementation:

class SomethingController < ApplicationController
  def create
    @form = MyForm.new(action_params)
    if @form.valid?
      @form.save!
      redirect_to "somewhere"
    else
      render :new
    end
  end
  def new
    @form = MyForm.new
  end
end

This looks like almost every “rails-way” controller (if valid? save & redirect; else: render). Exactly what a rails dev expects to see.

Short & sweet, straight to the point.

This must be OK, right?

The second

Let’s analyze what the form object is in charge of in these few lines, what knowledge it manages only by taking a look at its public API

  • new(params) => knows the request’s params. This makes it a good place to house input data transformations to adapt it to the model’s needs.
  • valid? => The form runs validations. This means it knows the parameters, its types, and the validation rules, which also means knowledge of business logic and domain rules.
  • save! => Object persistence. That means that it also knows how to create them from parameters and how the persistence layer works.
  • @form => Variable passed to the view layer. Makes it a potential place to hold view-backing logic (eg: form_forchoices for selects and check/radios, conditional rendering, form_for model, etc)

Deep inside, in a shady corner, our single responsibility principle intuition, lonely cries Yeah… is it perhaps too much? An object that knows too much does too much. This exerts a gravitational pull of code on the whole system:

  • I need a few more view-backing helpers, where should I put them? => in the form object.
  • new validations? => form object.
  • Need to map params to some model attributes/objects => yeap, the form object.
  • Even worse if you give it as one of the posts says the opportunity to send mail and/or user notifications.

And the worst part is, and here comes the second problem: it does make sense to put those in there.

Changes like these will probably pass code review as there’s no good argument against them.

Our form object is now the new fat controller/model. It holds and attracts the same problems that we were trying to solve when we first introduced them.

The third

There’s yet another problem and perhaps the more concerning one: you probably already have objects that are in charge of these responsibilities.

  • persistence: it is a model’s responsibility. Delegate to those classes instead of imitating them. Making the controller look like a “blog-in-10-minutes” controller is not enough as an argument.
  • business logic: to hold business logic use service objects. They are a natural place for complex business logic.
  • input validation: use validation objects. There are multiple libraries you can use to implement them (including ActiveRecord::Model, Scrivener, and dry-schema)
  • view backing/helper methods: use a view model/presenter object.

If you are shooting for a form object, you probably have a medium-sized app and already have some of these in your codebase. Why add yet another architectural concept that has a rather vague definition and overlaps over other components’ responsibilities?

We need to embrace the idea that if the use case is complex, the code will be complex. Brushing the complexity under the carpet doesn’t make it disappear.

A proposal

Using the ideas above, the same controller could look like:

def new
  @form = SomeFormViewModel.new(defaut_values)
end

def create
  validation = SomeValidation.new(action_params)
  if validation.valid?
    result = SomeService.new(validation.params).call
    if result.success?
      redirect_to "somewhere", info: "everything went ok."
    else
      redirect_to "somewhere", error: "uh oh… something gone oops: #  {result.errors}"
    end
  else
    @form = SomeFormViewModel.new
    render :new
  end
end

PROS

  • It is not 100% rails-way but the structure is pretty similar and easy to follow
  • The controller is composing and delegating logic to single-purposed objects.
  • The limits and purpose of each piece are well-defined.
  • Each piece has a single focus, so they are easier and faster to test (and to maintain those tests)
  • The code structure and responsibilities distribution are self-replicating. Is easier to build a culture on top of these and it should also be harder for a change that mixes up responsibilities to pass code review.

Conclusion

This is an idea that arose out of an analysis of some of the codebases we inherited or built ourselves in the past and we now need to maintain. This is most likely not the only solution, and it is not a “default recipe” that should be followed blindly.

Form objects are not bad per se. Their intention is good and they are usually better than a bloated model or controller. Perhaps the next time you see one or think of adding a new one, some of these ideas come to your mind.

If you find anything useful or that you think would be good for your codebases, let us know!

Linked articles