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.