Command Objects - a.k.a Service Objects in Ruby on Rails - The Ergonomic Way
Overview
Ruby on Rails is a great framework, and we, at ErgoServ, really love it. It follows inherently strong principles like ‘Optimize for Programmer Happiness’ and our favorite ‘Convention Over Configuration’ amongst other significant foundational pillars.
Ruby on Rails has a lot of built-in tools that help build functional and feature-rich projects very quickly. Remember that famous tutorials blog in 15 minutes? (Yeah, 15 minutes! Installing and configuring WordPress may take more time ;) )
Rails provides an excellent and easily manageable structure for the codebase of unconventional projects based on the Model-View-Controller or the MVC pattern. The RoR framework works pretty well for small-size projects. But when a project grows, you need a proper place to store your domain or business logic.
Technically controllers were initially supposed to hold the business logic of the application. But modern web applications are getting complex and have many places to run a business process like background jobs, schedulers, rake tasks, etc. Plus, you want your code to be reusable.
Models are also not the best place to store business logic as they get bloated as the application grows. Flexibility is paramount as the app's logic may be needed to work with several models simultaneously or, in some cases, not work with models at all.
Concerns is another place where David Heinemeier Hansson, the creator of Ruby on Rails, recommends putting all the business logic. While Concerns are quite functional and can make things much shorter, we don't think they always make things clearer. So, following another item from the Rails doctrine "No one paradigm", we use concerns only for tiny things, but for more complex logic, we use other structures.
In the modern Ruby on Rails world, the approach of so-called Service Objects is quite popular nowadays. While Service Objects have many variations, at ErgoServ, we prefer to follow its more classic version that implements Command Pattern, calling this new application layer Command Objects.
Command Objects (a.k.a. Services Objects)
Introduction
Command Objects provide considerable benefits when we introduce them to encapsulate business logic, including the following:
- Skinny Controllers, Skinny Models
- Isolated testing of business processes
- Reusable code
- and many more
A Command should encapsulate a single user task such as registering for a new account or placing an order. You, of course, don’t need to put all code for this task within the Command. You can (and should) create other classes that your Command uses to perform its work.
Concept and Conventions
-
One public method (`perform`) - describes all steps of a process in a straightforward and
unambiguous way.
-
Simple and descriptive `perform` method - keep it as short, simple, and clear as possible, yet it
should describe what a command does.
-
Command steps - put all business logic steps into private methods with definite, self-explanatory,
and meaningful names.
-
Name Command Objects accordingly - the name should be clear and meaningful, and since it performs
some process or action, it should contain a verb that distinctly describes the action the command performs.
-
Directory `app/commands` - the most convenient palace for commands as another layer of the app.
-
Suffix `Command` for the class name - the command name should have the suffix `Command`. It helps
define the object type explicitly.
Solution
You may have your own implementation of Command Objects (or Service Objects) by starting with a simple PORO (Plain Old Ruby Objects). You can find many tutorials and examples for PORO, or use one of the plenty gems that provide their vision of this approach.
We have also, in the past, tried many of them but finally came up with our own implementation of Command Objects which we wrapped into a gem called AuxiliaryRails.
Command Objects by AuxiliaryRails
Benefits
-
Is clear and simple, yet advanced and flexible definition of arguments (using `param` and `option`).
-
Integrates with `ActiveModel::Validations` - which helps to validate arguments in a familiar manner.
-
Integrates with `ActiveModel::Errors` - which allows providing an extensible and friendly response in case
of errors.
-
Supports responding with multiple results if necessary, encapsulates into the instance of command, instead
of wired kinds of “Result Objects”.
-
An instance of the Command Object has status (failure or success).
-
Uses commonly used and universal names for primary method “perform”, utilized magic method “call”, and
provides some syntax-sugar.
Usage
# app/commands/application_command.rb
class ApplicationCommand < AuxiliaryRails::Application::Command
end
# app/commands/register_user_command.rb
class RegisterUserCommand < ApplicationCommand
# Define command arguments
# using `param` or `option` methods provided by dry-initializer
# https://dry-rb.org/gems/dry-initializer/3.0/
param :email
param :password
# Define the results of the command using `attr_reader`
# and set it as a regular instance var inside the command
attr_reader :user
# Regular Active Model Validations can be used to validate params
# https://api.rubyonrails.org/classes/ActiveModel/Validations.html
# Use #valid?, #invalid?, #validate! methods to engage validations
validates :password, length: { in: 8..32 }
# Define the only public method `#perform` where command's flow is defined
def perform
# Use `return failure!` and `return success!` inside `#perform` method
# to control exits from the command with appropriate status.
# Use `return failure!` to exit from the command with failure
return failure! if registration_disabled?
# Method `#transaction` is a shortcut for `ActiveRecord::Base.transaction`
transaction do
# Keep the `#perform` method short and clean, put all the steps, actions
# and business logic into meaningful and self-explanatory methods
@user = create_user
# Use `error!` method to interrupt the flow raising an error
error! unless user.persistent?
send_notifications
# ...
end
# Always end the `#perform` method with `success!`.
# It will set the proper command's execution status.
success!
end
private
def create_user
User.create(email: email, password: password)
end
def send_notifications
# ...
end
end
### usage ###
class RegistrationsController
def register
cmd = RegisterUserCommand.call(params[:email], params[:password])
if cmd.success?
redirect_to user_path(cmd.user) and return
else
@errors = cmd.errors
end
### OR ###
RegisterUserCommand.call(params[:email], params[:password])
.on(:success) do
redirect_to dashboard_path and return
end
.on(:failure) do |cmd|
@errors = cmd.errors
end
end
end
Conclusion
# app/commands/application_command.rb
class ApplicationCommand < AuxiliaryRails::Application::Command
end
# app/commands/register_user_command.rb
class RegisterUserCommand < ApplicationCommand
# Define command arguments
# using `param` or `option` methods provided by dry-initializer
# https://dry-rb.org/gems/dry-initializer/3.0/
param :email
param :password
# Define the results of the command using `attr_reader`
# and set it as a regular instance var inside the command
attr_reader :user
# Regular Active Model Validations can be used to validate params
# https://api.rubyonrails.org/classes/ActiveModel/Validations.html
# Use #valid?, #invalid?, #validate! methods to engage validations
validates :password, length: { in: 8..32 }
# Define the only public method `#perform` where command's flow is defined
def perform
# Use `return failure!` and `return success!` inside `#perform` method
# to control exits from the command with appropriate status.
# Use `return failure!` to exit from the command with failure
return failure! if registration_disabled?
# Method `#transaction` is a shortcut for `ActiveRecord::Base.transaction`
transaction do
# Keep the `#perform` method short and clean, put all the steps, actions
# and business logic into meaningful and self-explanatory methods
@user = create_user
# Use `error!` method to interrupt the flow raising an error
error! unless user.persistent?
send_notifications
# ...
end
# Always end the `#perform` method with `success!`.
# It will set the proper command's execution status.
success!
end
private
def create_user
User.create(email: email, password: password)
end
def send_notifications
# ...
end
end
### usage ###
class RegistrationsController
def register
cmd = RegisterUserCommand.call(params[:email], params[:password])
if cmd.success?
redirect_to user_path(cmd.user) and return
else
@errors = cmd.errors
end
### OR ###
RegisterUserCommand.call(params[:email], params[:password])
.on(:success) do
redirect_to dashboard_path and return
end
.on(:failure) do |cmd|
@errors = cmd.errors
end
end
end
The Command Objects pattern can immensely improve your application's overall design. Our approach and gem can provide you with a simple yet effective way to manage your commands.
Gem AuxiliaryRails can be found on GitHub
We would be happy to respond to your questions or requests there as well.