GLCommand
GLCommand
is a way to encapsulate business logic and standardize error handling.
Calling a command returns a GLCommand::Context
which has these properties:
- The arguments that were passed in to the command
.call
method (set viaallows
requires
) - The returns from the
.call
method -
error
(which contains the error, if an error was raised) -
full_error_message
- which renders a string from the error, or can be set explicitly (used to show a legible error to the user). -
success
-true
if the command executed without an error (false if there is anerror
)
Table of contents
- Installation
- Using GLCommand
- Success/Failure
- Displaying errors (use
full_error_message
) - stop_and_fail!
- Validations
- Best practices for error handling
- GLExceptionNotifier
- Chainable
- Testing
GLCommand
s- Stubbing with
build_context
- Rspec matchers
- Stubbing with
- Publishing the gem to Rubygems
Installation
Add the following line to your Gemfile:
gem 'gl_command'
Download and install the gem:
bundle install
Using GLCommand
Invoke a command with .call
or .call!
.call
will return the GLCommand::Context
, with error
assigned (if there is an error)
.call!
will raise the error (if there is an error), otherwise it will return the GLCommand::Context
General rules for deciding whether to use .call!
- In controllers use
.call
(make sure you check that it succeeds and render errors appropriately) - In background jobs and rake tasks, use
.call!
- Use
.call!
when calling a command within another command. If the inner command fails, it will assign errors to the outer command.
class SomeCommand < GLCommand::Callable
returns :data
def call
# If OtherCommand fails, SomeCommand will also fail - with the error from OtherCommand
result = OtherCommand.call!
context.data = result.data
end
end
Success/Failure
GLCommand context's are successful by default (successful?
aliases success?
).
They are a failure (success? == false
) if the context has an error.
Here are the ways of adding an error to a command:
- Raising an exception
- Immediately stops execution
- Calling
stop_and_fail!
- Immediately stops execution
- Failing a validation
- Validation errors are checked before the
call
method is invoked (ifvalid? == false
the command will return). - If validations are added during the
call
method, the command fails after call
- Validation errors are checked before the
- Directly assigning
context.error
orcontext.full_error_message
to a non-nil value- Checked after
call
method finishes
- Checked after
If you invoke a command with .call!
all of the above will raise an exception
If a command fails, it calls its rollback
method before returning (even when invoked with .call!
)
Displaying errors (use full_error_message
)
In addition to encapsulating business logic, GLCommand also standardizes error handling.
This means that rather than having to rescue errors in controllers, just render the command's full_error_message
result = GLCommand::Callable.call(params)
if result.success?
redirect_to new_controller_action
else
flash[:error] = result.full_error_message
redirect_back
end
In general, use context.full_error_message
to render errors (rather than context.error.message
which might not have the full error message text).
stop_and_fail!
Use stop_and_fail!
to immediately stop a command and raise an error (GLCommand::StopAndFail
by default)
The argument to stop_and_fail!
is assign to the context.error
- If you pass an exception, that exception will be raised and/or sent to Sentry
- Otherwise, the error will be a
GLCommand::StopAndFail
and what was passed will be assigned tofull_error_message
# Passing a string:
stop_and_fail!('An error message')
context.error # => GLCommand::StopAndFail
context.full_error_message # => 'An error message'
# Passing an exception:
stop_and_fail!(ActiveRecord::RecordNotFound)
context.error # => ActiveRecord::RecordNotFound
context.full_error_message # => ActiveRecord::RecordNotFound
# Passing an exception with an error message
stop_and_fail!(ActiveRecord::RecordNotFound.new('Some error message'))
context.error # => ActiveRecord::RecordNotFound
context.full_error_message # => 'Some error message'
You can also include no_notify: true
, which prevents GLExceptionNotifier
from being called.
# Sentry is notified when #call fails by default:
stop_and_fail!('An error message') # GLExceptionNotifier is called
# If you don't want to alert Sentry when the command fails in a specific way:
stop_and_fail!('An error message', no_notify: true) # GLExceptionNotifier is *not* called
Validations
You can add validations to GLCommand::Callable
and GLCommand::Chainable
. They include ActiveModel::Validations
, so you can use Rails active record validations.
If the validations fail, the command returns success: false
without executing and if validations fail, GLExceptionNotifier
is not called.
class ExampleCommand < GLCommand::Callable
validates :name, presence: true
validate :name_must_start_with_cool
def name_must_start_with_cool
return true unless name.start_with?('cool')
errors.add(:name, "Doesn't start with 'cool'")
end
end
Best practices for error handling
Only add validation errors in validations
i.e. don't use errors.add
in the call
method. Use stop_and_fail!
instead.
Prefer raising the original error
For example, if you want to raise a custom error message, don't rescue and then stop_and_fail!('Some special error message')
. Do this instead:
rescue StandardError => e
context.full_error_message = "Some special error message"
raise e
end
This will preserve the original error and stack trace, which makes it easier to debug and track down issues.
GLExceptionNotifier
GLExceptionNotifier is Give Lively's wrapper for notify our error monitoring service (currently Sentry)
When a command fails GLExceptionNotifier
is called, unless:
- The command is invoked with
call!
(because an error will be raised, which will alert Sentry) - The failure is a validation failure
-
stop_and_fail!
is called withno_notify: true
- for examplestop_and_fail!('An error message', no_notify: true)
NOTE: commands that invoke other commands with call!
inherit the no_notify
property of the called command.
class InteriorCommand < GLCommand::Callable
def call
stop_and_fail!('An error message', no_notify: true)
end
end
class MainCommand < GLCommand::Callable
def call
# Use call! in commands that invoke other commands to have the errors automatically bubble up
InteriorCommand.call!
end
end
# This won't call GLExceptionNotifier, because no_notify: true was used on InteriorCommand
result = MainCommand.call
result.success? # => false
result.full_error_message # => 'An error message'
Chainable
Bundle commands together with GLCommand::Chainable
- Automatically passes the requires/allows and returns between the commands
- Returns a
GLCommand::ChainableContext
, that inherits fromGLCommand::Context
. It adds aCommands
array that contains the Command class names that were called. - A command in the chain failing will call
rollback
on itself and then each command in the contextCommand
array (in reverse order)
If you need to do logic in the GLCommand::Chainable
class, define the call
method and invoke chain
from that.
class SomeChain < GLCommand::Chainable
requires :item
returns :new_item
chain CommmandOne, CommandTwo
def call
# Add some logic goes here
chain(item:) # Automatically assigns the return to the context
# Additional logic here
end
end
And if you need to return early (and skip the chain
), call skip_chain
- or else you will get an error: #chain method not called in GLCommand::Chainable #call.
class SomeChain < GLCommand::Chainable
requires :item
returns :new_item
chain CommmandOne, CommandTwo
def call
skip_chain unless item.valid? # Automatically skips the whole chain without any errors
chain(:item)
end
end
RSpec Matchers
GLCommand
comes with a set of RSpec matchers to make testing your command's interface declarative and simple.
Setup
To enable the matchers, add the following line to your spec/spec_helper.rb
or spec/rails_helper.rb
:
require 'gl_command/rspec'
This will automatically include the necessary matchers and configure RSpec for specs marked with type: :command
.
Usage
You can now test your command's interface like this:
# spec/commands/some_command_spec.rb
RSpec.describe SomeCommand, type: :command do
describe 'interface' do
it { is_expected.to require(:user).being(User) }
it { is_expected.to allow(:subject) }
it { is_expected.to returns(:message) }
it { is_expected.not_to require(:other_thing) }
end
end
Note: The .being(ClassName)
chain is supported for require
and allow
but not for return
, as GLCommand
does not store type information for return values.
Testing GLCommand
s
Give Lively uses Rspec for testing, so this section assumes you're using RSpec.
Stubbing with build_context
If you need the response from a command (typically because you are stubbing it), use the build_context
method to create a context with the desired response. This has the advantage of using the actual Command's requires
, allows
, and returns
methods.
class SomeCommand < GLCommand::Callable
requires user: User
allows :subject
returns :message
def call
user.update!(subject:)
context.message = "Hello - user subject: #{user.subject}"
end
end
result = SomeCommand.build_context(user: User.new, message: "Hello!")
result.success? # true
result.full_error_message # nil
result_error = SomeCommand.build_context(error: "invalid")
result_error.success? # false
result_error.full_error_message # "invalid"
SomeCommand.build_context(other_thing: "some other thing")
# ArgumentError: Unknown argument or return attribute: 'other_thing'
RSpec Matchers
GLCommand
comes with a set of RSpec matchers to make testing your command's interface declarative and simple.
Setup
To enable the matchers, add the following line to your spec/spec_helper.rb
or spec/rails_helper.rb
:
require 'gl_command/rspec'
This will automatically include the necessary matchers and configure RSpec for specs marked with type: :command
.
Usage
You can now test your command's interface like this:
# spec/commands/some_command_spec.rb
RSpec.describe SomeCommand, type: :command do
describe 'interface' do
it { is_expected.to require(:user).being(User) }
it { is_expected.to allow(:subject) }
it { is_expected.to returns(:message) }
it { is_expected.not_to require(:other_thing) }
end
end
Note: The .being(ClassName)
chain is supported for require
and allow
but not for return
, as GLCommand
does not store type information for return values.
Publishing the gem to Rubygems
-
As per our code agreements, all code changes to this gem are required to be made via pull request with final approval from at least one Give Lively engineer.
-
When creating a pull request, ensure that your code changes include an update to the gem's version number using semantic versioning
-
After getting approval, merge your changes to
main
. -
Once your CI build finishes successfully, pull the latest version of
main
locally. -
Run the command
gem build
. This bundles the relevant files from the gem and prepares it to be published to rubygems.org. -
This will create a new file locally that looks like
gl_command-<new_gem_version_number>.gem
. -
Run
gem push gl_command-<new_gem_version_number>.gem
to publish the new version
NOTE: only the gem owners listed on rubygems can publish new versions
This library is influenced by interactors and inspired by the Command Pattern.