How to use service steps?
Which types of steps are available?
The two criteria currently categorize steps.
-
By action: service and method steps.
-
By control flow:
and
,or
,not
steps.
How to define a service step?
You can register existing services as steps of other services using the step
directive.
Let's have a look at it in practice.
Having the Service
service.
class Service
include ConvenientService::Standard::Config
def result
success
end
end
We can use it as a step of the OtherService
service in the following way.
class OtherService
include ConvenientService::Standard::Config
step Service
end
This code works almost the same as:
class OtherService
include ConvenientService::Standard::Config
def result
Service.result
end
end
That is why for a single-step service the usage of steps may not seem very beneficial.
So consider a more complex example.
Now we have 3 services.
class FooService
include ConvenientService::Standard::Config
def result
success
end
end
class BarService
include ConvenientService::Standard::Config
def result
success
end
end
class BazService
include ConvenientService::Standard::Config
def result
success
end
end
The HostService
uses them as steps.
class HostService
include ConvenientService::Standard::Config
step FooService
step BarService
step BazService
end
Rough HostService
equivalent without steps may be implemented as follows:
class HostService
include ConvenientService::Standard::Config
def result
foo_result = FooService.result
return foo_result unless foo_result.success?
bar_result = BarService.result
return bar_result unless bar_result.success?
BazService.result
end
end
As you can see, steps allow us to eliminate at least 2 assignments and 2 if-conditionals for this particular example.
Sure, those assignments and if-conditionals are still performed under the hood, but it is no longer a developer's responsibility to manage them.
Now the benefit of steps becomes more obvious, but that is not the only benefit.
Other guides are going to demonstrate the additional simplifications.
How to define a method step?
Any method can be a step.
The only requirement is that it must return a result.
For example:
class Service
include ConvenientService::Standard::Config
step :foo
def foo
success
end
end
What is the result of a service with steps?
Consider the following service.
class Service
include ConvenientService::Standard::Config
step :foo
step :bar
step :baz
def foo
success
end
def bar
success
end
def baz
success
end
end
When all steps are successful, the overall service result is the last step result.
result = Service.result
# => <Service::Result status: :success>
result.step
# => <Service::Step method: :baz>
Let's check some other service.
class OtherService
include ConvenientService::Standard::Config
step :foo
step :bar
step :baz
def foo
success
end
def bar
failure("Message from `bar`")
# `error("Message from `bar`")` also stops the sequence.
end
def baz
raise "Not raised exception"
success
end
end
When any step is NOT successful, the overall service result is that NOT successful step result.
result = OtherService.result
# => <OtherService::Result status: :failure>
result.step
# => <OtherService::Step method: :bar>
It is important to note that the remaining steps are NOT even evaluated.
That is why the exception from baz
was not raised.
What is an organizer?
Organizer is a service with steps.
It is also worth mentioning that services without steps are often referred to as regular services.
How to pass arguments to steps?
Use the in
option of the step
directive.
Here is a quick example.
The NestedService
accepts two keyword arguments - :first_kwarg
, and :second_kwarg
.
class NestedService
include ConvenientService::Standard::Config
attr_reader :first_kwarg, :second_kwarg
def initialize(:first_kwarg, :second_kwarg)
@first_kwarg = first_kwarg
@second_kwarg = second_kwarg
end
def result
puts "first_kwarg: `#{first_kwarg}`"
puts "second_kwarg: `#{second_kwarg}`"
success
end
end
This is how the in
option is utilized in order to pass them as step inputs.
class Service
include ConvenientService::Standard::Config
step NestedService, in: [:first_kwarg, :second_kwarg]
def first_kwarg
:foo
end
def second_kwarg
:bar
end
end
As you can see below, the corresponding methods' return values are passed as :first_kwarg
and :second_kwarg
to the NestedService
step when we call the organizer.
For this particular case, it is :foo
and :bar
respectively.
result = Service.result
# "first_kwarg: `:foo`"
# "second_kwarg: `:bar`"
# => <Service::Result status: :success>
In order to simplify the understanding of the step inputs, let's have a closer look at this specific line.
step NestedService, in: [:first_kwarg, :second_kwarg]
Whenever you have trouble with its meaning, please, remember that it can be mentally translated like so:
def first_step_result
@first_step_result ||= NestedService.result(
first_kwarg: first_kwarg,
second_kwarg: second_kwarg
)
end
That is why it is important to have corresponding instance methods defined for step inputs in the organizer class.
More examples of the in
option usage may be found in the so-called translation table.
How to access step result data?
Use the out
option of the step
directive.
Here is a short example.
The NestedService
returns a success
with data that has two keys - :first_data_key
, and :second_data_key
.
class NestedService
include ConvenientService::Standard::Config
def result
success(first_data_key: :baz, second_data_key: :qux)
end
end
This is how the out
option is utilized in order to access them as step outputs.
class Service
include ConvenientService::Standard::Config
step NestedService, out: [:first_data_key, :second_data_key]
step :result
def result
puts "first_data_key: `#{first_data_key}`"
puts "second_data_key: `#{second_data_key}`"
success
end
end
Step result data values become available via the first_data_key
and second_data_key
instance methods.
For this particular case, they return :baz
and :qux
, the same values that were passed to the original success
in the NestedService
service.
result = Service.result
# "first_data_key: `:baz`"
# "second_data_key: `:qux`"
# => <Service::Result status: :success>
In order to simplify the understanding of the step outputs, let's have a closer look at this specific line.
step NestedService, out: [:first_data_key, :second_data_key]
Whenever you have trouble with its meaning, please, remember that it can be mentally translated like so:
def first_step_result
@first_step_result ||= NestedService.result
end
def first_data_key
first_step_result.data[:first_data_key]
end
def second_data_key
first_step_result.data[:second_data_key]
end
Having this information, avoid defining instance methods with the same names as step outputs in the organizer class.
Otherwise, your methods will be silently overridden.
More examples of the out
option usage may be found in the so-called translation table.
How to create a step input alias?
There are cases when the organizer service does not have the corresponding method defined with the same name as a step input.
Or that method is already reserved for some other purpose and can not be reused.
Consider the example, when the RemoveNilElements
service expects the elements
keyword argument as its input.
class RemoveNilElements
include ConvenientService::Standard::Config
attr_reader :elements
def initialize(elements:)
@elements = elements
end
def result
success(filtered_elements: elements.compact)
end
end
The PrepareCollection
service tries to register RemoveNilElements
as its step.
class PrepareCollection
include ConvenientService::Standard::Config
step RemoveNilElements, in: [:elements]
def collection
[42, nil, "foo"]
end
end
Since it does not have the elements
method defined, the usual in: [:elements]
declaration won't work.
But it has the collection
method instead.
In order to utilize its return value as the :elements
input, the PrepareCollection
should be updated as follows.
class PrepareCollection
include ConvenientService::Standard::Config
step RemoveNilElements, in: [{elements: :collection}]
def collection
[42, nil, "foo"]
end
end
Now, the in
option contains a hash {elements: :collection}
that can be read as "Pass elements
to step as collection
".
As a rule of thumb, try to memorize that the alias is always on the right side of the hash 😎.
Traditionally, to simplify the understanding of the steps-related stuff, input aliases this time, let's have a closer look at the step
directive again.
step RemoveNilElements, in: [{elements: :collection}]
Whenever you have trouble with its meaning, please, remember that it can be mentally translated like so:
def first_step_result
@first_step_result ||= RemoveNilElements.result(elements: collection)
end
As you can see, creating an input alias is just passing a different method return value to the underlying service.
More examples of the in
option aliases usage may be found in the so-called translation table.
How to create a step output alias?
There are cases when the organizer service already has the corresponding method defined with the same name as a step output.
That method is probably reserved for some other purpose so it is not supposed to be redefined.
Consider the example, when the RemoveNilElements
service returns the filtered_elements
key as its output.
class RemoveNilElements
include ConvenientService::Standard::Config
attr_reader :elements
def initialize(elements:)
@elements = elements
end
def result
success(filtered_elements: elements.compact)
end
end
The PrepareCollection
service tries to register RemoveNilElements
as its step.
class PrepareCollection
include ConvenientService::Standard::Config
step RemoveNilElements,
in: [:elements],
out: [:filtered_elements]
def elements
[42, nil, "foo"]
end
def filtered_elements
elements - ["foo"]
end
end
Since it already has the filtered_elements
method defined, the usual out: [:filtered_elements]
declaration overrides its return value.
Whenever you call filtered_elements
before the RemoveNilElements
step is executed, it returns [42, nil]
(raises an exception for versions lower than v0.20).
But when you invoke it after the RemoveNilElements
step is run, it returns [42, "foo"]
.
Sometimes such behavior is expected, but most of the time we don't want to lose access to the original filtered_elements
implementation.
That is why there is an ability to define output aliases.
Let's utilize one in the PrepareCollection
service.
class PrepareCollection
include ConvenientService::Standard::Config
step RemoveNilElements,
in: [:elements],
out: [{:filtered_elements: :filtered_collection}]
def elements
[42, nil, "foo"]
end
def filtered_elements
elements - ["foo"]
end
end
The out
option contains a hash {:filtered_elements: :filtered_collection}
that can be read as "Retrieve filtered_elements
from step as filtered_collection
".
Now, the additional filtered_collection
method is defined that is fully independent of filtered_elements
.
As a rule of thumb, try to memorize that the alias is always on the right side of the hash 😎.
As always, to simplify the understanding of the steps-related stuff, output aliases this time, let's have a closer look at the step
directive.
step RemoveNilElements,
in: [:elements],
out: [{:filtered_elements: :filtered_collection}]
Whenever you have trouble with its meaning, please, remember that it can be mentally translated like so:
def first_step_result
@first_step_result ||= RemoveNilElements.result(elements: elements)
end
def filtered_collection
@filtered_collection ||= first_step_result.data[:filtered_elements]
end
As you can see, creating an output alias is just retrieving a different key value from the underlying step result.
More examples of the out
option aliases usage may be found in the so-called translation table.