EasyCommand
A simple, standardized way to build and use Service Objects in Ruby.
Table of Contents
Requirements
It is currently used at Swile with Ruby 2.7 and Ruby 3 projects.
Installation
Add this line to your application's Gemfile:
gem 'easy_command'
And then execute:
$ bundle
Or install it yourself as:
$ gem install easy_command
Contributing
To ensure that our automatic release management system works perfectly, it is important to:
Please note that we are using auto release.
Publication
Automated
Gem publishing and releasing is now automated with google-release-please.
The exact configuration of the workflow can be found in .github/workflows/release.yml
Usage
Here's a basic example of a command that check if a collection is empty or not
class CollectionChecker
prepend EasyCommand
def call
@collection.empty? || errors.add(:collection, :failure, "Your collection is empty !.")
@collection.length
end
private
def initialize(collection)
@collection = collection
end
end
Then, in your controller:
class CollectionController < ApplicationController
def create
command = CollectionChecker.call(params)
if command.success?
render json: { count: command.result }
else
render_error(
message: "Payload is empty.",
details: command.errors,
)
end
end
private
def render_error(details:, message: "Bad request", code: "BAD_REQUEST", status: 400)
payload = {
error: {
code: code,
message: message,
details: details,
}
}
render status: status, json: payload
end
end
When errors, the controller will return the following json :
{
"error": {
"code": "BAD_REQUEST",
"message": "Payload is empty",
"details": {
"collection": [
{
"code": "failure",
"message": "Your collection is empty !."
}
]
}
}
}
Returned objects
The EasyCommands' return values make use of the Result monad.
An EasyCommand will always return an EasyCommand::Result
(either as an EasyCommand::Success
or as an EasyCommand::Failure
) which are easy to manipulate and to interface with. These objects both answer to #success?
, #failure?
, #result
and #errors
(with #result
being the return value of the #call
method by default).
This means that the mechanisms described below (Subcommand and Command chaining) are
easily extendable and can be made compatible with objects that make use of them.
Subcommand
It is also possible to call sub command and stop run if failed :
class CollectionChecker
prepend EasyCommand
def initialize(collection)
@collection = collection
end
def call
assert_subcommand FormatChecker, @collection
@collection.empty? || errors.add(:collection, :failure, "Your collection is empty !.")
@collection.length
end
end
class FormatChecker
prepend EasyCommand
def call
@collection.is_a?(Array) || errors.add(:collection, :failure, "Not an array")
@collection.class.name
end
def initialize(collection)
@collection = collection
end
end
command = CollectionChecker.call('foo')
command.success?
command.failure?
command.errors
command.result
You can get result from your sub command :
class CrossProduct
prepend EasyCommand
def call
product = assert_subcommand Multiply, @first, 100
product / @second
end
def initialize(first, second)
@first = first
@second = second
end
end
class Multiply
def call
@first * @second
end
end
Command chaining
Since EasyCommands are made to encapsulate a specific, unitary action it is frequent to need to chain them to represent a
logical flow. To do this, a then
method has been provided (also aliased as |
). This will feed the result of the
initial EasyCommand as the parameters of the following EasyCommand, and stop the execution is any error is encountered during
the flow.
This is compatible out-of-the-box with any object that answers to #call
and returns a EasyCommand::Result
(or similar
object).
class CreateUser
prepend EasyCommand
def call
puts "User #{@name} created!"
{
name: @name,
email: "#{@name.downcase}@swile.co"
}
end
def initialize(name)
@name = name
end
end
class Emailer
prepend EasyCommand
def call
send_email
@user
end
def send_email
puts "Sending email at #{@email}"
if $mail_service_down
errors.add(:email, :delivery_error, "Couldn't send email to #{@email}")
end
end
def initialize(user)
@user = user
@email = @user[:email]
end
end
class NotifyOtherServices
prepend EasyCommand
def call
puts "User created: #{@user}"
@user
end
def initialize(user)
@user = user
end
end
$mail_service_down = false
user_flow = EasyCommand::Params['Michel'] |
CreateUser |
Emailer |
NotifyOtherServices
# User Michel created !
# Sending email at michel@swile.co
# User created: { name: 'Michel', email: 'michel@swile.co' }
# => <EasyCommand::Success @result={ name: 'Michel', email: 'michel@swile.co' }>
$mail_service_down = true
user_flow = EasyCommand::Params['Michel'] |
CreateUser |
Emailer |
NotifyOtherServices
EasyCommand::Params
is provided as a convenience object to encapsulate the initial params to feed into the flow for
readability, but user_flow = CreateUser.call('Michel') | Emailer | NotifyOtherServices
would have been functionally
equivalent.
Flow success callbacks
Since it is also common to react differently according to the result of the flow, convenience callback definition
methods are provided:
user_flow.
on_success do |user|
puts "Process done without issues ! 🎉"
LaunchOnboardingProcess.call(user)
end.
on_failure do |errors|
puts "Encountered errors: #{errors}"
NotifyFailureToAdmin.call(errors)
end
Merge errors from ActiveRecord instance
class UserCreator
prepend EasyCommand
def call
@user.save!
rescue ActiveRecord::RecordInvalid
merge_errors_from_record(@user)
end
end
invalid_user = User.new
command = UserCreator.call(invalid_user)
command.success?
command.failure?
command.errors
Stopping execution of the command
To avoid the verbosity of numerous return
statements, you have three alternative ways to stop the execution of a
command:
abort
class FormatChecker
prepend EasyCommand
def call
abort :collection, :failure, "Not an array" unless @collection.is_a?(Array)
@collection.class.name
end
def initialize(collection)
@collection = collection
end
end
command = FormatChecker.call("not array")
command.success?
command.failure?
command.errors
It also accepts a result:
parameter to give the Failure object a value.
abort :collection, :failure, "Not an array", result: @collection
command = FormatChecker.call(my_custom_object)
command.result
assert
class UserDestroyer
prepend EasyCommand
def call
assert check_if_user_is_destroyable
@user.destroy!
end
def check_if_user_is_destroyable
errors.add :user, :active, "Can't destroy active users" if @user.projects.active.any?
errors.add :user, :sole_admin, "Can't destroy last admin" if @user.admin? && User.admin.count == 1
end
end
invalid_user = User.admin.with_active_projects.first
command = UserDestroyer.call(invalid_user)
command.success?
command.failure?
command.errors
It also accepts a result:
parameter to give the Failure object a value.
assert check_if_user_is_destroyable, result: @user
command = UserDestroyer.call(invalid_user)
command.result
ExitError
Raising an ExitError
anywhere during #call
's execution will stop the command, this is not recommended but can be
used to develop your own failure helpers. It can be initialized with a code
and message
optional parameters and a named parameter result:
to give the Failure object a value.
Callback
Sometimes, you need to deport action, after all command and sub commands are executed.
It is useful to send email or broadcast notification when all operation succeeded.
To make this possible, you can use #on_success
callback.
#on_success
This callback works through assert_sub
when using sub command system.
Note: the on_success
callback of a command will be executed as soon as the
subcommand is done if it the command iscall
ed directly instead of through assert_sub
Examples are better than many words :wink:.
class Updater
def call; end
def on_success
puts "#{self.class.name}##{__method__}"
end
end
class CarUpdater < Updater
prepend EasyCommand
end
class BikeUpdater < Updater
prepend EasyCommand
end
class SkateUpdater < Updater
prepend EasyCommand
def call
abort :skate, :broken
end
end
class SuccessfulVehicleUpdater < Updater
prepend EasyCommand
def call
assert_sub CarUpdater
assert_sub BikeUpdater
end
end
class FailedVehicleUpdater < Updater
prepend EasyCommand
def call
assert_sub BikeUpdater
assert_sub SkateUpdater
end
end
SuccessfulVehicleUpdater.call
FailedVehicleUpdater.call
Error message
The third parameter is the message.
errors.add(:item, :invalid, 'It is invalid !')
A symbol can be used and the sentence will be generated with I18n (if it is loaded) :
errors.add(:item, :invalid, :invalid_item)
Scope can be used with symbol :
errors.add(:item, :invalid, :'errors.invalid_item')
errors.add(:item, :invalid, :invalid_item, scope: :errors)
Error message is optional when adding error :
errors.add(:item, :invalid)
is equivalent to
errors.add(:item, :invalid, :invalid)
Default scope
Inside an EasyCommand class, you can specify a base I18n scope by calling the class method #i18n_scope=
, it will be the
default scope used to localize error messages during errors.add
. Default value is errors.messages
.
Example
en:
errors:
messages:
date:
invalid: "Invalid date (yyyy-mm-dd)"
invalid: "Invalid value"
activerecord:
messages:
invalid: "Invalid record"
class CommandWithDefaultScope
prepend EasyCommand
def call
errors.add(:generic_attribute, :invalid)
errors.add(:date_attribute, :invalid, 'date.invalid')
end
end
CommandWithDefaultScope.call.errors == {
generic_attribute: [{ code: :invalid, message: "Invalid value" }],
date_attribute: [{ code: :invalid, message: "Invalid date (yyyy-mm-dd)" }],
}
class CommandWithCustomScope
prepend EasyCommand
self.i18n_scope = 'activerecord.messages'
def call
errors.add(:base, :invalid)
end
end
CommandWithCustomScope.call.errors == {
base: [{ code: :invalid, message: "Invalid record" }],
}
Test with Rspec
Make the spec file spec/commands/collection_checker_spec.rb
like:
describe CollectionChecker do
subject { described_class.call(collection) }
describe '.call' do
context 'when the context is successful' do
let(:collection) { [1] }
it 'succeeds' do
is_expected.to be_success
end
end
context 'when the context is not successful' do
let(:collection) { [] }
it 'fails' do
is_expected.to be_failure
end
end
end
end
Mock
To simplify your life, the gem come with mock helper.
You must include EasyCommand::SpecHelpers::MockCommandHelper
in your code.
Setup
To allow this, you must require the spec_helpers
file and include them into your specs files :
require 'easy_command/spec_helpers'
describe CollectionChecker do
include EasyCommand::SpecHelpers::MockCommandHelper
end
or directly in your spec_helpers
:
require 'easy_command/spec_helpers'
RSpec.configure do |config|
config.include EasyCommand::SpecHelpers::MockCommandHelper
end
Usage
You can mock a command, to be successful or to fail :
describe "#mock_command" do
subject { mock }
context "to fail" do
let(:mock) do
mock_command(CollectionChecker,
success: false,
result: nil,
errors: { collection: [ code: :empty, message: "Your collection is empty !" ] },
)
end
it { is_expected.to be_failure }
it { is_expected.to_not be_success }
it { expect(subject.errors).to eql({ collection: [ code: :empty, message: "Your collection is empty !" ] }) }
it { expect(subject.result).to be_nil }
end
context "to success" do
let(:mock) do
mock_command(CollectionChecker,
success: true,
result: 10,
errors: {},
)
end
it { is_expected.to_not be_failure }
it { is_expected.to be_success }
it { expect(subject.errors).to be_empty }
it { expect(subject.result).to eql 10 }
end
end
For an unsuccessful command, you can use a simpler mock :
let(:mock) do
mock_unsuccessful_command(CollectionChecker,
errors: { collection: { empty: "Your collection is empty !" } }
)
end
For a successful command, you can use a simpler mock :
let(:mock) do
mock_successful_command(CollectionChecker,
result: 10
)
end
Matchers
To simplify your life, the gem come with matchers.
You must include EasyCommand::SpecHelpers::CommandMatchers
in your code.
Setup
To allow this, you must require the spec_helpers
file and include them into your specs files :
require 'easy_command/spec_helpers'
describe CollectionChecker do
include EasyCommand::SpecHelpers::CommandMatchers
end
or directly in your spec_helpers
:
require 'easy_command/spec_helpers'
RSpec.configure do |config|
config.include EasyCommand::SpecHelpers::CommandMatchers
end
Rails project
Instead of above, you can include matchers only for specific classes, using inference
require 'easy_command/spec_helpers'
RSpec::Rails::DIRECTORY_MAPPINGS[:class] = %w[spec classes]
RSpec.configure do |config|
config.include EasyCommand::SpecHelpers::CommandMatchers, type: :class
end
Usage
subject { CollectionChecker.call({}) }
it { is_expected.to be_failure }
it { is_expected.to have_failed }
it { is_expected.to have_failed.with_error(:collection, :empty) }
it { is_expected.to have_failed.with_error(:collection, :empty, "Your collection is empty !") }
it { is_expected.to have_error(:collection, :empty) }
it { is_expected.to have_error(:collection, :empty, "Your collection is empty !") }
context "when called in a controller" do
before { get :index }
it { expect(CollectionChecker).to have_been_called_with_action_controller_parameters(payload) }
it { expect(CollectionChecker).to have_been_called_with_ac_parameters(payload) }
it { expect(CollectionChecker).to have_been_called_with_acp(payload) }
end
Using as Command
EasyCommand
used to be called Command
. While this was no issue for a private library, it could not stay named that
way as a public gem. For ease of use and to help smoother transitions, we provide another require entrypoint for the
library:
gem 'easy_command', require: 'easy_command/as_command'
Requiring easy_command/as_command
defines a Command
alias that should provide the same functionality as when the gem was named as such.
⚠️ This overwrites the toplevel Command
constant - be sure to use it safely.
Also: do remember that any other require
s should still be updated to easy_command
though.
For example require 'easy_command/spec_helpers'
.
Acknowledgements
This gem is a fork of the simple_command gem. Thanks for their initial work.
We also thank all the contributors at Swile that took part in the internal development of this gem:
- Jérémie Bonal
- Alexandre Lamandé
- Champier Cyril
- Dorian Coffinet
- Guillaume Charneau
- Matthew Nguyen
- Benoît Barbe
- Cédric Murer
- Marine Sourin
- Jean-Yves Rivallan
- Houssem Eddine Bousselmi
- Julien Bouyoud
- Didier Bernaudeau
- Charles Duporge
- Pierre-Julien D'alberto