Activity
- Last updated 15 Jun 23
Overview
An activity is an executable circuit of tasks. Each task is arbitrary Ruby code, usually encapsulated in a callable object. Depending on its return value and its outgoing connections, the next task to invoke is picked.
Activities are tremendously helpful for modelling and implementing any kind of logic and any level of complexity. They’re useful for a hash merge algorithm, an application’s function to validate form data and update models with it, or for implementing long-running business workflows that drive entire application lifecycles.
The activity
gem is an extraction from Trailblazer 2.0, where we only had operations. Operations expose a linear flow which goes into one direction, only. While this was a massive improvement over messily nested code, we soon decided it’s cool being able to model non-linear flows. This is why activities are the major concept since Trailblazer 2.1.
Anatomy
To understand the mechanics behind Trailblazer’s activities, you need to know a few simple concepts.
- An activity is a circuit of tasks - boxes being connected by arrows.
- It has one start and at least one end event. Those are the circles in the diagrams.
- A task is a unit of business logic. They’re visualized as boxes. This is where your code goes!
- Each task has one or more outputs. From one particular output you can draw one connecting line to the next task.
- An output is triggered by a signal. The last line in a task usually decides what output to pick, and that happens by
return
ing a specific object, a signal. - Besides the signal, a semantic is assigned to an output. This is a completely arbitrary “meaning”. In Trailblazer, we use
success
andfailure
as conventional semantics. - In a railway activity, for instance, the “failure” and “success” track mean nothing more than following the
failure
orsuccess
-labeled outputs. That’s a track.
Activities can be visualized neatly by taking advantage of the BPMN specification.
Well, this is not entirely BPMN, but you get the idea. Intuitively, you understand that the tasks B
and C
have only one outcome, whereas A
yields two possible results. This works by adding two outputs to A
.
An output is a combination of a semantic and a signal. A part of the return value of the invoked task is interpreted as a signal, and that’s how Trailblazer picks the connection to the next task to take.
Depending on A
’s’ returned signal (yet to be defined), the flow will continue on its success
or failure
connection. It’s completely up to the modelling developer what names they choose for semantics, and how many outputs they need. Nevertheless, for binary outputs we usually take success
and failure
as meaningful semantics.
DSL
To implement our activity, we can use Activity’s DSL.
To demonstrate the concepts of an activity, we make use of the DSL. This simplifies defining activities. However, keep in mind that you’re free to build activities using the PRO editor, with your own DSL or with our [low-level API].
class Upsert < Trailblazer::Activity::Path
step :find_model, Output(Trailblazer::Activity::Left, :failure) => Id(:create)
step :update
step :create, magnetic_to: nil, Output(Trailblazer::Activity::Right, :success) => Id(:update)
# ...
end
The Activity::Path
class is the simplest DSL strategy. It automatically connects each step
to the previous one, unless you use the :magnetic_to
option. In our case, this is necessary to connect #find
(A) to #create
(C). The Output
method helps to define what signal and semantic an output has, and using Id
you can point those to a specific neighbor task.
If unsure, use the [developer tools] to render the circuit.
Trailblazer::Developer.render(A::Upsert)
Alternatively, use the PRO editor tools.
Invocation
Before you can use your activity, the tasks need to be written. Using the [task interface] this is pretty straight-forward. Note that you can return either a boolean value or a [signal subclass] in order to dictate the direction of flow.
class Upsert < Trailblazer::Activity::Path
# ...
def find_model(ctx, id:, **) # A
ctx[:memo] = Memo.find(id)
ctx[:memo] ? Trailblazer::Activity::Right : Trailblazer::Activity::Left # can be omitted.
end
def update(ctx, params:, **) # B
ctx[:memo].update(**params)
true # can be omitted
end
def create(ctx, **)
ctx[:memo] = Memo.new
end
end
You don’t have to stick to the task interface! The [circuit interface] is a bit more clumsy, but gives you much better control over how ctx and signals are handled.
To run your activity, use its call
method. Activity
s always use the [circuit interface].
ctx = {id: 1, params: {text: "Hydrate!"}}
signal, (ctx, flow_options) = A::Upsert.([ctx, {}])
The ctx
will be whatever the most recently executed task returned, and hopefully contain what you’re expecting.
# FIXME
After this brief introduction, you should check out how [nesting] of activities will help you, what [operations] are, and what awesome debugging tools such as [tracing] we provide.
:activity
is guaranteed to match the currently invoked activity
STRATEGY
Path
The simplest strategy is Path
, which does nothing but connecting each task’s :success
output to the following task.
class Create < Trailblazer::Activity::Path
step :validate
step :create
# ...
end
Without any additional DSL options, this results in a straight path.
In turn, this means that only true
return values in your tasks will work. The DSL will, per default, wrap every task with the Binary
interface, meaning returning true
will result in Activity::Right
, and false in Activity::Left
. Currently, only Right
signals are wired up.
You may add as many outputs to a task as you need. The DSL provides the Output()
helper to do so.
class Create < Trailblazer::Activity::Path
step :validate, Output(Trailblazer::Activity::Left, :failure) => End(:invalid)
step :create
# ...
end
The Path
strategy only maintains the :success
/Activity::Right
semantic/signal combination. Any other combination you need to define explicitly using Output(signal, semantic)
.
The End()
helper allows creating a new end event labelled with the specified semantic.
class Create < Trailblazer::Activity::Path
step :validate, Output(Trailblazer::Activity::Left, :failure) => End(:invalid)
step :create
# ...
end
This will result in the following circuit.
The validate
task now has a success
and a failure
output. Since it’s wrapped using Binary
it may return true
or false
to dictate the used output (or Activity::Right
/Activity::Left
since it’s the [task interface]).
class Create < Trailblazer::Activity::Path
# ...
def validate(ctx, params:, **)
ctx[:input] = Form.validate(params) # true/false
end
def create(ctx, input:, **)
Memo.create(input)
end
end
The activity will halt on the :invalid
-labelled end if validate
was falsey.
ctx = {params: nil}
signal, (ctx, flow_options) = Memo::Create.([ctx, {}])
puts signal #=> #<Trailblazer::Activity::End semantic=:invalid>
Note that repeatedly using the same semantic (End(:semantic)
) will reference the same end event.
class Create < Trailblazer::Activity::Path
step :validate, Output(Trailblazer::Activity::Left, :failure) => End(:invalid)
step :create, Output(Trailblazer::Activity::Left, :failure) => End(:invalid)
# ...
end
Since we’re adding a :failure
output, create
now has two outgoing connections.
Railway
The Railway
pattern is used for “automatic” error handling. You arrange your actual chain of logic on the “success” track, if a problem occurs, the processing jumps to the parallel “failure” track, skipping the rest of the tasks on the success track.
Once on the failure track, it stays there (unless you instruct not to do so!).
Three possible execution paths this activity might take.
- No errors: First
validate
, thencreate
, then ends inEnd.success
. The activity was successful. - Validation error: First
validate
, which returns aLeft
(failure) signal, leading tolog_error
, thenEnd.failure
. - Creation error: First
validate
, thencreate
, which deviates to the failure track, leading toEnd.failure
. Note this doesn’t hit the logging error handler due to the sequence order.
To place tasks on the failure track, use #fail
. Note that the order of tasks corresponds to the order in the Railway.
class Create < Trailblazer::Activity::Railway
step :validate
fail :log_error
step :create
# ...
end
Obviously, you may use as many tasks as you need on both tracks. There are no limitations.
Historically, the success path is called “right” whereas the error handling track is “left”. The signals Right
and Left
in Trailblazer are still named following this convention.
All wiring features apply to Railway
. You can rewire, add or remove connections as you please.
class Create < Trailblazer::Activity::Railway
step :validate
fail :log_error
step :create, Output(:failure) => End(:db_error)
# ...
end
Railway automatically connects a task’s success
output to the next possible task available on the success track. Vice-verse, the failure
output is connected the the new possible task on the failure path.
Here, create
’s failure output is reconnected.
DSL’s #fail
method allows to place tasks on the failure track.
Such error handlers are still wrapped using Binary
. In other words, they can still return a Right
or Left
signal. However, per default, both outputs are connected to the next task on the failure track.
You may rewire or add outputs on failure tasks, too.
class Create < Trailblazer::Activity::Railway
step :validate
fail :log_error, Output(:success) => Track(:success)
step :create
# ...
end
For instance, it’s possible to jump back to the success path if log_error
decides to do so.
The return value of log_error
now does matter.
class Create < Trailblazer::Activity::Railway
# ...
def log_error(_ctx, logger:, params:, **)
logger.error("wrong params: #{params.inspect}")
fixable?(params) ? true : false # or Activity::Right : Activity::Left
end
end
If the return value of a “right” task shouldn’t matter, use #pass
.
class Create < Trailblazer::Activity::Railway
step :validate
fail :log_error
pass :create
# ...
end
Regardless of create
’s return value, it will always flow to the next success task.
Both outputs are connected to the following task on the success path (or, in this case, the success end).
trailblazer-activity-dsl-linear 1.2.2
To avoid syntax problems with several editors, #fail
is also aliased as #left
.
class Create < Trailblazer::Activity::Railway
step :validate
left :log_error
# ...
end
The signature and accepted arguments are identical to #fail
.
FIXME
- Using
Railway
, tasks always get two outputs assigned::success/Right
and:failure/Left
.
FastTrack
Based on the Railway
strategy, the FastTrack
pattern allows to “short-circuit” tasks and leave the circuit at specified events.
The infamous Trailblazer::Operation
is a thin public API around Activity::FastTrack
.
The :pass_fast
option wires the :success
output straight to the new pass_fast
end.
class Create < Trailblazer::Activity::FastTrack
step :validate, pass_fast: true
fail :log_error
step :create
# ...
end
If validate
returns a true value, it will skip the remaining tasks on the success track and end in End.pass_fast
.
Note that in the example, the create
task not accessable anymore.
The counter-part for :pass_fast
is :fail_fast
.
class Create < Trailblazer::Activity::FastTrack
step :validate, fail_fast: true
fail :log_error
step :create
# ...
end
A falsey return value from #validate
will deviate the flow and go straight to End.fail_fast
.
Again, this specific example renders the log_errors
task unreachable.
It’s possible to wire a task to the two FastTrack ends End.fail_fast
and End.pass_fast
in addition to the normal Railway wiring.
class Create < Trailblazer::Activity::FastTrack
step :validate, fast_track: true
fail :log_error
step :create
def validate(ctx, params:, **)
begin
ctx[:input] = Form.validate(params) # true/false
rescue
return Trailblazer::Activity::FastTrack::FailFast # signal
end
ctx[:input] # true/false
end
# ...
end
The validate
task now has four outputs. You can instruct the two new FastTrack outputs by returning either Trailblazer::Activity::FastTrack::FailFast
or Trailblazer::Activity::FastTrack::PassFast
(see also [returning signals]).
Note that you don’t have to use both outputs.
The standard FastTrack setup allows you to communicate and model up to four states from one task.
FIXME
- All options (
:pass_fast
,:fail_fast
and:fast_track
) may be used withstep
,pass
orfail
. If in doubt, [render the circuit]. :pass_fast
and:fail_fast
can be used in combination.
Wiring API
You can use the wiring API to model more complicated flows in activities.
The wiring API is implemented in the [trailblazer-activity-dsl-linear
gem].
Feel invited to write your own DSL using our [low-level mechanics], or if your activities get too complex, please use the [visual editor].
In addition to your friends step
, pass
and fail
, the DSL provides helpers to fine-tune your wiring.
class Execute < Trailblazer::Activity::Railway
step :find_provider
step :charge_creditcard
end
By default, and without additional helpers used, the DSL will connect every step
task’s two outputs to the two respective tracks of a “railway”.
Output()
The Output()
method helps to rewire one or more specific outputs of a task, or to add outputs.
To understand this helper, you should understand that every step
invocation calls Output()
for you behind the scenes. The following DSL use is identical to the one [above].
class Execute < Trailblazer::Activity::Railway
step :find_provider,
Output(Trailblazer::Activity::Left, :failure) => Track(:failure),
Output(Trailblazer::Activity::Right, :success) => Track(:success)
step :charge_creditcard
end
We’re adding two outputs here, provide the signal as the first and the semantic as the second parameter to Output()
and then connect them to a track.
Trailblazer has two outputs predefined. As you might’ve guessed, the :failure
and :success
outputs are a convention. This allows to omit the signal when referencing an existing output.
class Execute < Trailblazer::Activity::Railway
step :find_provider, Output(:failure) => Track(:failure)
step :charge_creditcard
end
As the DSL knows the :failure
output, it will reconnect it accordingly while keeping the signal.
When specifying a new semantic to Output()
, you are adding an output to the task. This is why you must also pass a signal as the first argument.
Since a particular output is triggered by a particular signal, note that each output must be configured with a unique signal per activity.
class Execute < Trailblazer::Activity::Railway
UsePaypal = Class.new(Trailblazer::Activity::Signal)
step :find_provider, Output(UsePaypal, :paypal) => Track(:paypal)
step :charge_creditcard
end
The find_provider
task now has three possible outcomes that can be triggered by returning either Right
, Left
, or UsePaypal
.
End()
Use End()
to connect outputs to an existing end, or create a new end.
You may reference existing ends by their semantic.
class Execute < Trailblazer::Activity::Railway
step :find_provider
step :charge_creditcard, Output(:failure) => End(:success)
end
This reconnects both outputs to the same end, always ending in a - desirable, yet unrealistic - successful state.
Providing a new semantic to the End()
function will create a new end event.
class Execute < Trailblazer::Activity::Railway
step :find_provider
step :charge_creditcard, Output(:failure) => End(:declined)
end
Adding ends to an activity is a beautiful way to communicate more than two outcomes to the outer world without having to use a state field in the ctx
. It also allows wiring those outcomes to different tracks in the container activity. [See nesting]
This activity now maintains three end events. The path to the declined
end is taken from the task’s failure
output.
Successive uses of the same End(:semantic)
will all connect to the same end.
Id()
An output can be connected to a particular task by using Id()
.
class Execute < Trailblazer::Activity::Railway
step :find_provider
step :charge_creditcard, Output(:failure) => Id(:find_provider)
end
This connects the failure
output to the previous task, which might create an infinity loop and waste your computing time - it is solely here for demonstrational purposes.
Track()
The Track()
function will snap the output to the next task that is “magnetic to” the track’s semantic.
class Execute < Trailblazer::Activity::Railway
step :find_provider, Output(:success) => Track(:failure)
step :charge_creditcard
fail :notify
end
Since notify
sits on the “failure” track and hence is “magnetic to” :failure
, find_provider
will be connected to it.
Using Track()
with a new track semantic only makes sense when using the [:magnetic_to
option] on other tasks.
Use [Path()] if you want to avoid Track()
and :magnetic_to
- this helper does nothing but providing those values to your convenience.
Terminus
trailblazer-activity-dsl-linear 1.0.0
In addition to the strategy’s termini, you can add your own end events using #terminus
. This is an important design tool helping you to communicate outcomes other than “success” or “failure” to the outer world (in a Railway
activity).
module Payment::Operation
class Create < Trailblazer::Activity::Railway
step :find_provider
terminus :provider_invalid # , id: "End.provider_invalid", magnetic_to: :provider_invalid
# ...
end
end
The above code adds a new terminus named End.provider_invalid
to the activity.
As visible, this terminus is not connected to anything as its magnetic_to
property is set to :provider_invalid
.
You could now connect find_provider
’s failure output to the new terminus by using the Track()
helper.
module Payment::Operation
class Create < Trailblazer::Activity::Railway
step :find_provider,
# connect {failure} to the next element that is magnetic_to {:provider_invalid}.
Output(:failure) => Track(:provider_invalid)
terminus :provider_invalid
# ...
end
end
The failure output will be connected to the next following element that is magnetic_to :provider_invalid
, which is the new terminus we created.
Invoking this activity with an unsolicited provider will stop on the newly added terminus.
signal, (ctx, _) = Payment::Operation::Create.(provider: "bla-unknown")
puts signal.to_h[:semantic] #=> :provider_invalid
The default semantic is :provider_invalid
. Note that the following options :id
and :magnetic_to
can be passed to #terminus
:
:id
:magnetic_to
:task
which has to be a subclass ofTrailblazer::Activity::End
.
Path()
trailblazer-activity-dsl-linear 1.2.0
For branching out a separate path in an activity, use the Path()
macro. It’s a convenient, simple way to declare alternative routes, even if you could do everything it does manually.
module Song::Activity
class Charge < Trailblazer::Activity::Railway
# ...
step :validate
step :decide_type, Output(:failure) => Path(terminus: :with_cc) do
step :authorize
step :charge
end
step :direct_debit
end
end
module Song::Operation
class Charge < Trailblazer::Operation
# ...
step :validate
step :decide_type, Output(:failure) => Path(terminus: :with_cc) do
step :authorize
step :charge
end
step :direct_debit
end
end
If #decide_type
returns false, the path will be executed and terminate on the new terminus End.with_cc
because of the :terminus
option.
Note that you don’t necessarily have to reuse the :failure
output in order to branch out a new path. You might as well use an additional signal, or any other predefined output of the task.
If you want the path to reconnect and join the activity at some point, use the :connect_to
option.
module Song::Activity
class Charge < Trailblazer::Activity::Railway
# ...
step :validate
step :decide_type, Output(:failure) => Path(connect_to: Id(:finalize)) do
step :authorize
step :charge
end
step :direct_debit
step :finalize
end
end
module Song::Operation
class Charge < Trailblazer::Operation
# ...
step :validate
step :decide_type, Output(:failure) => Path(connect_to: Id(:finalize)) do
step :authorize
step :charge
end
step :direct_debit
step :finalize
end
end
There won’t be an additional terminus created.
You can use Path()
in any Trailblazer strategy, for example in Railway
.
module Song::Activity
class Charge < Trailblazer::Activity::Railway
MySignal = Class.new(Trailblazer::Activity::Signal)
# ...
step :validate
step :decide_type, Output(MySignal, :credit_card) => Path(connect_to: Id(:finalize)) do
step :authorize
step :charge
end
step :direct_debit
step :finalize
end
end
module Song::Operation
class Charge < Trailblazer::Operation
MySignal = Class.new(Trailblazer::Operation::Signal)
# ...
step :validate
step :decide_type, Output(MySignal, :credit_card) => Path(connect_to: Id(:finalize)) do
step :authorize
step :charge
end
step :direct_debit
step :finalize
end
end
In this example, we add a third output to #decide_type
to handle the credit card payment scenario (you could also “override” or re-configure the existing :failure
or :success
outputs).
Only when decide_type
returns MySignal
, the new path alternative is taken.
def decide_type(ctx, model:, **)
if model.is_a?(CreditCard)
return MySignal # go the Path() way!
elsif model.is_a?(DebitCard)
return true
else
return false
end
end
Output()
in combination with Path()
allows for a simple modeling of alternative routes.
In older versions before trailblazer-activity-dsl-linear-1.2.0
, connecting the Path()
to a separate terminus required you to pass two options :end_task
and :end_id
.
Output(...) => Path(end_task: Activity::End.new(semantic: :valid), end_id: "End.valid") do
# ...
end
This is now simplified (and more consistent) by introducing the :terminus
option.
Output(...) => Path(terminus: :valid) do
# ...
end
If you haven’t updated your code you will see a deprecation warning.
[Trailblazer] <file.rb> Using `:end_task` and `:end_id` in Path() is deprecated, use `:terminus` instead.
Please refer to https://trailblazer.to/2.1/docs/activity.html#activity-wiring-api-path-end_task-end_id-deprecation
Subprocess
While you could nest an activity into another manually, the Subprocess
macro will come in handy.
Consider the following nested activity.
class Memo::Validate < Trailblazer::Activity::Railway
step :check_params
step :check_attributes
# ...
end
Use Subprocess
to nest it into the Create
activity.
class Memo::Create < Trailblazer::Activity::Railway
step :create_model
step Subprocess(Memo::Validate)
step :save
# ...
# ...
The macro automatically wires all of Validate
’s ends to the known counter-part tracks.
The Subprocess
macro will go through all outputs of the nested activity, query their semantics and search for tracks with the same semantic.
Note that the failure track starting from create_model
will skip the nested activity, just as if it was simple task.
You can use the familiar DSL to reconnect ends.
class Memo::Create < Trailblazer::Activity::Railway
step :create_model
step Subprocess(Memo::Validate), Output(:failure) => Track(:success)
step :save
# ...
end
The nested’s failure
output now goes to the outer success
track.
In this example, regardless of nested’s outcome, it will always be interpreted as a successful invocation.
A nested activity doesn’t have to have two ends, only.
class Memo::Validate < Trailblazer::Activity::Railway
step :check_params, Output(:failure) => End(:invalid_params)
step :check_attributes
# ...
end
Subprocess
will try to match the nested ends’ semantics to the tracks it knows. You may wire custom ends using Output
.
class Memo::Create < Trailblazer::Activity::Railway
step :create_model
step Subprocess(Memo::Validate), Output(:invalid_params) => Track(:failure)
step :save
# ...
end
The new special end is now wired to the failure
track of the containing activity.
There will be an exception thrown if you don’t connect unknown ends.
DSL Options
#step
and friends accept a bunch of options in order to insert a task at a specific location, add pre-defined connections and outputs, or even configure its taskWrap.
magnetic_to
In combination with [Track()
], the :magnetic_to
option allows for a neat way to spawn custom tracks outside of the conventional Railway or FastTrack schema.
class Execute < Trailblazer::Activity::Railway
step :find_provider, Output(:failure) => Track(:paypal)
step :charge_creditcard
step :charge_paypal, magnetic_to: :paypal
end
The failure
output of the find_provider
task will now snap to the next task being :magnetic_to
its semantic - which obviously is the charge_paypal
task.
When creating a new branch (or path) in this way, it’s a matter of repeating the use of Track()
and :magnetic_to
to add more tasks to the branch.
extensions
Inherit
Sequence Options
In addition to wiring options, there are a handful of other options known as sequence options. They configure where a task goes when inserted, and helps with introspection and tracing.
The DSL will provide default names for tasks.
You can name explicitely using the :id
option.
class Memo::Create < Trailblazer::Activity::Path
step :create_model
step :validate
step :save, id: :save_the_world
# ...
end
The IDs are as follows.
Trailblazer::Developer.railway(Memo::Create)
#=> [>create_model,>validate,>save_the_world]
This is advisable when planning to override a step via a module or inheritance or when reconnecting it. Naming also shows up in tracing and introspection. Defaults names are given to steps without the :id
options, but these might be awkward sometimes.
When it’s necessary to remove a task, you can use :delete
.
class Memo::Create::Admin < Memo::Create
step nil, delete: :validate
end
The :delete
option can be helpful when using modules or inheritance to build concrete operations from base operations. In this example, a very poor one, the validate
task gets removed, assuming the Admin
won’t need a validation.
Trailblazer::Developer.railway(Memo::Create::Admin)
#=> [>create_model,>save_the_world]
All steps are inherited, then the deletion is applied, as the introspection shows.
To insert a new task before an existing one, for example in a subclass, use :before
.
class Memo::Create::Authorized < Memo::Create
step :policy, before: :create_model
# ...
end
The circuit now yields a new policy
step before the inherited tasks.
Trailblazer::Developer.railway(Memo::Create::Authorized)
#=> [>policy,>create_model,>validate,>save_the_world]
To insert after an existing task, you might have guessed it, use the :after
option with the exact same semantics as :before
.
class Memo::Create::Logging < Memo::Create
step :logger, after: :validate
# ...
end
The task is inserted after, as the introspection shows.
Trailblazer::Developer.railway(Memo::Create::Logging)
#=> [>create_model,>validate,>logger,>save_the_world]
Replacing an existing task is done using :replace
.
class Memo::Update < Memo::Create
step :find_model, replace: :create_model, id: :update_memo
# ...
end
Replacing, obviously, only replaces in the applied class, not in the superclass.
Trailblazer::Developer.railway(Memo::Update)
#=> [>update_memo,>validate,>save_the_world]
Patching
Working with Subprocess
and nested activities is a great way to encapsulate and create reusable code especially with complex logic. However, it can be a PITA if you want to customize one of those deeply nested components and add, replace or remove a certain step.
Suppose the following 3-level nested activity.
The public operation Destroy
contains Delete
as a nested activity, which itself contains DeleteAssets
. In order to customize the latter one and add another step tidy_storage
, you’d normally have to subclass all three activities and override steps.
Using #patch
, you can modify nested activities from the uppermost activity and let Trailblazer do the legwork.
As #patch
is mostly used when leveraging inheritance we introduce a subclass of Destroy
which is called Erase
and introduces the #tidy_storage
step. As illustrated above, this new step should be inserted in DeleteAssets
activity that itself is nested in Delete
, which again is one step of Destroy
.
class Erase < Destroy # we're inheriting from Song::Operation::Destroy
# ...
def self.tidy_storage(ctx, **)
# delete files from your amazing cloud
end
# ...
# These steps are inherited:
# step :policy
# step :find_model
# step Subprocess(Delete), id: :delete
extend Trailblazer::Activity::DSL::Linear::Patch::DSL
# Note the path you pass to #patch.
patch(:delete, :delete_assets) {
step Erase.method(:tidy_storage), after: :rm_images
}
end
The patching logic accepts a path to the particular activity that you want to modify.
patch(:delete, :delete_assets) { ... }
The provided block is executed within that targeted activity and executed as if you’d extend that class. However, the entire modification will only change Erase
, all other traversed activities are copied and then modified, leaving the original implemenation unchanged.
The #patch
method is perfect when using inheritance to first copy over behavior and control flow, and then fine-tune it for the specific use case.
If you’re not using inheritance and want to tweak a nested activity ad-hoc the Subprocess()
helper accepts a :patch
option.
class Destroy < Trailblazer::Activity::Railway
def self.tidy_storage(ctx, **)
# delete files from your amazing cloud
true
end
# ...
step :policy
step :find_model
step Subprocess(Delete,
patch: {
[:delete_assets] => -> { step Destroy.method(:tidy_storage), before: :rm_uploads }
}
)
end
class Destroy < Trailblazer::Operation
def self.tidy_storage(ctx, **)
# delete files from your amazing cloud
true
end
# ...
step :policy
step :find_model
step Subprocess(Delete,
patch: {
[:delete_assets] => -> { step Destroy.method(:tidy_storage), before: :rm_uploads }
}
)
end
This works just like the #patch
function but returns the patched activity.
Subprocess()
accepts the :patch
option which consists of a hash of the path to the customized activity, and its patch.
This will result in an identical operation as in the above example with #patch
. However, Delete
is now the operation containing the customization, not a new class Erase
.
Patching can be also done at the top-level activity by passing :patch
as a block (Take Delete
from above example).
step Subprocess(
Delete,
patch: -> { step Destroy.method(:tidy_storage), before: :delete_model }
), id: :delete
Variable Mapping
trailblazer-activity-dsl-linear 1.2.0
Since TRB 2.1 it is possible to define the input and output variables for each step. This is called variable mapping, or I/O in short. It provides an interface to define what variable go in and come out of a task, enabling you to limit what steps “see” and what “output” they can add to the context.
It’s one of the most frequently used features in Trailblazer.
Overview
Imagine a complex application where policies are protecting your operation code from unsolicited access. This code component - the policy - sits as a step in every business operation and decides whether or not the current user is permitted to execute this very operation.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create # an imaginary policy step.
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create # an imaginary policy step.
# ...
end
end
The Policy::Create
implementation is a simple callable class following the step interface.
module Policy
# Explicit policy, not ideal as it results in a lot of code.
class Create
def self.call(ctx, model:, user:, **)
decision = ApplicationPolicy.can?(model, user, :create) # FIXME: how does pundit/cancan do this exactly?
# ...
end
end
end
Note that it requires two variables :model
and :user
from the ctx. For whatever reasons, the author of this class dictated that the “current user” must be passed named :user
, not, as it’s a convention in Trailblazer, named :current_user
.
Last, depending on the policy decision, the step code returns true or false.
When executing the Create
operation using the :current_user
variable, an ArgumentError
is raised.
result = Trailblazer::Activity.(Song::Activity::Create, current_user: Module)
#=> ArgumentError: missing keyword: :user
result = Song::Operation::Create.(current_user: Module)
#=> ArgumentError: missing keyword: :user
Since the “current user” is handed into the operation as the :current_user
variable, and no other step preceding Policy::Create
is setting this variable, the step expecting :user
crashes.
And this is why we need variable mapping in Trailblazer.
Composable I/o
Variable mapping (short: i/o) can be done manually, with ugly “helper” steps before or after the respective step, or by using In()
, Out()
and Inject()
. Before these helpers got introduced, we used the :input
and :output
option - both works, the latter one coming with several drawbacks.
Helpers can be used multiple times, depending on how complex the incoming or outcoming variables are, forming a pipeline of filters around the actual task.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => [:message]
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => [:message]
# ...
end
end
Not only are those input and output pipelines easy to debug, they also allow to be altered in derived operations, when using inheritance, and work in combination with macros.
In()
In()
allows to configure variables going into the step. The helper accepts either a mapping hash, a limiting array or a callable object (often a lambda), to compute variables at runtime.
Be wary that once you use In()
your are white-listing: only the variables defined in your filters will be passed into the step. All other variables from ctx are invisible in the step.
Picking up the example from above, here’s how a mapping hash “translates” the selected variables from the original ctx object to a new ctx, one that is compatible with Policy::Create
’s interface.
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {
:current_user => :user, # rename {:current_user} to {:user}
:model => :model # add {:model} to the inner ctx.
}
# ...
end
The In()
filter will result in :current_user
being renamed to :user
. Since the policy step also needs :model
we need to mention this variable as well, no renaming happening here. The beauty of I/O: this is only visible to Policy::Create
!
To instantly see what new ctx is passed into the configured step, you could replace the original policy step with a #show_ctx
method.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step :show_ctx,
In() => {
:current_user => :user, # rename {:current_user} to {:user}
:model => :model # add {:model} to the inner ctx.
}
def show_ctx(ctx, **)
p ctx.to_h
#=> {:user=>#<User email:...>, :model=>#<Song name=nil>}
end
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step :show_ctx,
In() => {
:current_user => :user, # rename {:current_user} to {:user}
:model => :model # add {:model} to the inner ctx.
}
def show_ctx(ctx, **)
p ctx.to_h
#=> {:user=>#<User email:...>, :model=>#<Song name=nil>}
end
# ...
end
end
You should use the mapping hash when variables need to be renamed. If variables need to be added without renaming, a limiting array is your friend.
In()
accepts an array, listed variables are passed into the new ctx (whether they exist in the original ctx or not!).
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model]
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model]
# ...
end
end
This configuration will lead to the exact same new ctx for Policy::Create
as in the example above, producing a new ctx that will look as below.
#=> {
# :user => #<User email:...>,
# :model => #<Song name=nil>}
# }
As always, you may implement your own input filter with any callable object [adhering to the step interface])(#activity-internals-step-interface).
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => ->(ctx, **) do
# only rename {:current_user} if it's there.
ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
end,
In() => [:model]
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => ->(ctx, **) do
# only rename {:current_user} if it's there.
ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
end,
In() => [:model]
# ...
end
end
Callable In()
filters have to return a hash. This hash will be merged with the other In()
filters and comprise the new ctx.
And again, when the operation is invoked with a :current_user
, this will, result in the same new ctx as above.
#=> {
# :user => #<User email:...>,
# :model => #<Song name=nil>}
# }
However, if :current_user
is nil
, Policy::Create
will raise an exception complaining about the :user
keyword missing.
Following the TRB option standard, an In()
filter may even be implemented as an instance method. All you need to do is pass a symbol to In()
.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => :input_for_policy, # You can use an {:instance_method}!
In() => [:model]
def input_for_policy(ctx, **)
# only rename {:current_user} if it's there.
ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
end
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => :input_for_policy, # You can use an {:instance_method}!
In() => [:model]
def input_for_policy(ctx, **)
# only rename {:current_user} if it's there.
ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
end
# ...
end
end
The method needs to expose a step interface just like any other callable.
Both callables and filter methods for In()
can receive ctx variables as keyword arguments, making it a convenient access and have Ruby perform a loose existance test automatically.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
# vvvvvvvvvvvv keyword arguments rock!
In() => ->(ctx, current_user: nil, **) do
current_user.nil? ? {} : {user: current_user}
end,
In() => [:model]
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
# vvvvvvvvvvvv keyword arguments rock!
In() => ->(ctx, current_user: nil, **) do
current_user.nil? ? {} : {user: current_user}
end,
In() => [:model]
# ...
end
end
Keep in mind that when not defaulting the keyword argument your filter might crash at runtime when the expected variables were not passed.
Out()
Without any output configuration on the exemplary policy step, any variable written to ctx
will be automatically set on the outer ctx, passing along internal variabes to the following step.
Here, both :status
and :message
variables that were written in Policy::Create
are passed into the outer ctx. The behavior is identical to the way before you were using i/o.
However, it is often necessary to rename or limit the outgoing variables of a particular step. Especially when using nested operations you probably don’t want the entire nested ctx to be copied into the outer context. This is where output filters enter the stage.
Consider the following updated Policy::Create
step.
module Policy
# Explicit policy, not ideal as it results in a lot of code.
class Create
def self.call(ctx, model:, user:, **)
decision = ApplicationPolicy.can?(model, user, :create) # FIXME: how does pundit/cancan do this exactly?
if decision.allowed?
return true
else
ctx[:status] = 422 # we're not interested in this field.
ctx[:message] = "Command {create} not allowed!"
return false
end
end
end
end
Both ctx[:status]
and ctx[:message]
will be visible in all steps following Policy::Create
. This might lead to “misunderstandings” and bugs in more complex applications.
As soon as you use Out()
, only variables specified through the filters will be merged with the original (outer) ctx and passed on to the next step.
In order to limit variables added to the outer ctx, Out()
accepts an array similar to In()
. Consider this as a whitelisting to specify exposed variables.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => [:message]
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => [:message]
# ...
end
end
This single Out()
usage will result in only the :message
variable being written to the outer ctx that is passed on. The :status
variable is discarded.
You may pass any number of variables in the limiting array.
Renaming variables from the inner to the outer ctx works by providing a mapping hash, where the “old” inner name points to the outer name that you want to use in the operation hosting that step.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => {:message => :message_from_policy}
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => {:message => :message_from_policy}
# ...
end
end
Here, steps following Policy::Create
will see a variable :message_from_policy
merged into the ctx - which is the original :message
, renamed.
An Out()
filter can be any callable object following the step interface.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => ->(ctx, **) do
return {} unless ctx[:message]
{ # you always have to return a hash from a callable!
:message_from_policy => ctx[:message]
}
end
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => ->(ctx, **) do
return {} unless ctx[:message]
{ # you always have to return a hash from a callable!
:message_from_policy => ctx[:message]
}
end
# ...
end
end
The callable receives the inner ctx that just left the actual step, here Policy::Create
. You may run any Ruby code in the callable, even if
s.
Note that a callable always must return a hash, which is then merged with the original outer ctx.
Be adviced that it is usually a better idea to maintain multiple smaller Out()
callables for different variables. You might later decide to override them, debugging will be easier and the code is more maintainable. This was different when :output
was the only way to filter outgoing variables and you had to create one big hash in a one single filter.
You may also use an :instance_method
to filter outgoing variables, similar to how it’s done with In().
Just as with In()
callables can receive keyword arguments.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => ->(ctx, message: nil, **) do
return {} if message.nil?
{ # you always have to return a hash from a callable!
:message_from_policy => message
}
end
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => ->(ctx, message: nil, **) do
return {} if message.nil?
{ # you always have to return a hash from a callable!
:message_from_policy => message
}
end
# ...
end
end
Any variable readable on the inner ctx
that just left Policy::Create
is available as a keyword argument for a callable. Note that you need to default it if its presence is not guaranteed.
You can access the outer, original ctx by passing the :with_outer_ctx
option to Out()
.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => [:message],
Out(with_outer_ctx: true) => ->(inner_ctx, outer_ctx:, **) do
{
errors: outer_ctx[:errors].merge(policy_message: inner_ctx[:message])
}
end
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => [:message],
Out(with_outer_ctx: true) => ->(inner_ctx, outer_ctx:, **) do
{
errors: outer_ctx[:errors].merge(policy_message: inner_ctx[:message])
}
end
# ...
end
end
While the callable still needs to return a hash that is then merged with the original ctx, it’s possible to access variables from the outer ctx through the :outer_ctx
keyword argument. This allows for merging deeper data structures, such as error objects.
Inject()
An Inject()
filter, as opposed to In()
, does an existance check on the ctx using ctx.key?(:variable)
before performing its logic. It is helpful in combination with In()
filters, when using defaulted keyword arguments in a step or in nested operations.
- It allows defaulting a variable when it’s absent in the ctx.
- It can pass-through a variable when it is present in the ctx, and only then.
- You can also statically set a variable, whether or not it is present using the
:override
option.
Note that Inject()
can be used without In()
.
Check the following exemplary policy code.
module Policy
class Check
# vvvvvvvvvvvvvvv-- defaulted keyword arguments
def self.call(ctx, model:, user:, action: :create, **)
decision = ApplicationPolicy.can?(model, user, action) # FIXME: how does pundit/cancan do this exactly?
# ...
end
end
end
This policy implementation uses keyword arguments to automatically extract :model
, :user
and :action
from the ctx. Note that the latter is defaulted to :create
. Defaulting kwargs only works when the keyword variable is not passed into the step - if it’s nil
, the defaulting will not get triggered.
You could now use In()
filters to embed this policy step into your operation.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Check,
In() => {:current_user => :user},
In() => [:model, :action]
# ...
end
end
However, this will break because the action
variable will never be defaulted to :create
. The In()
filter will always pass :action
through when calling the policy, even when it’s absent.
The Inject()
helper is designed to handle this case.
Use Inject()
in combination with In()
to add variables to the filtered ctx, but only when they’re present in the outer ctx.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Check,
In() => {:current_user => :user},
In() => [:model],
Inject() => [:action]
# ...
end
end
We call this qualified pass-through, it means the :action
variable will only be passed into the filtered ctx if it exists on ctx
when the filter is invoked.
Instead of hard-wiring defaulted keyword arguments into your step implementations, you can configure Inject()
to set a default value to variables, if they’re absent in the ctx.
Here’s an example policy without any defaulting in the signature.
module Policy
class Check
# vvvvvvv-- no defaulting!
def self.call(ctx, model:, user:, action:, **)
decision = ApplicationPolicy.can?(model, user, action) # FIXME: how does pundit/cancan do this exactly?
# ...
end
end
end
Defaulting the :action
variable via Inject()
will improve the policy component’s reusability.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Check,
In() => {:current_user => :user},
In() => [:model],
Inject(:action) => ->(ctx, **) { :create }
# ...
end
end
The lambda is executed at runtime, just before the actual step is invoked. It provides access to the ctx object and allows extracting keyword arguments.
Use the :override
option to always set a variable, even if it is already present in the incoming ctx
.
Inject(:action, override: true) => ->(*) { :create } # always used.
This is helpful to set configuration variables for an activity while still using the well-established keyword argument mechanics. The Policy::Create
class defines :action
as a kwarg. This doesn’t necessarily mean the user can always inject and dictate the very value. Instead, we can override any injected value with the “hard-coded” :create
value.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Check,
In() => {:current_user => :user},
In() => [:model],
Inject(:action, override: true) => ->(*) { :create } # always used.
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :create_model
step Policy::Check,
In() => {:current_user => :user},
In() => [:model],
Inject(:action, override: true) => ->(*) { :create } # always used.
# ...
end
end
The call as below will always use action: :create
, even if something else is injected.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Create,
current_user: current_user,
action: :update # this is always overridden.
)
puts ctx[:model] #=> #<Song id: 1, ...>
result = Song::Operation::Create.(
current_user: current_user,
action: :update # this is always overridden.
)
puts result[:model] #=> #<Song id: 1, ...>
Macro
As all DSL options the In()
, Out()
and Inject()
helpers can be used from macros, providing the macro author a convenient way to define default filters. API docs
module Policy
def self.Create()
{
task: Policy::Create,
wrap_task: true,
Trailblazer::Activity::Railway.In() => {:current_user => :user},
Trailblazer::Activity::Railway.In() => [:model],
Trailblazer::Activity::Railway.Out() => {:message => :message_from_policy},
}
end
end
In the options hash that a macro must return, you can use the helpers by referencing Trailblazer::Activity::Railway
. Except for the prefixed constant, there is no difference or limitation to their usage.
They can be extended with options the macro user provides.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create(),
Out() => {:message => :copied_message} # user options!
# ...
end
end
The user options will be merged into the macro options, resulting in :message
being renamed to :message_from_policy
and copied to :copied_message
.
Before trailblazer-activity-dsl-linear-1.0.0
and the In()
and Out()
helper shipped with it, any :input
from the user would always override the macro’s :input
option.
Inheritance
Subclasses can add and remove input and output filters - hence the term composable. This is a great tool when inherited operations replace particular steps and need to fine-tune ingoing or returned variables.
Consider the following base operation.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step Policy::Create,
In() => {:current_user => :user},
In() => [:model],
Out() => [:message],
id: :policy
# ...
end
end
It defines two input and one output filter.
A sub operation could now replace the policy step. However, instead of redefining the i/o filters, they can be inherited and extended.
Here’s a potential inheriting operation.
module Song::Activity
class Admin < Create
step Policy::Create,
Out() => {:message => :raw_message_for_admin},
inherit: [:variable_mapping],
id: :policy, # you need to reference the :id when your step
replace: :policy
end
end
This configuration is adding another Out()
filter, resulting in a total filter setup as follows in the introspection.
puts Trailblazer::Developer::Render::TaskWrap.(Song::Activity::Admin, id: :policy)
Song::Activity::Admin
# `-- policy
# |-- task_wrap.input..................Trailblazer::Activity::DSL::Linear::VariableMapping::Pipe::Input
# | |-- input.init_hash.............................. ............................................. VariableMapping.initial_aggregate
# | |-- input.add_variables.0.994[...]............... {:current_user=>:user}....................... VariableMapping::AddVariables
# | |-- input.add_variables.0.592[...]............... [:model]..................................... VariableMapping::AddVariables
# | `-- input.scope.................................. ............................................. VariableMapping.scope
# |-- task_wrap.call_task..............Method
# `-- task_wrap.output.................Trailblazer::Activity::DSL::Linear::VariableMapping::Pipe::Output
# |-- output.init_hash............................. ............................................. VariableMapping.initial_aggregate
# |-- output.add_variables.0.599[...].............. [:message]................................... VariableMapping::AddVariables::Output
# |-- output.add_variables.0.710[...].............. {:message=>:raw_message_for_admin}........... VariableMapping::AddVariables::Output
# `-- output.merge_with_original................... ............................................. VariableMapping.merge_with_original
The new Out()
filter setting :raw_message_for_admin
is placed behind the inherited filter.
Note that inherit: true
will, besides other options, also invoke the variable mapping inheritance.
Introspect
You can visualize the pipelines around each step by using the trailblazer-developer
gem.
puts Trailblazer::Developer::Render::TaskWrap.(Song::Activity::Create, id: :policy)
This handy invocation will render the task wrap around Song::Operation::Create
’s step :policy
.
Song::Activity::Create
`-- policy
|-- task_wrap.input..................Trailblazer::Activity::DSL::Linear::VariableMapping::Pipe::Input
| |-- input.init_hash.............................. ............................................. VariableMapping.initial_aggregate
| |-- input.add_variables.0.994[...]............... {:current_user=>:user}....................... VariableMapping::AddVariables
| |-- input.add_variables.0.592[...]............... [:model]..................................... VariableMapping::AddVariables
| `-- input.scope.................................. ............................................. VariableMapping.scope
|-- task_wrap.call_task..............Method
`-- task_wrap.output.................Trailblazer::Activity::DSL::Linear::VariableMapping::Pipe::Output
|-- output.init_hash............................. ............................................. VariableMapping.initial_aggregate
|-- output.add_variables.0.599[...].............. [:message]................................... VariableMapping::AddVariables::Output
`-- output.merge_with_original................... ............................................. VariableMapping.merge_with_original
In i/o context, the interesting branches here are task_wrap.input
and task_wrap.output
. Sandwiched between generic library steps are your filter steps. The visualizer even renders filter configuration where possible.
We’re planning improvements on this part of trailblazer-developer
. If you want to help out with better rendering, please come chat to us.
Input / Output
With trailblazer-2.1.1
and the bundled trailblazer-activity-dsl-linear-1.0.0
gems, the recommended way of I/O is using composable variable mapping via In()
and Out()
.
Before the introduction of the composable In()
, Out()
and Inject()
filters, variable mapping was done with the :input
and :output
option. This is still supported and not planned to be dropped. However, there are a bunch of drawbacks with using the monolithic, non-composable options.
- Once used, the
:input
,:output
and:inject
option will overwrite any options set earlier (or later) viaIn()
,Out()
andInject()
. This will often lead to problems when using macros. - The superseded options are basically impossible to debug, whereas the composable
In()
approach can nicely display the computed set of variables going in or out of a step. - In future versions of Trailblazer we’re planning automatic “contracts” for steps along with type checking. This is not possible with the monolithic
:input
option.
The :input
option accepts any callable following the option interface.
step :create_model,
input: :input_for_create_model
# becomes
In() => :input_for_create_model
:input
works identically to a single In()
call. API docs
The :output
option, just like :input
, accepts any callable following the option interface.
step :create_model,
output: :output_for_create_model
# becomes
Out() => :output_for_create_model
:output
works identically to a single Out()
call. API docs
The :output_with_outer_ctx
option is documented here.
step :create_model,
output: :output_for_create_model,
output_with_outer_ctx: true
# becomes
Out(with_outer_ctx: true) => :output_for_create_model
:inject
works identically to a single Inject()
call. API docs
step :create_model,
inject: :inject_for_create_model
# becomes
Inject() => :inject_for_create_model
Dependency Injection
WIP: This section is not final, yet.
Overview
Very often your activity or one of the steps contained require particular objects and values to get their job done. Instead of hard-wiring those “dependencies” in the code it is good style to allow providing those objects by passing them into the activity at run-time. This is called dependency injection and is a common technique in software engineering.
One way for using dependency injection is using keyword arguments for variables you need, and defaulting those in the step signature.
Mapping
TODO
Dry container
TODO
defaulting in macros
Macro API
Macros are short-cuts for inserting a task along with options into your activity.
Definition
They’re simple functions that return a hash with options described here.
module MyMacro
def self.NormalizeParams(name: :myparams, merge_hash: {})
task = ->((ctx, flow_options), _) do
ctx[name] = ctx[:params].merge(merge_hash)
return Trailblazer::Activity::Right, [ctx, flow_options]
end
# new API
{
task: task,
id: name
}
end
end
Two required options are :id
and :task
, the latter being the actual task you want to insert. The callable task needs to implement the [circuit interface].
Please note that the actual task doesn’t have to be a proc! Use a class, constant, object, as long as it exposes a #call
method it will flow.
Usage
To actually apply the macro you call the function in combination with step
, pass
, fail
, etc.
class Create < Trailblazer::Activity::Railway
step MyMacro::NormalizeParams(merge_hash: {role: "sailor"})
end
There’s no additional logic from Trailblazer happening here. The function returns a well-defined hash which is passed as an argument to step
.
Options
In the returned hash you may insert any valid DSL [step option], such as sequence options like :before
, Output()
and friends from the wiring API or even :extensions
.
The following FindModel
macro retrieves a configured model just like trailblazer-macro
’s Model()
and automatically wires the step’s failure
output to a new terminus not_found
.
module MyMacro
def self.FindModel(model_class)
# the inserted task.
task = ->((ctx, flow_options), _) do
model = model_class.find_by(id: ctx[:params][:id])
return_signal = model ? Trailblazer::Activity::Right : Trailblazer::Activity::Left
ctx[:model] = model
return return_signal, [ctx, flow_options]
end
# the configuration needed by Trailblazer's DSL.
{
task: task,
id: :"find_model_#{model_class}",
Trailblazer::Activity::Railway.Output(:failure) => Trailblazer::Activity::Railway.End(:not_found)
}
end
end
See how you can simply add Output
wirings by using the well-established mechanics from the wiring API? Remember you’re not in an Activity
or Operation
namespace and hence need to use the fully-qualified constant reference Trailblazer::Activity::Railway.Output()
.
To insert that step and its extended wiring, simply call the macro.
class Create < Trailblazer::Activity::Railway
step MyMacro::FindModel(User)
end
When running the activity without a valid model ID, it will now terminate on End.not_found
.
signal, (ctx, _) = Trailblazer::Developer.wtf?(User::Create, [{params: {id: nil}}])
signal #=> #<Trailblazer::Activity::End semantic=:not_found>
`-- User::Create
|-- Start.default
|-- find_model_User
`-- End.not_found
Using the wiring API in your own macros gives you a powerful tool for harnessing extended wiring without requiring the user to know about the details - the crucial point for a good API.
You can even use other macros in custom macros, such as the Subprocess()
helper for nesting activities.
Consider the following Logger
activity.
class Logger < Trailblazer::Activity::Railway
step :log
def log(ctx, logged:, **)
ctx[:log] = logged.inspect
end
end
Along with the nested Logger
step should also go :input
and :output
configuration. When using the Logger
in multiple operation, you would need to repeat the options, so why not pack the entire configuration in a macro?
module Macro
def self.Logger(logged_name: )
{
id: "logger",
input: {logged_name => :logged},
output: [:log],
**Trailblazer::Activity::Railway.Subprocess(Logger), # nest
}
end
end
The nesting activity can now elegantly use the macro without inconvenient options.
class Create < Trailblazer::Activity::Railway
step Macro::Logger(logged_name: :model) # we want to log {ctx[:model]}
end
Internals
This section discusses low-level structures and is intended for engineers interested in changing or adding their own DSLs, the activity build process, or who want to optimize the Trailblazer internals (which is always appreciated!).
Introspection API
To introspect an activity and find out about steps, IDs and configuration, use the Introspect.Nodes()
function.
Consider the following activity.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :validate
step Subprocess(Save),
id: :save
end
end
module Song::Operation
class Create < Trailblazer::Operation
step :validate
step Subprocess(Save),
id: :save
end
end
You can retrieve a datastructure describing a particular task by ID.
attrs = Trailblazer::Activity::Introspect.Nodes(Song::Activity::Create, id: :validate)
The returned Attributes
object exposes #id
, #data
and #task
.
puts attrs.id #=> :validate
puts attrs.task #=> #<Trailblazer::Activity::TaskBuilder::Task user_proc=validate>
puts attrs.data[:extensions] => []
It also accepts a :task
option if you need to find attributes for a step where you only know the code component, not the ID. This is, for example, used in the tracing code.
attrs = Trailblazer::Activity::Introspect.Nodes(Song::Activity::Create, task: Song::Activity::Save)
attrs.id #=> :save
The returned Attributes
instance behaves identically to the above.
Note that you can use Attributes
to query custom data from the DSL.
Circuit Interface
Activities and all tasks (or “steps”) are required to expose a circuit interface. This is the low-level interface. When an activity is executed, all involved tasks are called with that very signature.
Most of the times it is hidden behind the task interface that you’re probably used to from your operations when using step
. Under the hood, however, all callable circuit elements operate through that very interface.
The circuit interface consists of three things.
- A circuit element has to expose a
call
method. - The signature of the
call
method iscall((ctx, flow_options), **circuit_options)
. - Return value of the
call
method is an array of format[signal, [new_ctx, new_flow_options]]
.
Do not fear those syntactical finesses unfamiliar to you, young padawan.
class Create < Trailblazer::Activity::Railway
def self.validate((ctx, flow_options), **_circuit_options)
# ...
return signal, [ctx, flow_options]
end
step task: method(:validate)
end
Both the Create
activity itself and the validate
step expose the circuit interface. Note that the :task
option for step
configures this element as a low-level circuit interface, or in other words, it will skip the wrapping with the task interface.
Maybe it makes more sense now when you see how an activity is called manually? Here’s how to invoke Create
.
ctx = {name: "Face to Face"}
flow_options = {}
signal, (ctx, _flow_options) = Create.([ctx, flow_options])
signal #=> #<Trailblazer::Activity::End semantic=:success>
ctx #=> {:name=>\"Face to Face\", :validate_outcome=>true}
Note that both ctx
and flow_options
can be just anything. Per convention, they respond to a hash interface, but theoretically it’s up to you how your network of activities and tasks communicates.
Check the implementation of validate
to understand how you return a different signal or a changed ctx
.
def self.validate((ctx, flow_options), **_circuit_options)
is_valid = ctx[:name].nil? ? false : true
ctx = ctx.merge(validate_outcome: is_valid) # you can change ctx
signal = is_valid ? Trailblazer::Activity::Right : Trailblazer::Activity::Left
return signal, [ctx, flow_options]
end
Make sure to always stick to the return
signature on the circuit interface level.
The circuit interface is a bit more clumsy but it gives you unlimited power over the way the activity will be run. And trust us, we’ve been playing with different APIs for two years and this was the easiest and fastest outcome.
def self.validate((ctx, flow_options), **_circuit_options)
# ...
return signal, [ctx, flow_options]
end
The alienating signature uses Ruby’s decomposition feature. This only works because the first argument for call
is actually an array.
Using this interface empowers you to fully take control of the flow™.
- You can return any
signal
you want, not only the binary style in steps. Do not forget to wire that signal appropriately to the next task, though. - If needed, the
ctx
object might be mutated or, better, replaced and a new version returned. This is the place where you’d start implementing an immutable version of Trailblazer’sctx
, for instance. - Advanced features like tracing, input/output filters or type checking leverage the framework argument
flow_options
, which will be passed onwards through the entire activities flow. Know what you’re doing when usingflow_options
and always return it even if you’re not changing it. - The
circuit_options
is another framework argument needed to control the start task and more. It is immutable and you don’t have to return it. The samecircuit_options
are guaranteed to be passed to all invoked tasks within one activity.
Since in 99% the circuit_options
are irrelevant for you, it’s nicer and faster to discard them instantly.
def validate((ctx, flow_options), *)
# ...
end
Use the lonely *
squat asterisk to do so.
The last positional argument when call
ing an activity or task is called circuit options. It’s a library-level hash that is guaranteed to be identical for all tasks of an activity. In other words, all tasks of one activity will be called with the same circuit_options
hash.
The following options are available.
You can instruct the activity where to start - it doesn’t have to be the default start event! Use the :start_task
option.
Consider this activity.
class Create < Trailblazer::Activity::Railway
# ...
step :create
step :validate
step :save
end
Inject the :start_task
option via the circuit options. The value has to be the actual callable task object. You can use the [introspection API] to grab it.
circuit_options = {
start_task: Trailblazer::Activity::Introspect::Nodes(B::Create, id: :validate).task
}
signal, (ctx, flow_options) = B::Create.([ctx, flow_options], **circuit_options)
Starting with :validate
, the :create
task will be skipped and only :validate
and then :save
will be executed.
Note that this is a low-level option that should not be used to build “reuseable” activities. If you want different behavior for differing contexts, you should compose different activities.
When using the step :method_name
DSL style, the :exec_context
option controls what object provides the method implementations at runtime.
Usually, Activity#call
will automatically set this, but you can invoke the circuit
instead, and inject your own exec_context
. This allows you to have a separate structure and implementation.
The following activity is such an “empty” structure.
class Create < Trailblazer::Activity::Railway
step :create
step :save
end
You may then use a class, object or module to define the implementation of your steps.
class Create::Implementation
def create(ctx, params:, **)
ctx[:model] = Memo.new(params)
end
def save(ctx, model:, **)
ctx[:model].save
end
end
end
This is really just a container of the desired step logic, with the familiar interface.
When invoking the Create
activity, you need to call the circuit
directly and inject the :exec_context
option.
circuit_options = {
exec_context: C::Create::Implementation.new
}
signal, (ctx, flow_options) = C::Create.to_h[:circuit].([ctx, flow_options], **circuit_options)
While this bypasses Activity#call
, it gives you a powerful tool for advanced activity design.
When using the DSL, use the :task
option if you want your added task to be called directly with the circuit interface. This skips the TaskBuilder::Binary
wrapping.
class Create < Trailblazer::Activity::Railway
# ...
step task: method(:validate)
end
Step Interface
a.k.a. Task interface
The convenient high-level interface for a task implementation is - surprisingly - called task interface. It’s the one you will be working with 95% of your time when writing task logic.
This interface comprises of two parts.
- The signature receives a mutable
ctx
object, and an optional list of keywords, often seen as(ctx, **)
. - The return value can be
true
,false
, or a subclass ofActivity::Signal
to dictate the control flow.
The return value does not control what is the next task. Instead, it informs the circuit runner about its outcome, and the circuit runner will find the task executed next.
module Memo::Operation
class Create < Trailblazer::Activity::Railway
def self.create_model(ctx, **)
attributes = ctx[:attrs] # read from ctx
ctx[:model] = Memo.new(attributes) # write to ctx
ctx[:model].save ? true : false # return value matters
end
step method(:create_model)
# ...
end
end
Components (such as methods or callable objects) exposing the step interface always receive the ctx
as the first (and only) positional argument. Keyword arguments may be used to extract variables from the ctx
.
Depending on the step’s logic, you can write variables to the ctx
object.
The return value can be either a subclass of Trailblazer::Activity::Signal
or it will be evaluated to true
or false
.
A cleaner way to access data from the ctx
object is to use keyword arguments in the method signature. Trailblazer makes all ctx
options available as kw args.
def self.create_model(ctx, attrs:, **) # kw args!
ctx[:model] = Memo.new(attrs) # write to ctx
ctx[:model].save ? true : false # return value matters
end
You may use as many keyword arguments as you need - it will save you reading from ctx
manually, gives you automatic presence checks, and allows defaulting, too.
Using the DSL, your task will usually be wrapped in a TaskBuilder::Binary
object, which translates a nil
and false
return value to an Activity::Left
signal, and all other return values to Activity::Right
.
def self.create_model(ctx, attrs:, **) # kw args!
# ...
ctx[:model].save ? true : false # return value matters
end
In a Railway activity, a true value will usually result in the flow staying on the “success” path, where a falsey return value deviates to the “failure” track. However, eventually it’s the developer’s decision how to wire signals to connections.
You are not limited to true and falsey return values. Any subclass of Activity::Signal
will simply be passed through without getting “translated” by the Binary
wrapper. This allows to emit more than two possible states from a task.
module Memo::Operation
class Create < Trailblazer::Activity::Railway
DatabaseError = Class.new(Trailblazer::Activity::Signal) # subclass Signal
def create_model(ctx, attrs:, **)
ctx[:model] = Memo.new(attrs)
begin
return ctx[:model].save ? true : false # binary return values
rescue
return DatabaseError # third return value
end
end
# ...
step :create_model,
Output(DatabaseError, :handle_error) => Id(:handle_db_error)
step :handle_db_error,
magnetic_to: nil, Output(:success) => Track(:failure)
end
end
The exemplary DatabaseError
is being passed through to the routing and interpreted. It’s your job to make sure this signal is wired to a following task, track, or end (line 16).
Note that you don’t have to use the default binary signals at all (Left
and Right
).
API docs
The most convenient way is to use instance methods. Those may be declared after the step
definitions, allowing you to first define the flow, then implement it.
class Memo::Create < Trailblazer::Activity::Railway
step :authorize
# ...
def authorize(ctx, current_user:, **)
current_user.can?(Memo, :create)
end
end
Use :method_name
to refer to instance methods.
Do not use instance variables (@ivar
) ever as they’re not guaranteed to work as expected. Always transport state via ctx
.
A class method can implement a task of an activity. It needs to be declared as a class method using self.method_name
and must precede the step
declaration. Using Ruby’s #method
, it can be passed to the step
DSL.
class Memo::Create < Trailblazer::Activity::Railway
def self.authorize(ctx, current_user:, **)
current_user.can?(Memo, :create)
end
step method(:authorize)
end
Instead of prefixing every method signature with self.
you could use Ruby’s class << self
block to create class methods.
class Memo::Create < Trailblazer::Activity::Railway
class << self
def authorize(ctx, current_user:, **)
current_user.can?(Memo, :create)
end
# more methods...
end
step method(:authorize)
end
In TRB 2.0, instance methods in operations were the preferred way for implementing tasks. This was a bit more convenient, but required the framework to create an object instance with every single activity invocation. It also encouraged users to transport state via the activity instance itself (instead of the ctx
object), which led to bizarre issues.
Since 2.1, the approach is as stateless and functional as possible, as we now prefer class methods.
As a matter of fact, you can use any callable object. That means, any object that responds to #call
is suitable as a task implementation.
class Memo::Create < Trailblazer::Activity::Railway
# ...
step AuthorizeForCreate
end
When using a class, it needs to expose a class method #call
. This is ideal for more complex task code that needs to be decomposed into smaller private methods internally.
class AuthorizeForCreate
def self.call(ctx, current_user:, **)
current_user.can?(Memo, :create)
end
end
The signature of #call
is identical to the other implementation styles.
Keep in mind that you don’t have to implement every task in the activity itself - it can be outsourced to a module.
module Authorizer
module_function
def memo_create(ctx, current_user:, **)
current_user.can?(Memo, :create)
end
end
When using module_function
, every method will be a “class” method automatically.
In the activity, you can reference the module’s methods using our old friend method
.
class Memo::Create < Trailblazer::Activity::Railway
step Authorizer.method(:memo_create)
# ...
end
TaskWrap
The taskWrap is the “around_filter
” of Trailblazer. It allows adding steps before and after actual tasks without having to change the activity, and without having to introduce if
s.
Some prominent examples for taskWrap usage in the wild are variable mapping and tracing happening in #wtf?
. Those are features completely separated from the core code. You can use the taskWrap mechanics to inject static wraps at compile time (as it’s done for, say, the In()
feature), or at runtime, the way #wtf?
is built.
It’s possible to configure a specific taskWrap for a particular step, or run the same one for all steps encountered while running an activity, even with nesting.
Example
Let’s discuss the taskWrap with the following simple Create
activity that you’ve probably met before.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step :validate
left :handle_errors
step :notify
# ...
end
end
We’re using an imaginary MyAPM
gem that provides a convenient interface for starting and stopping instrumentation.
span = MyAPM.start_span("validate", payload: {time: Time.now})
# do whatever you have to...
span.finish(payload: {time: Time.now})
Step
In order to execute the instrumentation, we have to write two taskWrap steps, one to start, one for finishing the instrumentation. Those steps look similar to “ordinary” activity steps, but expose the taskWrap interface.
module MyAPM # Advanced performance monitoring, done right!
module Extension
def self.start_instrumentation(wrap_ctx, original_args)
(ctx, _flow_options), circuit_options = original_args
activity = circuit_options[:activity] # currently running Activity.
task = wrap_ctx[:task] # the current "step".
task_id = Trailblazer::Activity::Introspect.Nodes(activity, task: task).id
span = MyAPM.start_span("operation.step", payload: {id: task_id})
wrap_ctx[:span] = span
return wrap_ctx, original_args
end
end
end
A taskWrap step receives two positional arguments, the wrap_ctx
that is used to transport taskWrap-specific data across taskWrap steps, and the original_args
that represent how the circuit called the currently invoked task.
Above, you can see how it’s possible to retrieve the currently invoked task
and its activity
.
We then store the span
object in the wrap_ctx
so the finishing code can close that span in the second taskWrap step #finish_instrumentation
.
module MyAPM # Advanced performance monitoring, done right!
module Extension
def self.finish_instrumentation(wrap_ctx, original_args)
ctx = original_args[0][0]
span = wrap_ctx[:span]
span.finish(payload: ctx.inspect)
return wrap_ctx, original_args
end
end
end
As you can see, the span
object can be fetched from wrap_ctx
as the second step is run somewhere after start_instrumentation
.
call_task
Each taskWrap has at least one element, the call_task
step that calls the actual task. Sometimes this task is a method, such as #validate
, sometimes a nested activity, and so on.
Runtime
The taskWrap can be extended at runtime, when call
ing an activity. This allow for adding features to steps without having to change the actual business code.
To actually run the Song::Activity::Create
activity with this new taskWrap, we need to build a runtime extension.
apm_extension = Trailblazer::Activity::TaskWrap::Extension(
[MyAPM::Extension.method(:start_instrumentation), id: "my_apm.start_span", prepend: "task_wrap.call_task"],
[MyAPM::Extension.method(:finish_instrumentation), id: "my_apm.finish_span", append: "task_wrap.call_task"],
)
You can configure where to insert the taskWrap steps using :append
or :prepend
.
We’re now all set to inject that new taskWrap into the execution of our business activity. In the following example, the my_wrap
hash is configured in a way that our extension is run around every task in Create
, even for Create
itself.
my_wrap = Hash.new(apm_extension)
Song::Activity::Create.invoke(
[
# ctx:
{
song: {title: "Timebomb"},
seq: []
}
],
wrap_runtime: my_wrap # runtime taskWrap extensions!
)
This will result in the same taskWrap being applied to every step of the activity execution path, and for #validate
, looks like so.
You don’t have to run a taskWrap extension around every step. It is possible to configure an extension only being applied to a particular step in your activity. Let’s invoke the new APM taskWrap only around #validate
.
validate_task = Trailblazer::Activity::Introspect
.Nodes(Song::Activity::Create, id: :validate) # returns Node::Attributes
.task # and the actually executed task from the circuit.
my_wrap = {validate_task => apm_extension}
Note that my_wrap
can map specific tasks to their taskWrap extension.
When running Create
with the new configuration, the APM code is only called for #validate
.
Song::Activity::Create.invoke(
[
# ctx:
{
song: {title: "Timebomb"},
seq: []
}
],
wrap_runtime: my_wrap # runtime taskWrap extensions!
)
It’s important to understand that even the top activity Song::Activity::Create
is run using a taskWrap, so if you want to apply APM only to this particular “step”, you could do so as follows.
my_wrap = {Song::Activity::Create => apm_extension}
Static
Instead of dynamically adding taskWrap extensions at runtime, in some cases you might want to do so when defining the activity class, at compile-time. For example, the In()
and Out()
filter DSL uses a static taskWrap extension to add steps around a specific task.
This can be achieved with the WrapStatic()
helper.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :create_model
step :validate,
Extension() => Trailblazer::Activity::TaskWrap::Extension::WrapStatic(
[MyAPM::Extension.method(:start_instrumentation), id: "my_apm.start_span", prepend: "task_wrap.call_task"],
[MyAPM::Extension.method(:finish_instrumentation), id: "my_apm.finish_span", append: "task_wrap.call_task"],
)
left :handle_errors
step :notify
include T.def_steps(:create_model, :validate, :notify)
end
end
Obviously, you’d introduce a macro here to hide all those specific details to the user of your extension.
When running, the APM instrumentation is run only around #validate
.
signal, (ctx, _) = Song::Activity::Create.invoke([ctx, {}])
Introspection
You can use our web-based debugger to introspect the static taskWrap of any step.
Troubleshooting
Even though tracing and wtf?
attempt to make your developer experience as smooth as possible, sometimes there are annoying issues.
Type Error
It’s a common error to use a bare Hash
(with string keys!) instead of a Trailblazer::Context
object when running an activity. While symbolized hashes are not a problem, string keys will fail.
ctx = {"message" => "Not gonna work!"} # bare hash.
Bla.([ctx])
The infamous TypeError
means your context object can’t convert strings into symbol keys. This is required when calling your steps with keyword arguments.
TypeError: wrong argument type String (expected Symbol)
Use Trailblazer::Context
as a wrapper.
ctx = Trailblazer::Context({"message" => "Yes, works!"})
signal, (ctx, _) = Bla.([ctx])
The Context
object automatically converts string keys to symbols.
Wrong circuit
When using the same task multiple times in an activity, you might end up with a wiring you’re not expecting. This is due to Trailblazer internally keying tasks by their object identity.
class Update < Trailblazer::Activity::Railway
class CheckAttribute < Trailblazer::Activity::Railway
step :valid?
end
step :find_model
step Subprocess(CheckAttribute), id: :a
step Subprocess(CheckAttribute), id: :b # same task!
step :save
end
When introspecting this activity, you will see that the CheckAttribute
task is present only once.
You need to create a copy of the method or the class of your callable task in order to fix this and have two identical steps.
class Update < Trailblazer::Activity::Railway
class CheckAttribute < Trailblazer::Activity::Railway
step :valid?
end
step :find_model
step Subprocess(CheckAttribute), id: :a
step Subprocess(Class.new(CheckAttribute)), id: :b # different task!
step :save
end
Illegal Signal Error
As the name suggests, the IllegalSignalError
exception is raised when a step returns a signal that is not registered at compile time. The routing algorithm is not able to find a connection for the returned signal and raises an error at run-time.
Usually, you encounter this beautiful exception when using the circuit interface signature for a step, and returning a “wrong” signal that is not wired to an on-going next task.
Other common cases may be
- Steps which are not wrapped by [TaskBuilder], for example:
step task: method(:validate)
- User defined macros.
class Create < Trailblazer::Activity::Railway
def self.validate((ctx, flow_options), **circuit_options)
return :invalid_signal, [ctx, flow_options], circuit_options
end
step task: method(:validate)
end
ctx = {"message" => "Not gonna work!"} # bare hash.
Create.([ctx])
# IllegalSignalError: Create:
# Unrecognized Signal `:invalid_signal` returned from `Method: Create.validate`. Registered signals are,
# - Trailblazer::Activity::Left
# - Trailblazer::Activity::Right
The exception helps by displaying both the actually returned signal and the registered, wired signals for this step.