Technical writings of Shkrt
Trailblazer::Operation introduces the concept of operation
- a programming pattern, used for wrapping a set of actions into a single object,
providing API to work with errors and results of these actions. The actions themselves are often the interactors.
The similar approach is also used in a dry-transaction library.
The most obvious properties of operation can be summarized as follows:
the operation takes control of execution some service-object-alike actions one after another, step by step
execution continues to the next action only if the result of the previous action was successful
if the result of the previous action was not successful, the code has access to the errors from the previous action.
Some folks are calling the code written by respecting these conventions as “railway-oriented programming”, but for me, it simply looks like a monad.
Let’s model a situation when there is a need to perform some action one step after another, and wrap it in a single transaction-like object. Imagine a user-powered news delivery system, where most of the content is submitted by readers and readers also get rewarded for worthy articles. The system has some AI-empowered service, that evaluates article’s worthfulness and assigns a grade to user submission. Also, users tend to violate service’s rules, and in that case, they are fined instead of getting reward points. The system also does not give reward points, if a submitted article has been graded too low. In this case, it notifies the user that the submitted content needs a little more work. Basically, we have a number of callable service objects for each task:
CreateNewsItem |
creates article from user submission |
ValidateTerms |
validates if article content does not violate the terms |
EvaluateGrade |
evaluates article’s grade |
CalculateUserReward |
calculates reward points, accounting particular users submission history |
UpdateRewardPoints |
updates users reward points |
NotifyReward |
notifies user about reward |
ApplyFine |
applies fine if user has violated the rules |
NotifyUpdateExpected |
notifies user if article should be updated |
If we think in terms of operation, we can split this logic into the successful and unsuccessful tracks. First six operations build into a successful track, and the last two are the matters of a failing scenario. This can be generalized as the following scheme:
#1. Successful scenario(general):
CreateNewsItem
–> ValidateTerms
–> EvaluateGrade
–> CalculateUserReward
–> NotifyReward
#2. Scenario, when user violated the terms of service
CreateNewsItem
–> ValidateTerms
–> ApplyFine
#3. Scenario, when user’s submission got low grade
CreateNewsItem
–> ValidateTerms
–> EvaluateGrade
–> NotifyUpdateExpected
By the use of operation concept, we can build a maintainable implementation of this logic, so let’s look at how this can be done using
Trailblazer:Operation
.
To start, we only need one gem installed:
Inherit the main service object, that will call further actions, from Trailblazer::Operation:
First prototype looks like this:
Notice the fail_fast
option passed to the apply_fine
method. It ensures that execution of the failure path will not continue
after the first failure, which we need in this case. Without this option, notify_update_expected
would have been called
after the apply_fine
method.
Every method in our method chain receives two arguments, the first is a Trailblazer::Skill object, and the second is params hash, containing additional parameters that can be passed to the method
If we inspect the first argument, the Trailblazer::Skill object, we can find a way to see the whole tree of calls:
In Trailblazer terminology, successful and failure paths are called right and left tracks, respectively. So, this inspect
output
contains a visual representation of left and right tracks. Also we can inspect the call tree another way, by calling the whole
operation as:
NewsItemSubmission.call['pipetree']
Here we used methods for steps, but instead of them we can use lambda or class with call
class method, so let’s
rewrite the class using a separate class instead of each method:
How does Trailblazer::Operation decide, if the current step was successful or not? Based on its return value. To continue on the right track, we have to return truthy value, to move to the failure track - falsy value. Now, all we have to do to ensure that our service object is working as expected is to write specs, for each of the 3 scenarios.
As we can see from the output of rspec, each one of our three scenarios is working perfectly.
Another basic concept of Trailblazer::Operation is the results.
Basically, results is a hash, that can be modified at every step and adds to a resulting Trailblazer::Skill object. For example, if we want to save some intermediary results in an operation object, we can add following construct in every chosen step:
These values are then accessible in each step of the operation and in the resulting object:
Suggested reading:
[ruby
trailblazer
]