TheHelp is a framework for developing service objects in a way that encourages adherence to the Single Responsibility Principle and Tell Don't Ask
Add this line to your application's Gemfile:
gem 'the_help'
And then execute:
$ bundle
Or install it yourself as:
$ gem install the_help
Create subclasses of TheHelp::Service
and call
them.
Make it easier to call a service by including
TheHelp::ServiceCaller
.
Every service call will return an instance of TheHelp::Service::Result
. Your service
implementation MUST set a result using either the #success
or the #error
methods, for example:
class MyService < TheHelp::Service
authorization_policy allow_all: true
input :foo
main do
if foo
result.success 'bar'
else
result.error 'sorry, that did not work'
end
end
end
result = MyService.call(context: {}, logger: logger, foo: false)
result.success?
#=> false
result.error?
#=> true
result.value
#=> 'sorry, that did not work'
result.value!
# raises the exception TheHelp::Service::ResultError with the message 'sorry, that did not work'
result = MyService.call(context: {}, logger: logger, foo: true)
result.success?
#=> true
result.error?
#=> false
result.value
#=> 'bar'
result.value!
#=> 'bar'
MyService.call(context: {}, logger: logger, foo: true) { |result|
break 'oops' if result.error?
result.value + ' baz'
}
#=> 'bar baz'
When using the ServiceCaller interface, unless a block is provided, the #call_service
will call
the TheHelp::Service::Result#value!
method internally, and will either return the succesful
result value or raise an exception as appropriate.
call_service(MyService, foo: true)
#=> 'bar'
call_service(MyService, foo: false)
# raises the exception TheHelp::Service::ResultError with the message 'sorry, that did not work'
call_service(MyService, foo: true) { |result|
break 'oops' if result.error?
result.value + ' baz'
}
#=> 'bar baz'
Finally, you can change the type of the exception that is raised when
TheHelp::Service::Result#value!
is called on an error result by providing the exception itself
as the result value:
class MyService < TheHelp::Service
authorization_policy allow_all: true
input :foo
main do
if foo
result.success 'bar'
else
result.error ArgumentError.new('foo must be true')
end
end
end
call_service(MyService, foo: false)
# raises the exception ArgumentError with the message 'foo must be true'
If you want to make sure the exception's backtrace points to the correct line of code, raise the
exception in a block provided to the #error
method:
class MyService < TheHelp::Service
authorization_policy allow_all: true
input :foo
main do
if foo
result.success 'bar'
else
result.error { raise ArgumentError.new('foo must be true') }
end
end
end
call_service(MyService, foo: false)
# raises the exception ArgumentError with the message 'foo must be true'
With the block form, the backtrace will point to the line where the exception was first raised
rather than to the #value!
method, however all other code execution will continue until the
point where the #value!
method is called (as long as the exception is a subtype of
StandardError
.)
In some cases a simple success or error result is not sufficient to describe the various results about which a service may need to be able to inform its callers. In these cases, a callback style of programming can be useful:
class Foo < TheHelp::Service
authorization_policy allow_all: true
main do
call_service(GetSomeWidgets,
customer_id: 12345,
each_widget: callback(:process_widget),
invalid_customer: callback(:no_customer),
no_widgets_found: callback(:no_widgets))
do_something_else
end
callback(:process_widget) do |widget|
# do something with it
end
callback(:invalid_customer) do
# handle this case
stop!
end
callback(:no_widgets) do
# handle this case
end
callback(:do_something_else) do
# ...
end
end
When writing a service that accepts callbacks like this, do not simply run
#call
on the callback that was passed in. Instead you must use the
#run_callback
method. This ensures that, if the callback method you pass in
tries to halt the execution of the service, it will behave as expected.
In the above service, it is clear that the intention is to stop executing the
Foo
service in the case where GetSomeWidgets
reports back that the customer
was invalid. However, if GetSomeWidgets
is implemented as:
class GetSomeWidgets < TheHelp::Service
input :customer_id
input :each_widget
input :invalid_customer
input :no_widgets_found
authorization_policy allow_all: true
main do
set_some_stuff_up
if customer_invalid?
invalid_customer.call
no_widgets_found.call
do_some_important_cleanup_for_invalid_customers
result.error 'invalid customer'
else
#...
result.success some_widgets
end
end
#...
end
then the problem is that the call to #stop!
in the Foo#invalid_customer
callback will not just stop the Foo
service, it will also stop the
GetSomeWidgets
service at the point where the callback is executed (because it
uses throw
behind the scenes.) This would cause the
do_some_important_cleanup_for_invalid_customers
method to never be called.
You can protect yourself from this by implementing GetSomeWidgets
like this,
instead:
class GetSomeWidgets < TheHelp::Service
input :customer_id
input :each_widget
input :invalid_customer
input :no_widgets_found
authorization_policy allow_all: true
main do
set_some_stuff_up
if customer_invalid?
run_callback(invalid_customer)
run_callback(no_widgets_found)
do_some_important_cleanup_for_invalid_customers
result.error 'invalid customer'
else
#...
result.success some_widgets
end
end
#...
end
This will ensure that callbacks only stop the service that provides them, not
the service that calls them. (If you really do need to allow the calling service
to stop the execution of the inner service, you could raise an exception or
throw a symbol other than :stop
; but do so with caution, since it may have
unintended consequences further down the stack.)
After checking out the repo, run bin/setup
to install dependencies. Then, run
rake spec
to run the tests. You can also run bin/console
for an interactive
prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To
release a new version, update the version number in version.rb
, and then run
bundle exec rake release
, which will create a git tag for the version, push
git commits and tags, and push the .gem
file to
rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/jwilger/the_help. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the TheHelp project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.