TheHelp

TheHelp is a framework for developing service objects in a way that encourages adherence to the Single Responsibility Principle and Tell Don't Ask

Installation

Add this line to your application's Gemfile:

gem 'the_help'

And then execute:

$ bundle

Or install it yourself as:

$ gem install the_help

Usage

Create subclasses of TheHelp::Service and call them.

Make it easier to call a service by including TheHelp::ServiceCaller.

Service Results

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.)

Running Callbacks

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.)

Development

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.

Contributing

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.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the TheHelp project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.