A simple, standardized way to build and use Service Objects in Ruby.
- Requirements
- Installation
- Contributing
- Publication
- Usage
- Test with Rspec
- Using as Command
- Acknowledgements
- At least Ruby 2.0+
It is currently used at Swile with Ruby 2.7 and Ruby 3 projects.
Add this line to your application's Gemfile:
gem 'easy_command'
And then execute:
$ bundle
Or install it yourself as:
$ gem install easy_command
To ensure that our automatic release management system works perfectly, it is important to:
- strictly use conventional commits naming: https://github.com/googleapis/release-please#how-should-i-write-my-commits
- verify that all PRs name are compliant with conventional commits naming before squash-merging it into master
Please note that we are using auto release.
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
Here's a basic example of a command that check if a collection is empty or not
# define a command class
class CollectionChecker
# put EasyCommand before the class' ancestors chain
prepend EasyCommand
# mandatory: define a #call method. its return value will be available
# through #result
def call
@collection.empty? || errors.add(:collection, :failure, "Your collection is empty !.")
@collection.length
end
private
# optional, initialize the command with some arguments
# optional, initialize can be public or private, private is better ;-)
def initialize(collection)
@collection = collection
end
end
Then, in your controller:
class CollectionController < ApplicationController
def create
# initialize and execute the command
command = CollectionChecker.call(params)
# check command outcome
if command.success?
# command#result will contain the number of items, if any
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 !."
}
]
}
}
}
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.
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? # => false
command.failure? # => true
command.errors # => { collection: [ { code: :failure, message: "Not an array" } ] }
command.result # => nil
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
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
# User Michel created !
# Sending email at michel@swile.co
# => <EasyCommand::Error @errors={ email: [{code: :delivery_error, message: "Couldn't send email to michel@swile.co"}] }>
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.
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
class UserCreator
prepend EasyCommand
def call
@user.save!
rescue ActiveRecord::RecordInvalid
merge_errors_from(@user)
end
end
invalid_user = User.new
command = UserCreator.call(invalid_user)
command.success? # => false
command.failure? # => true
command.errors # => { name: [ { code: :required, message: "must exist" } ] }
To avoid the verbosity of numerous return
statements, you have three alternative ways to stop the execution of a
command:
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? # => false
command.failure? # => true
command.errors # => { collection: [ { code: :failure, message: "Not an array" } ] }
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 # => my_custom_object
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? # => false
command.failure? # => true
command.errors # => { user: [
# { code: :active, message: "Can't destroy active users" },
# { code: :sole_admin, message: "Can't destroy last admin" }
# ] }
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 # => invalid_user
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.
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.
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 😉.
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
# CarUpdater#on_success
# BikeUpdater#on_success
# SuccessfulVehicleUpdater#on_success
FailedVehicleUpdater.call
# "nothing"
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')
# equivalent to
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)
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
.
# config/locales/en.yml
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) # Identical to errors.add(:generic_attribute, :invalid, :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) # Identical to errors.add(:base_attribute, :invalid, :invalid)
end
end
CommandWithCustomScope.call.errors == {
base: [{ code: :invalid, message: "Invalid record" }],
}
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
To simplify your life, the gem come with mock helper.
You must include EasyCommand::SpecHelpers::MockCommandHelper
in your code.
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
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
To simplify your life, the gem come with matchers.
You must include EasyCommand::SpecHelpers::CommandMatchers
in your code.
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
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
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 }
# the 3 matchers bellow are aliases
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
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.
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'
.
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