Skip to main content

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".

tip

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.

tip

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.