Skip to content
Episode 2 5:11

Stop Fat Controllers: Rails Form Objects Explained

When one form spans two models, the controller gets fat fast. Build a plain Ruby form object that keeps the validations in one place and the controller thin.

Shownotes

After this episode the viewer can build a form that spans two models with a plain Ruby form object, keep validations in one place, and keep the controller thin.

Prerequisites: demo-shop on episode-002-before, Rails 8.1, episode 001 (model validations)

Not covered: tests, nested attributes, styling

Source: github.com/webmatze/demo-shop (branches episode-002-before / episode-002-after)

Chapters

  • 0:00 The problem
  • 0:51 The solution at a glance
  • 1:12 Setup
  • 1:28 Building it
  • 3:31 Refactoring
  • 4:14 Gotchas
  • 4:32 Things to remember
  • 4:51 Wrap-up
Transcript

The problem

Our shop has categories now. Every product on the index shows one. We open the form for a new product: Rails Hoodie, thirty nine, and a category called a b, two letters. Create. It saves. But look at the index: the hoodie has no category. The name was too short, the category failed, and nobody told us. Here is why. The controller builds the category by hand, with find by or create. Create returns an unsaved record when validation fails, the product just carries on without it, and the form has no idea. Two models, one form, and the rules live in three places. That is what we fix.

The solution at a glance

The tool is Active Model. The Rails guide shows how a plain Ruby class gets validations, errors and everything form with needs, with two includes. No table, no Active Record. That is a form object: one class per form, all rules in one place.

Setup

No gem to install. We make a folder, app forms. Rails autoloads every folder under app, so a class in there is found without any configuration. We ask the autoloader, and app forms is on the list.

Building it

We create the form object. Product Form includes Active Model Model and Active Model Attributes. Four typed attributes: name, price as a decimal, description, and category name. Then the rules: name must be present, price greater than zero, and the category name at least three characters. Every rule in one class. Now the save method. If the form is not valid, we return false, just like Active Record. Otherwise we open a transaction, find or create the category, and create the product with it. Both writes succeed together, or neither happens. The controller shrinks. New builds a Product Form instead of a Product. Create hands the params to the form and calls save, the six lines of category handling are gone. On success we go back to the index. And the strong parameters get their own method, form params, which now allows category name. Three small changes in the view. Form with needs to know where to post, because a form object is never persisted: we keep the scope product and set the URL by hand. Then the submit button, which would otherwise be labelled after our class, Create Product form. And the category field becomes a normal form field, like the others. Same input as before: Rails Hoodie, thirty nine, category a b. This time the form refuses: category name is too short. We fix it to Apparel and submit again. Now the hoodie is on the index, with its category. The rule lives in the form, and the form shows it. The form object works without a browser too. We build one with an empty name, a negative price and a short category, call valid, and print the messages. Three errors, exactly what the view would render.

Refactoring

This works, but two of those rules already exist on the product model, and now they are written twice. We delete the name and price lines. Instead, we add one validation that asks the product itself: if it is not valid, we merge its errors into ours. Save now builds the product, sets its category, and calls save bang. And a small product method keeps the instance around. We try the worst input: a name that already exists, a price of zero, and a short category. Three errors, one list. Two come from the product model, one from the form, and the form object collected them all.

Gotchas

Two things to know. The parameter key of a form object comes from its class name, product form, which is why we kept the scope product in the view. And persisted is always false, so form with would always post to a URL that does not exist, unless we set it ourselves.

Things to remember

A few more things. Scope product keeps the params compatible. Set the URL, the object has no route. Use save bang inside the transaction, or you get half a record. And keep model rules in the model: errors merge pulls them in, so nothing is written twice.

Wrap-up

That is a form object: one class, one form, two models, and every rule in one place. The code is on GitHub, branches before and after. Next episode we look at what happens when the shop gets busy: background jobs with Solid Queue.