Rails Forms with Virtus and ActiveModel
I absolutely HATED doing forms in Rails, until we came across this method of doing them at work. Our goal was to make forms simple to set up and to have clear logic and separation of concerns. We were using Reform at first, and although it worked well for simple one-to-one form-to-model relationships, it quickly fell apart with more complex model relationships were involved. As well as this, if there were complex validations or different logic paths when saving the forms, things quickly fell apart. And there was no way to control the internal data structure of the form. Enter Virtus and ActiveModel.
Virtus is used to define attributes on a class and the types of those attributes, and supports data coercion. As well as this, Virtus enables mass assignment of attributes from hashes when initializing the class using Virtus.model
; perfect for forms. Virtus brings other powerful features to the table to, like default values. Check out the docs for more! (note that while Virtus says it is discontinued, the actual gem works fine. The author has created several other projects since virtus that he links to in the repo)
The inclusion of ActiveModel in your form classes gives you access to the validate
and validates
methods, among other functionality including custom validations, translation, and serialization. You might write a custom validation method, and within it add an error to the form via the errors.add(:base, message)
call when it meets a failure condition. Calling .valid?
on the form will trigger all validations on the form. Very powerful!
Persist Logic
One of the most compelling reasons to use POROs combined with Virtus is the control you have over form persistence logic. You can create a save
method like so in your class, that does whatever you need it to. Note that in this case we are calling the attributes
method which converts all of the form attributes into a hash:
class BookCreateForm
include ActiveModel::Model
include Virtus.model
attribute :title, String
attribute :isbn, String
attribute :author, String
attribute :genre, String
def save
return false unless valid?
Book.create(attributes)
end
end
This persistence logic can be as complex or as simple as you need. For example, we could make this book create form into an upsert form by including an id
and changing the persistence logic:
class BookUpsertForm
# ....
attribute :id, Integer
def save
return false unless valid?
return Book.update(id, attributes.except(:id)) if id.present?
Book.create(attributes)
end
end
You may want to abstract your save logic out into another service, enqueue a resque job after the form is saved, or any other number of possibilities. The point is that you can do whatever you like when saving your form’s data to the database. Another great idea is to abstract the inclusion of Virtus
and ActiveModel
into a base form class so you don’t have to do it all the time (and you can add other useful methods there too):
class BaseForm
include ActiveModel::Model
include Virtus.model
def save
return false unless valid?
# you would just override this method in any class
# inheriting from BaseForm
save_form
end
end
Validations
It’s extremely important to have server-side validations for your rails forms, and not to just rely on HTML5 validations. The hierarchy of data validation should be as follows:
- Client - HTML5 form validations,
required
attribute - Server - ActiveModel form validations and logic
- Database - NOT NULL, CHECK and UNIQUE constraints, etc.
ActiveModel’s form validations make it very easy to fulfil the second level. Say for the example above I want to ensure that the title and ISBN number is always present for the book. I would just add this:
validates :title, :isbn, :genre, :author, presence: true
And if I call my save
method without filling these values in, I would see an error when I check the errors
object:
book_form = BookUpsertForm.new({})
book_form.save
puts book_form.errors.full_messages
=> ["Title is required", "ISBN is required"]
It is easy show these errors to the user. Usually the form would be stored in a facade or similar, which would be stored in a controller instance variable. Then in the view you would just do something like:
<% if @facade.form.errors.full_messages.length.positive? %>
<ul>
<% @facade.form.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
If I wanted to do something fancier I could create a custom validation method. Say if I needed to check the format of the ISBN, I could do the following:
validate :isbn_ok?
def isbn_ok?
return if ISBNFormatCheck.format_ok?(isbn)
errors.add(:base, 'ISBN is in an invalid format')
end
Adding errors from the custom validators to the errors
block is extremely powerful. If you want, you can replace :base
with an attribute name and rails will humanize it for you, e.g. errors.add(:isbn, 'is an invalid format')
. This can then also be easily tied into I18n
.
Custom Validators
Sometimes you may want to use the same validation in many different forms. You can create custom validators using ActiveModel::Validator
. Here is one for validating the ISBN formats:
class ISBNFormatValidator < ActiveModel::Validator
def validate(record)
return if ISBNFormatCheck.format_ok?(isbn)
errors.add(:base, 'ISBN is in an invalid format')
end
end
Then, to include this in a form you use the validates_with
method:
class BookUpsertForm
validates_with ISBNFormatValidator
# form logic
end
Putting it All Together
Taking our example of the BookUpsertForm
above we can put everything together and make a form with custom validations that can create or update a book, then enqueue a background long running job. You can see how everything works together in the Gist below:
Side Note - Data Transfer Objects
Another neat side effect of using Virtus that we have found is the ability to use it to create Data Transfer Objects (DTOs) or payload classes for other services. For example, if you have a service that fetches the data used to generate a report. You could perform the logic of fetching the data and doing some operations on it to mould it into a format that your report presenter can use. We often just define these DTO classes inside the service class. A Virtus DTO is perfect for that:
class BookSalesReportDataService
class BookSalesReportDataItem
include Virtus.model
attribute :title, String
attribute :isbn, String
attribute :author, String
attribute :genre, String
attribute :total_sales, Float
end
def fetch
retrieve_data.map do |row|
BookSalesReportDataItem.new(
title: row.title,
isbn: row.isbn,
author: row.author,
genre: row.genre,
total_sales: (row.qty_sold * row.price)
)
end
end
private
def retrieve_data
# gets data from database
end
end
We have been extremely happy with the power of Virtus in our Rails monolith. Give it a try!