Simpler, safer and more composable operations
FService is a small gem that provides a base class for your services (aka operations). The goal is to make services simpler, safer, and more composable. It uses the Result monad for handling operations.
Add this line to your application's Gemfile:
gem 'f_service'
And then execute:
$ bundle
Or install it yourself as:
$ gem install f_service
To start using it, you have to create your service class inheriting from FService::Base.
class User::Create < FService::Base
end
Now, define your initializer to setup data.
class User::Create < FService::Base
def initialize(name:)
@name = name
end
end
The next step is writing the #run
method, which is where the work should be done.
Use the methods #Success
and #Failure
to handle your return values.
You can optionally specify a list of types which represents that result and a value for your result.
class User::Create < FService::Base
# ...
def run
return Failure(:no_name, :invalid_attribute) if @name.nil?
user = UserRepository.create(name: @name)
if user.save
Success(:success, :created, data: user)
else
Failure(:creation_failed, data: user.errors)
end
end
end
Remember, you have to return an
FService::Result
at the end of your services.
To run your service, use the method #call
provided by FService::Base
. We like to use the implicit call, but you can use it in the form you like most.
User::Create.(name: name)
# or
User::Create.call(name: name)
We do not recommend manually initializing and running your service because it will not type check your result (and you could lose nice features like pattern matching and service chaining)!
Use the methods #successful?
and #failed?
to check the status of your result. If it is successful, you can access the value with #value
, and if your service fails, you can access the error with #error
.
A hypothetical controller action using the example service could look like this:
class UsersController < BaseController
def create
result = User::Create.(user_params)
if result.successful?
json_success(result.value)
else
json_error(result.error)
end
end
end
Note that you're not limited to using services inside controllers. They're just PORO's (Play Old Ruby Objects), so you can use in controllers, models, etc. (even other services!).
The code above could be rewritten using the #on_success
and #on_failure
hooks. They work similar to pattern matching:
class UsersController < BaseController
def create
User::Create.(user_params)
.on_success { |value| return json_success(value) }
.on_failure { |error| return json_error(error) }
end
end
Or else it is possible to specify an unhandled option to ensure that the callback will process that message anyway the error.
class UsersController < BaseController
def create
User::Create.(user_params)
.on_success(unhandled: true) { |value| return json_success(value) }
.on_failure(unhandled: true) { |error| return json_error(error) }
end
end
class UsersController < BaseController
def create
User::Create.(user_params)
.on_success { |value| return json_success(value) }
.on_failure { |error| return json_error(error) }
end
end
You can ignore any of the callbacks, if you want to.
Going further, you can match the Result type, in case you want to handle them differently:
class UsersController < BaseController
def create
User::Create.(user_params)
.on_success(:user_created) { |value| return json_success(value) }
.on_success(:user_already_exists) { |value| return json_success(value) }
.on_failure(:invalid_data) { |error| return json_error(error) }
.on_failure(:critical_error) do |error|
MyLogger.report_failure(error)
return json_error(error)
end
end
end
It's possible to provide multiple types to the hooks too. If the result type matches any of the given types, the hook will run.
class UsersController < BaseController
def create
User::Create.(user_params)
.on_success(:user_created, :user_already_exists) { |value| return json_success(value) }
.on_failure(:invalid_data) { |error| return json_error(error) }
.on_failure(:critical_error) do |error|
MyLogger.report_failure(error)
return json_error(error)
end
end
end
FService matches the service's types from left to right, from more specific to more generic.
For example, the following result Failure(:unprocessable_entity, :client_error, :http_response)
will match in the following order:
:unprocessable_entity
;:client_error
;:http_response
;- unmatched block;
Since all services return Results, you can chain service calls making a data pipeline. If some step fails, it will short circuit the call chain.
class UsersController < BaseController
def create
result = User::Create.(user_params)
.and_then { |user| User::Login.(user) }
.and_then { |user| User::SendWelcomeEmail.(user) }
if result.successful?
json_success(result.value)
else
json_error(result.error)
end
end
end
You can use the .to_proc
method on FService::Base to avoid explicit inputs when chaining services:
class UsersController < BaseController
def create
result = User::Create.(user_params)
.and_then(&User::Login)
.and_then(&User::SendWelcomeEmail)
# ...
end
end
You can use Check
to converts a boolean to a Result, truthy values map to Success
, and falsey values map to Failures
:
Check(:math_works) { 1 < 2 }
# => #<Success @value=true, @types=[:math_works]>
Check(:math_works) { 1 > 2 }
# => #<Failure @error=false, @types=[:math_works]>
Try
transforms an exception into a Failure
if some exception is raised for the given block. You can specify which exception class to watch for
using the parameter catch
.
class IHateEvenNumbers < FService::Base
def run
Try(:rand_int) do
n = rand(1..10)
raise "Yuck! It's a #{n}" if n.even?
n
end
end
end
IHateEvenNumbers.call
# => #<Success @value=9, @types=[:rand_int]>
IHateEvenNumbers.call
# => #<Failure @error=#<RuntimeError: Yuck! It's a 4>, @types=[:rand_int]>
We provide some helpers and matchers to make ease to test code envolving Fservice services.
To make available in the system, in the file 'spec/spec_helper.rb' or 'spec/rails_helper.rb'
add the folowing require:
require 'f_service/rspec'
mock_service(Uer::Create)
# => Mocks a successful result with all values nil
mock_service(Uer::Create, result: :success)
# => Mocks a successful result with all values nil
mock_service(Uer::Create, result: :success, types: [:created, :success])
# => Mocks a successful result with type created
mock_service(Uer::Create, result: :success, types: :created, value: instance_spy(User))
# => Mocks a successful result with type created and a value
mock_service(Uer::Create, result: :failure)
# => Mocs a failure with all nil values
mock_service(User::Create, result: :failure, types: [:unprocessable_entity, :client_error])
# => Mocs a failure with a failure type
mock_service(User::Create, result: :failure, types: [:unprocessable_entity, :client_error], value: { name: ["can't be blank"] })
# => Mocs a failure with a failure type and an error value
expect(User::Create.(name: 'Joe')).to have_succeed_with(:created)
expect(User::Create.(name: 'Joe')).to have_succeed_with(:created).and_value(an_instance_of(User))
expect(User::Create.(name: nil)).to have_failed_with(:invalid_attributes)
expect(User::Create.(name: nil)).to have_failed_with(:invalid_attributes).and_error({ name: ["can't be blank"] })
expect(User::Create.(name: nil)).to have_failed_with(:invalid_attributes).and_error(a_hash_including(name: ["can't be blank"]))
You can access the API docs here.
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 allows 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/Fretadao/f_service.
The gem is available as open-source under the terms of the MIT License.