Wanna play the Rails Design Patterns game? Illustration done by Nimble design team
When it comes to web application architecture, it’s a well-known fact that the framework Ruby on Rails comes with a strong set of conventions. The latter is even one of the key principle of its doctrine 💪. To this day and even in the next version in the works, the framework has stayed true to itself.
In a nutshell, what it means is that a lot has been decided by the framework. So developers can simply follow battle-tested methodologies on how to organise the code of an application without having to reinvent the wheel on every single application. While each applications is unique, if we were to place two Rails applications side-by-side, they would look almost identical. From the outside, both applications would be structured exactly the same.
But the other side of the coin is that following the Rails way all the way all the time can yield implementation inefficiencies, make testing harder, and even make applications harder to maintain. The initial developer happiness, as advocated by the framework, can be replaced by frustration and tears over time. But this dark situation can easily be avoided and/or resolved by expanding what Rails provides by default. Using design patterns like Form Objects in a sensible way allows not to go off the Rails way completely while still being able to structure the application differently.
What are Form Objects 🤔
To its core, a form object is an object used to manage complex business logic.
In this context, complex just means that it goes beyond the basic CRUD functions e.g. creating and storing a user in a database, updating a user, deleting a user… Complexity arises when creating the user also requires to create a new organisation to which the user will be attached to or — say it’s an eCommerce application — if a user buys a product then both an order and a payment transaction needs to be created at the same time.
Often this logic is placed in places where — ideally — it should not be such as controllers or in models. While the code works, it’s usually considered an anti-pattern.
Indeed, as the business logic is complex, it usually results in a large amount of code thus large objects. But “fat” controllers are hard to test and maintain because of the lack of isolation of the business logic. On top of the routing, authentication and authorisation, which are some of the core responsibilities of controllers, the complex business logic also needs to be tested resulting in complex tests. But that goes against the general aim of keeping tests simple. Heavily-engineered tests are slow to debug when issues arise, difficult to maintain when the need emerges to add new test cases, and sometimes can be even slower to execute as lots of fixtures are required.
Another downside is that the business logic is not re-usable elsewhere as it’s tied to one endpoint. For instance, if we take the example of a user buying a product resulting in the creation of an order and transaction, this action might be performed in more than one place in the application for different resources and probably with different conditions. But having the code inside a controller does not make it easy to re-use. One way to work around could be to move the logic inside concerns but it would still result in more complexity than using Form Objects.
Fundamentally, mixing in business logic in other objects violates one of the core Object Oriented Programming (OOP) principle: single responsibility (the S of the SOLID group of principles). What we discussed above are merely some of the consequences of not following this principle. Having many small objects with a limited set of responsibilities is far easier to implement, test, maintain and re-use.
When to use Form Objects
Now that the pitch for Form Objects is complete 😉 , it might still not be clear when to use it or not. Design patterns are just both a mean and a tool to build more robust and resilient applications. It’s up to the developers to identify the situation where they can be applied.
That’s the reason why we defined the following short checklist to help us identify when a Form Object should be used:
- More than one type of resource is affected
This condition is usually the first one to be met. By convention, Rails controllers and models are single-resource based. So when the business logic requires to initialise and manipulate multiple resources, then this is a good sign that Form Objects should be used.
In this context, “affected” means all of the following: creations of multiple resources, updates to multiple resources, destruction of multiple resources, or a combination of creations, updates and deletions at the same time.
- Lots of and/or just custom validation logic is required
Resources such as models usually have pre-defined built-in validation e.g. some attributes must not be nil or abide by specific conditions (greater than 0, a specific pattern or pattern).
When using Active Record, this refers to the validates
method calls:
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. Non-Active Record based models may also have validation when initialised:
# Using basic keyword arguments validation
module
class Charge
def initialize(payable:, token:, capture: false)
# Will raise argumentError if payable or token are nil
end
end
end
# 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
So validation is everywhere in all software applications. But how to handle validation based on multiple models? What if additional validation is required only in specific contexts? Is it acceptable to place it in models even though it’s not always required? How do we make sure we cover all edge cases and write tests for them efficiently? If these questions arise then it’s also a good measure that a form object is needed.
- The same business logic needs to be re-used in many places
Applications commonly offer users the ability to perform the same action in different contexts. On an eCommerce platform, users might be able to make a payment in the initial checkout but also for additional addenda to an order. In both cases, the creation and persistence of an order, and the processing of a financial transaction are required. Both processes are usually complex and prone to unknown errors.
So when it comes to critical flows, centralizing the business logic is paramount. No parties involved, whether the application owners or the end-users, would want the checkout process to behave differently in different areas of the application. Therefore, if the same action can be performed in different contexts, Form Objects are a great place to host the required business logic.
A Practical Guide
The following step-by-step guide will help you to get started on using Form Objects in your Rails applications in no time. In brief, the approach explained below relies on using Plain Old Ruby Objects (PORO) coupled with Rails powers. So it’s a cross between using bare metal Ruby but still taking advantage of the underlying framework of a Rails application.
File Organisation
First, let’s define where to place our Form Objects. To follow Rails conventions, these files must be placed in the directory app
.
But we will create a separate sub-directory to isolate all the Form Objects from the rest of the application.
├── app
│ ├── assets
│ ├── channels
│ ├── controllers
│ ├── decorators
│ ├── factories
│ ├── forms 👈 store form objects classes here
│ ├── helpers
│ ├── jobs
│ ├── mailers
│ ├── models
│ ├── presenters
│ ├── queries
│ ├── searchers
│ ├── services
│ └── views
Second, to stay on the Rails track 🛤, all Form Objects must be named with the suffix_form
:
├── forms
│ └── booking
│ └── checkout_payment_form.rb
The example above shows a structure for Form Objects inside an engine named
booking
. So the aforementioned structure is not restricted to the main app folder only. When using engines, the same technique applies inside the engines app directories.
Start with a PORO
Create a new class along with its required attributes:
class CheckoutPaymentForm
attr_reader :order
def initialize(booking:, card_id: nil, token: nil)
@booking = booking
@card_id = card_id
@token = token
end
# ...
end
At this point, nothing differentiates Form Objects from any standard Ruby class and Rails is not required. That’s an important note to take as when it will come to testing, it will be just as easy to test the Form Object as it would be testing a simple Ruby class 💪.
Supercharge the PORO
As we have seen previously, one of the key benefits of using Form Objects is to handle complex validation. For that, we will take advantage of Rails by including the module ActiveModel::Model. Here comes the Rails goodness!
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
This module allows to use the same validations methods as in Active Record models:
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
When manipulating many resources, custom validator classes can be created to decompose even more the business logic thus splitting the validation from the persistence logic:
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
As a result, the form object can be validated using valid?
and all errors are accessible via the attribute errors
:
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"]
Appending custom errors is also as easy as:
errors.add(:base, I18n.t('payment.errors.expired_booking'))
Add a public interface
Apart from the initialization method, Form Objects must have a very narrow interface with very few public methods. Usually, one or two at most.
In addition, it’s a good practice to follow ActiveModel naming conventions e.g. save
, update
or create
(along with their bang-version counterparts save!
, update!
or create!
).
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
Therefore when used in a context of a controller, it should feel very familiar to managing any ActiveRecord resource:
class BookingPackagesController < ApplicationController
# ...
def update
if @booking_packages_form.save(booking_package_params)
# handle happy case
else
# handle unhappy case
end
end
end
Wrap the core logic inside a transaction
While this is not limited to form objects, but because this kind of objects is dealing with multiple resources, there are many opportunities for things to go wrong. Therefore, to keep the integrity of the persisted data, it’s critical to rollback the creation or alteration of resources if the flow did not complete successfully.
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
In this example, errors can occur at any steps. For instance, if saving the package sets fails, it’s critical to rollback the deletion of the removed package sets performed in the previous step. Similarly to validation, we are using the powers of ActiveRecord transactions to make the application code simple and elegant.
Testing
Because form objects are POROs and isolated from the Rails framework (at least from the MVC functions), testing is simplified. There is no concern of routing, authentication or authorization to worry about but only input/output with limited side effects.
RSpec.describe BookingPackagesForm do
describe '#save' do
context 'valid params' do
it 'creates new package groups' do
currency = Fabricate(:sgd_currency)
category = Fabricate(:category)
package_set = Fabricate.attributes_for(:booking_package_set,
category: category,
currency: currency)
booking_package_form = described_class.new(
user: Fabricate(:user),
package_sets: [package_set]
)
expect { form_object.save }.to change(PackageSet, :count).by(1)
end
# ...
end
context 'invalid params' do
# ...
end
end
end
Unit testing form objects should feel very similar to testing an Active Record model. The narrow interface makes it also very easy to cover all possible edge cases so developers can feel confident in the implementation.
Conclusion
Standing on the shoulders of giants like Rails to build web application bring lots of positive benefits: speed, security, maintainability, and resiliency. But the framework is only the foundation — a strong and giant piece — on which the application is built on. Developers are free to append to it and leverage from it to improve the engineering of the application.
Form Objects are a great addition to the toolbox that Ruby on Rails provides by default. They allow to better isolate business logic from the framework, improve the overall implementation using decomposition and simplify testing. As a result, applications are more resilient to changes and easier to maintain. At the same time, the methodologies explained in this post suggest many ideas to keep the structure and naming consistent with the rest of the framework so developers do not feel they are going off the Rails.
This post is part of a series dedicated to building better Rails applications using design patterns. Stay tuned for future posts!