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,notsteps.
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 typically 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.
How to pass a dynamic value as a step input?
Let's consider the following service.
class Service
# ...
step EscapeRegexp,
in: [:pattern]
out: [:escaped]
# ...
end
We need to pass either ENV["PATH_PATTERN"] or ENV["PATH_GLOB"] to the EscapeRegexp step as the pattern argument.
As you may already guessed that is achievable by defining a corresponding instance method.
class Service
# ...
step EscapeRegexp,
in: [:pattern]
out: [:escaped]
# ...
def pattern
ENV["PATH_PATTERN"] || ENV["PATH_GLOB"]
end
end
This is what you would usually do most of the time.
When the pattern method is reserved for some other purpose in your organizer service, there is always a possibility of using an alias.
class Service
# ...
step EscapeRegexp,
in: [{pattern: :template}]
out: [:escaped]
# ...
def template
ENV["PATH_PATTERN"] || ENV["PATH_GLOB"]
end
end
In this example the template method return value is passed as the pattern argument.
But sometimes it may feel redundant or unnatural to define a dedicated method for such a simple task.
That is why there are two additional ways to dynamically pass a value as a step input:
-
Using a
rawstep input argument. -
Using a
procstep input argument.
What is a raw step input argument?
Here is a quick example.
step EscapeRegexp,
in: [{pattern: raw(ENV["PATH_PATTERN"] || ENV["PATH_GLOB"]) }],
out: [:escaped]
This is how the step call is "translated" to regular service invocation under the hood:
def step_result
@step_result ||= EscapeRegexp.result(pattern: ENV["PATH_PATTERN"] || ENV["PATH_GLOB"])
end
Since raw is just a class method you can pass to it anything from the enclosing class scope.
The value is forwarded without any intermediate processing.
step EscapeRegexp,
in: [{pattern: raw(any_class_method)}],
out: [:escaped]
What is a proc step input argument?
Here is a quick example.
step EscapeRegexp,
in: [{pattern: -> { ENV["PATH_PATTERN"] || ENV["PATH_GLOB"] }}],
out: [:escaped]
Similarly to raw, the proc form also does not additionally process the passed value, but it is evaluated in the instance scope instead of the class scope.
So you can access any service instance methods in it.
step EscapeRegexp,
in: [{pattern: -> { any_instance_method }}],
out: [:escaped]
What is a not_step?
Once your codebase starts to grow, you will notice that it contains a lot of so-called boolean services.
The following list demonstrates some common names.
-
IsAdmin. -
HasAccessRights -
ShouldGrantPermission. -
AssertPolicySatisfied. -
EnsureEligibleForRemoval. -
ConfirmRuleApplied. -
WasPreviouslyChecked. -
And so on...
Usually, such services are utilized as the precondition steps in organizers.
class PlayAudio
include ConvenientService::Standard::Config
# ...
step AssertFormatSupported,
in: :audio
step EnsureDriverUpgraded,
in: :format
step ConfirmMaxSizeNotExceeded,
in: :size
# ...
step :result
def result
# ...
end
end
After a while it is just a matter of time when some of those preconditions are required to be used in some other contexts, but in the negated form.
In other words, negative counterparts are created.
-
IsAdmin->IsNotAdmin. -
HasAccessRights->DoesNotHaveAccessRights. -
ShouldGrantPermission->ShouldNotGrantPermission. -
AssertPolicySatisfied->RefutePolicySatisfied. -
EnsureEligibleForRemoval->EnsureNotEligibleForRemoval. -
ConfirmRuleApplied->DenyRuleApplied. -
WasPreviouslyChecked->WasNotPreviouslyChecked. -
And so on...
A majority of the time those services have very similar implementations.
The only difference is that success and failure results are switched.
For example, consider the AssertFormatSupported service.
class AssertFormatSupported
include ConvenientService::Standard::Config
attr_reader :audio
def initialize(audio:)
@audio = audio
end
def result
return error("Audio is nil") if audio.nil?
if !supported_formats.include?(audio.format)
return failure("Audio `#{audio.name}` format `#{audio.format}` is NOT supported")
end
success
end
end
Its opposite service RefuteFormatSupported has a slighlty distinctive definition.
class RefuteFormatSupported
include ConvenientService::Standard::Config
attr_reader :audio
def initialize(audio:)
@audio = audio
end
def result
return error("Audio is nil") if audio.nil?
if supported_formats.include?(audio.format)
return failure("Audio `#{audio.name}` format `#{audio.format}` is supported")
end
success
end
end
If you look precisely, only ! and NOT are removed compared to the original AssertFormatSupported service.
As a consequence, it may seem not logical to maintain two almost identical solutions.
That is why the not_step directive is available.
It works similarly to the regular step directive.
It also has in and out options, supports alias, raw, and proc input arguments, etc, but it calls the negated_result under the hood.
Having that said, a not_step usage like in the following snippet:
class Service
include ConvenientService::Standard::Config
not_step AssertFormatSupported,
in: :audio
# ...
end
Can be mentally translated to:
def first_step_result
@first_step_result ||= AssertFormatSupported.negated_result(audio: audio)
end
When the original result returns success, the negated_result returns failure.
When the original result returns failure, the negated_result returns success.
The error is always an error, so both the original result and negated_result return the same status.
Thus, the not_step directive (and negated_result) eliminates the need to maintain the opposite services when negated implementations are almost identical to the original.