How to use service results?
Which result types/statuses are available?
There are only 3 available statuses for service results.
The success status is when the service goal is resolved positively.
The failure status is when a service goal is resolved negatively.
The error status is when the service goal is not resolved. Additional moves are required to get a failure or success instead.
How to create success result?
Just call the success instance method and return it from result.
class Service
include ConvenientService::Standard::Config
def result
success
end
end
How to pass data to success result?
To be explicit, you may use the data keyword.
It accepts a hash with Symbol keys and any values.
Like in the service below.
class Service
include ConvenientService::Standard::Config
def result
success(data: {foo: :bar, baz: :qux})
end
end
But it is more common to omit it.
class Service
include ConvenientService::Standard::Config
def result
success(foo: :bar, baz: :qux)
end
end
How to access success result data?
Utilize round brackes [] on the result data object.
result = Service.result
if result.success?
result.data[:foo]
# => bar
end
A self-explanatory exception is raised when data is accessed before the status is checked.
result = Service.result
result.data[:foo]
# => Raises exception since result status is NOT checked...
That is by design.
The intention is to encourage developers to think about fallbacks from the beginning.
For example, to define reasonable else.
if result.success?
result.data[:foo]
# => bar
else
# Fallback.
end
Or to check for failure and error as well.
if result.success?
result.data[:foo]
# => bar
elsif result.failure?
# Some fallback.
else # result.error?
# Some other fallback.
end
A self-explanatory exception is raised when a missing data key is accessed.
result = Service.result
if result.success?
result.data[:missing_key]
# => Raises exception since data has NO value by `:missing_key` key...
end
That is also by design.
The intention is to highligh typos during development, so that they are fixed earlier.
How to create failure or error results?
failures and errors have similar public interface.
That is why it makes sence to explain them together.
So in order to create a failure or error - invoke the corresponding instance method, pass a message to it, and return its value from result.
This is an example for failure.
class Service
include ConvenientService::Standard::Config
def result
failure(message: "Some business rule is NOT satisfied")
end
end
This is how to create error.
class Service
include ConvenientService::Standard::Config
def result
error(message: "Something forbided service goal to be resolved")
end
end
The message keyword can be omitted.
def result
failure("Some business rule is NOT satisfied")
end
def result
error("Something forbided service goal to be resolved")
end
How to access failure or error result message?
Utilize the result message object.
result = Service.result
if result.success?
# ...
elsif result.failure?
result.message
# => "Some business rule is NOT satisfied"
else # result.error?
result.message
# => "Something forbided service goal to be resolved"
end
A self-explanatory exception is raised when message is accessed before the status is checked.
result = Service.result
result.message
# => Raises exception since result status is NOT checked...
The motivation is to discourage checks like if result.message.empty?.
How to check result status?
Check the comprehensive list of commands below.
To make them more demonstrative, let's introduce the following minimalistic services.
class SuccessService
include ConvenientService::Standard::Config
def result
success
end
end
SuccessService always returns success.
class FailureService
include ConvenientService::Standard::Config
def result
failure
end
end
FailureService always returns failure.
class ErrorServices
include ConvenientService::Standard::Config
def result
error
end
end
ErrorServices always returns error.
Having that information status checkers work as follows:
SuccessService.result.success?
# => true
FailureService.result.success?
# => false
ErrorService.result.success?
# => false
SuccessService.result.failure?
# => false
FailureService.result.failure?
# => true
ErrorService.result.failure?
# => false
SuccessService.result.error?
# => false
FailureService.result.error?
# => false
ErrorService.result.error?
# => true
Also not counterparts are available.
SuccessService.result.not_success?
# => false
FailureService.result.not_success?
# => true
ErrorService.result.not_success?
# => true
SuccessService.result.not_failure?
# => true
FailureService.result.not_failure?
# => false
ErrorService.result.not_failure?
# => true
SuccessService.result.not_error?
# => true
FailureService.result.not_error?
# => true
ErrorService.result.not_error?
# => false
For people who like to save every keyboard stroke - a shorter equivalent is available for success.
It is just ok.
SuccessService.result.ok?
# => true
FailureService.result.ok?
# => false
ErrorService.result.ok?
# => false
And the opposite for it.
SuccessService.result.not_ok?
# => false
FailureService.result.not_ok?
# => true
ErrorService.result.not_ok?
# => true
How to check for specific failure or error?
No. You don't need to match regular expressions by yourself.
Something like result.message.to_s.match?(/Business Rule/) is NOT the way to go.
Please, consider the code keyword.
Here is a quick demonstration.
class Service
include ConvenientService::Standard::Config
attr_reader :number
def initialize(number:)
@number = number
end
def result
return error(message: "Message for `foo`", code: :foo) if number == 1
return error(message: "Message for `bar`", code: :bar) if number == 2
return failure(message: "Message for `baz`", code: :baz) if number == 3
return failure(message: "Message for `qux`", code: :qux) if number == 4
success
end
end
Depending on the input number, the service above returns various failures and errors.
A shorter form is also available.
def result
return error("Message for `foo`", :foo) if number == 1
return error("Message for `bar`", :bar) if number == 2
return failure("Message for `baz`", :baz) if number == 3
return failure("Message for `qux`", :qux) if number == 4
success
end
This is how you can differentiate those failures and errors on the calling code.
result = Service.result(number: 4)
if result.success?
# ...
elsif result.failure?
if result.code.to_sym == :baz
result.message
# => "Message for `baz`"
elsif result.code.to_sym == :qux
result.message
# => "Message for `qux`"
else
# ...
end
else # result.error?
if result.code.to_sym == :foo
result.message
# => "Message for `foo`"
elsif result.code.to_sym == :bar
result.message
# => "Message for `bar`"
else
# ...
end
end
Sure, this snippet is not the prettiest, but currently, there is NO alternative.
Optimization like the following (API is still subject to change) is planned for the subsequent Convenient Service releases.
result = Service.result(number: 4)
result.respond_to do |status|
status.success { }
status.failure(code: :baz) { }
status.failure(code: :qux) { }
status.error(code: :foo) { }
status.error(code: :bar) { }
status.unexpected { }
end
When to use success results?
When you have a 100% guarantee that the service's desired outcome is achieved.
In other words, all business rules are satisfied and the service's actual operation is completed.
When to use failure results?
When you have a 100% guarantee that the service's desired outcome is NOT achieved.
In other words, some business rule is NOT satisfied.
When to use error results?
When you don't have any guarantee that the service's desired outcome was even tried to be completely achieved.
In other words, some business rule check is NOT completed or the service's actual operation is NOT completed.
How to decide between success, failure, and error results?
Let's consider the following service.
class AddRole
include ConvenientService::Standard::Config
attr_reader :user, :role_name
def initialize(user:, role_name:)
@user = user
@role_name = role_name
end
def result
return error("User is blank") if user.blank?
return error("Role name is blank") if role_name.blank?
return failure("User(#{user.id}) already has `#{role_name}` role") if user.has_role?(role_name) # Business rule.
return failure("User(#{user.id}) reputation is too low for `#{role_name}` role") if user.reputation.low? && role_name == :admin # Business rule.
user.add_role(role_name) # Service actual operation.
success
rescue DB::ConnectionTimeout => exception
error("DB connection is lost due to `#{exception.message}`")
end
end
As its name states, the AddRole service is responsible for the addition of a role to a particular user.
Thus successfully added role is its service goal.
AddRole can be easily invoked like so:
result = AddRole.result(user: User.find(1), role_name: :admin)
When the success result is returned, you are confident that the role is added.
result.success?
# => true
When the failure result is returned, you are confident that the role is NOT added.
result.failure?
# => true
As a significant bonus, you immediately receive the exact logical reason that clearly explains what forbade the role addition.
For this concrete service, it is the fact that the user already has the passed role.
result.message
# => "User(1) already has `admin` role"
Or the fact that the user's reputation is too low for the admin role.
result.message
# => "User(1) reputation is too low for `admin` role"
So you can quickly report that reason to the interested people without additional debugging sessions.
When the error result is returned, you obtain NO new information about the service goal.
result.error?
# => true
As you had NO idea whether it was possible or impossible to add the role before running the service.
The same question remains open and unresolved after executing the service.
Strictly speaking, the error result notifies that the only way to get the reliable service goal outcome is to rerun the service with changed inputs or to retry later.
AddRole has multiple errors.
The first two of them are unsatisfied input validations.
result.message
# => "User is blank"
result.message
# => "Role name is blank"
Usually, they indicate that a developer (service user) who was invoking the service made a mistake by providing incorrect inputs.
That is why they should be changed before rerunning the service.
The last one is a database exception.
result.message
# => "DB connection is lost due to `Default timeout exceeded`"
Such an error may show that the runtime infrastructure is not stable.
That is why retrying again later may be an option.
Similarly to failures, errors also have messages that clearly explain the reasons for what went wrong.
But, this time they are just hints about where to proceed with debugging, not the reliable facts about service goal resolution.