Skip to main content

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
info

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
info

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
info

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.

tip

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.

tip

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.

tip

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.