1.Previous
A form object is an object used to manage the complexity of data modification operations that goes beyond the basic CRUD functions. Usually, the basic CRUD tasks like adding users, editing users, or deleting users need very little code so we can put them in the controller. However, complexity arises when the function of creating a user requires creating an additional organization to add that user, or for an e-commerce application, if the user purchases a product, then an order and a payment transaction. must be created at the same time. Because the business logic is complicated, the number of lines of code as well as the objects created will be very large and will lead to a “fat controller” status. And a “fat controller” will be difficult to test as well as maintain, complex logic will lead you to write many test cases, but usually the controller also requires authentication and authorization, so when testing we still need Must create more data to pass authentication and authorization for the other test cases, this is redundant and can slow down the test.
Another downside is that the business logic won’t be reusable elsewhere because it’s already fixed at one endpoint. Although it is possible to extract logical code and include concerns modules, it is much more complicated than using the form object and basically putting the logic of one class into another violates a in the principles of OOP: single responsibility (S in SOLID). Putting all the logic into a separate class is easier to reuse, test, and maintain.
2. When to use form-object
More than one resource is affected
This is the most common case, by convention the controllers and models in rails are single resource-based. So, when business logic requires initializing or modifying, deleting many other resources, then we should gather these logic in one place for easy management, the best way is to use form object.
Combine validation for many resources
Usually we usually define the validation within the active-record models. eg:
1 2 3 4 5 6 7 8 | class User < ApplicationRecord validates :username, presence: true, uniqueness: true validates :locale, inclusion: { in: Language.all.map(&:code).map(&:to_s) } validates :time_zone, inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) } validates :terms_of_service, :informed_consent, acceptance: true end |
But not only that, there are some models that are not active record can also have validation when initialized, assuming there is a Search class, we need to validate presence: true for the search_params property, but not an active record. model, so if done in the normal way, it must be implemented by itself, and the assignment of error messages to the search_params property is also limited, can only be displayed outside the interface as a flash message.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # Custom validation module Flight class Search def initialize(search_params = {}) missing_required_params = missing_required_params_from(search_params).flatten if search_params[:search_id].blank? && missing_required_params.any? raise ArgumentError, I18n.t( 'api.errors.booking.missing_params', params: missing_required_params.join(', ') ) end end end end |
Validation is everywhere in our application, but how to handle validation for multiple models at the same time, if there are only a few validations that are only needed for some specific cases, put it inside the real model. It’s unnecessary and writing tests for these validations is sometimes difficult.
3. How to use the form object
Directory structure
The form object will be placed inside the app directory
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | ├── app │ ├── assets │ ├── channels │ ├── controllers │ ├── decorators │ ├── factories │ ├── forms ? │ ├── helpers │ ├── jobs │ ├── mailers │ ├── models │ ├── presenters │ ├── queries │ ├── searchers │ ├── services │ └── views |
And the class is named with the element _form
1 2 3 4 | ├── forms │ └── booking │ └── checkout_payment_form.rb |
Create a class form
Create a class with required attributes
1 2 3 4 5 6 7 8 9 10 11 | class CheckoutPaymentForm attr_reader :order def initialize(booking:, card_id: nil, token: nil) @booking = booking @card_id = card_id @token = token end # ... end |
Class above compared to a normal ruby class is usually no difference
Use ActiveModel :: Model.
Next, incluide module ActiveModel :: Model to be able to use methods found in the active model
1 2 3 4 5 6 7 8 9 10 11 12 13 | class CheckoutPaymentForm include ActiveModel::Model attr_accessor :order def initialize(booking:, card_id: nil, token: nil) @booking = booking @card_id = card_id @token = token end # ... end |
Thus, we can now use the same validations methods as the active record model
1 2 3 4 5 6 7 8 9 10 11 12 | class CheckoutPaymentForm include ActiveModel::Model attr_accessor :order validates :token, presence: true, if: -> { card_id.blank? } def initialize(booking:, card_id: nil, token: nil) # ... end end |
Or can validate with validation that you define yourself
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | class BookingPackagesForm include ActiveModel::Model attr_accessor :user, :package_sets, :packages validates_with BookingPackagesFormValidator def initialize(user:, package_sets: [], packages: []) @user = user @package_sets = package_sets @packages = packages end # ... end |
And now we can get the same error information as we do with the active record model, thanks to the valid method?
1 2 3 4 5 6 7 | pry(main)> booking = Booking.find(10) pry(main)> booking_package_form = CheckoutPaymentForm.new(booking: booking) pry(main)> booking_package_form.valid? => false pry(main)> booking_package_form.errors.full_messages => ["Token can't be blank"] |
Customizing CRUD methods
The CRUD methods of the form object should follow the convention of the active record object, like save, update or create (or save !, update! Or create! When an exception is required) to be used similarly to an active record object.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | class BookingPackagesForm include ActiveModel::Model attr_accessor :user, :package_sets, :packages validates_with BookingPackagesFormValidator def initialize(user:, package_sets: [], packages: []) #... end def save(params = {}) return false unless valid? # rest of persistence logic end private # ... end |
And on the controller, we just need to implement the same active record object as follows
1 2 3 4 5 6 7 8 9 10 11 12 | class BookingPackagesController < ApplicationController # ... def update if @booking_packages_form.save(booking_package_params) # handle happy case else # handle unhappy case end end end |
Use transaction
Since the form object is used to manage the influence of multiple resources, the use of transactions is essential to ensure data integrity when the data update of a resource fails.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | def update(params = {}) # ... assign params return false unless valid? ActiveRecord::Base.transaction do destroy_removed_package_sets save_package_sets save_packages raise ActiveRecord::Rollback unless errors.empty? end errors.empty? end |
Conclusion
Form Objects can help to mitigate a number of different problems when implementing Rails projects and therefore it can prove to be a useful tool for us, there is also a gem that can help. we can easily apply a form object to a project as reform
Source: https://nimblehq.co/blog/lets-play-design-patterns-form-objects