Dwi Wahyudi
Senior Software Engineer (Ruby, Golang, Java)
In Ruby on Rails, form objects are particularly useful for dealing with creating/updating (and deleting) data with complex validations going around.
Overview
So instead of service, we create a new form class.
- Do create and update of data there.
- So the controllers stay slim.
- And we can reuse the form between API/html form as well.
- This form class can be easily tested and optimized.
- For even more complex validations, we can use custom validations class.
Form class is for processing data creation and update for data that is directly inputted by users, because there would be validations (of inputs) to be done and error messages (if any) that should be displayed to users.
As the name suggests, form should only process data input from customers, other than that like: Background jobs, non customer journey activities and tasks should not be put into forms.
And anything that has validation(s) and error message(s) qualify to be put into a form class.
The general flow is:
- Initialize new form object of form class, pass it with params to be processed. If there is no params to be processed, then it is not a form.
- Inside the form class, if there are validation(s), check all of them with valid? or invalid? method. For each validation we must add error message if validation fail. There can be multiple failing validations, so error messages can be more than one.
- If any validation fail, we must return false from the form class.
- Otherwise, perform data create/update/delete, and then return true or any data we want. Multiple save or update operations should be inside transaction block.
- Controller action than act accordingly to return value of the form object.
The Form Class Example
# app/services/forms/account_creation_form.rb
module Forms
class AccountCreationForm
include ActiveModel::Model
# We list validations needed. just like in the models,
#
# Because we already included ActiveModel::Model,
# we already have these methods: validate, validates, valid?, invalid? and errors.
validate :account_data
validate :something
validate :another_thing
validate :one_more_thing
# params here is provided by the form caller.
def initialize(params)
@account = params
end
def create
# valid? and invalid? methods will trigger all validations above.
# If any validation is invalid, we immediately return false. Just like ActiveRecord.
# There should be errors messages populated, just like ActiveRecord.
return false if invalid?
@account.save!
# If validation pass, we continue.
# ... business logic for creating account based on passed params argument on initialize method.
# Assume that @account is from Account.new instantiated from html new page.
true # return true to tell caller that creation successful, no errors message should be populated.
end
private
def account_data
if @account.invalid?
# Merge with errors from account instance.
# for example:
# @account.errors.each { |account_field, error_message| errors.add(account_field, error_message) }
end
end
# Implement another validation code.
def something
errors.add(:base, i18n.t('something.something.wrong')) if something_invalid
end
# ... other validations and private methods if needed.
end
end
In the Controller Action
We then call this with inside controller create action:
account_creation_form = Forms::AccountCreationForm.new(account_params)
if account_creation_form.create
# When successful
# render json: { ..... },
# status: :created
#
# or redirect_success_path(is_create: true, params: params)
# format.html
# format.json
# or whatever...
else
# We have an access to account_creation_form.errors
end
The best practice is to have one form per controller action. But if the form has multiple pages and each need different stage of validation, It is different a case with different treatment.
References
https://medium.com/@jaryl/disciplined-rails-form-object-techniques-patterns-part-1-23cfffcaf429
https://thoughtbot.com/blog/activemodel-form-objects
https://www.codementor.io/@victor_hazbun/complex-form-objects-in-rails-qval6b8kt