{{ page.title }}
- Last updated 05 May 2017
- representable v3.0
Activity
- Last updated 01 Mar 20
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(Activity::Left, :failure) => Id(:create)
step :update
step :create, magnetic_to: nil, Output(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.
puts Trailblazer::Developer.render(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] ? Activity::Right : 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) = Upsert.([ctx, {}])
The ctx
will be whatever the most recently executed task returned, and hopefully contain what you’re expecting.
puts signal #=> #<Trailblazer::Activity::End semantic=:success>
puts ctx #=> {memo: #<Memo id=1, text="Hydrate!">, id: 1, ...}
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(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(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(Activity::Left, :failure) => End(:invalid)
step :create, Output(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).
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 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.
Path()
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.
class Charge < Trailblazer::Activity::Path
# ...
step :validate
step :decide_type, Output(Activity::Left, :credit_card) => Path(end_id: "End.cc", end_task: End(:with_cc)) do
step :authorize
step :charge
end
step :direct_debit
end
By providing the options :end_id
and :end_task
, the newly created path will quit in a new end event.
Using Output
you can create an additional output in decide_type
with the semantic :credit_card
. This output is triggered when its task returns a Trailblazer::Activity::Left
signal.
Note that the path ends in its very own end, signalizing a new end state, or outcome. The end’s semantic is :with_cc
.
If you want the path to reconnect and join the activity at some point, use the :connect_to
option.
class Charge < Trailblazer::Activity::Path
# ...
step :validate
step :decide_type, Output(Trailblazer::Activity::Left, :credit_card) => Path(connect_to: Id(:finalize)) do
step :authorize
step :charge
end
step :direct_debit
step :finalize
end
There won’t be another end event created.
You can use Path()
in any Trailblazer strategy, for example in Railway
.
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
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()
allow very simple modelling for alternive routes.
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
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 deeply nested activities for complex flows is a great way to encapsulate and create reusable code. However, it can be a PITA if you want to customize one of those deeply nested components and add or remove a certain step, for example.
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 patching, you can do this ad-hoc in the uppermost Destroy
activity.
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
The Subprocess()
macro accepts the :patch
option which is a hash of the path to the customized activity, and its patch. This patch block is class_eval
ed in context of the patched activity. You may add methods here, add, remove, or move steps, or whatever else needs fixing.
Note that patching does not change the originally nested activities. It creates copies of them. Also, the automatically assigned ID of a step gets replaced with new a copy. Make sure you mention it explicitly to persist.
Looking at the trace of Destroy
, you can see that #tidy_storage
is executed where you want it.
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
The idea of patching is that the originally nested activities remain untouched.
class Delete < Trailblazer::Activity::Railway
step :delete_model
step Subprocess(DeleteAssets), id: :delete_assets
# ...
end
They’re inherited and customized for you automatically by the DSL.
class DeleteAssets < Trailblazer::Activity::Railway
step :rm_images
step :rm_uploads
# ...
end
Patching has no implicit, magical side-effects and is strongly encouraged to customize flows for a specific case in a quick and consise way.
Variable Mapping
Since 2.1, it is possible to define the input and output 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, allowing you to limit what invoked tasks or nested activies “see” and what they propagate to the caller context.
The :input
filter is normally used to create a new context that limits what its task sees.
With the :output
filter, you can control what variables go from the inner scoped context to the outer.
Limiting
Without any I/O configuration, all values written in a task to ctx
will be visible in the following tasks. This might - sometimes - lead to context pollution or, even worse, certain tasks “seeing” wrong values.
When using the DSL, the filter options :input
and :output
are your interface for variable mapping.
Please note that I/O works for both “simple” tasks as well as nested activities.
Filter
The variable mapping API provides some shortcuts to control the scope.
class Memo::Create < Trailblazer::Activity::Path
step :authorize, input: [:params], output: {user: :current_user}
step :create_model
# ...
end
An array value such as [:params]
passed to :input
will result in the configured task only “seeing” the provided list of variables. All other values are not available, mimicking a whitelist.
A hash value (e.g. {user: :current_user}
) acts like a variable mapping directive. With :output
, it will only expose the variables mentioned in the hash, but rename them to the specifed value.
def authorize(ctx, params:, **)
ctx[:user] = User.find(params[:id])
if ctx[:user]
ctx[:result] = "Found a user."
else
ctx[:result] = "User unknown."
end
end
In the #authorize
example, the following happens.
- The task receives a context with only one variable set, which is
:params
passed into the activity invocation. - In the task, it may write (and pollute) the
ctx
object as much as it wants. It’s a scoped, private ctx object that will be discarded after the task is finished. This leads to the:result
variable being thrown away. - Before the private
ctx
gets disposed of, its:user
key gets copied into the originalctx
under the name:current_user
. - The following task
create_model
will see the original ctx plus:current_user
that was written in the previous step using:output
.
ctx = {params: {id: 1}}
signal, (ctx, flow_options) = Activity::TaskWrap.invoke(A::Memo::Create, [ctx, {}])
ctx #=> {:params=>{:id=>1}, :current_user=>#<User ..>, :model=>#<Memo ..>}}
An array passed to :output
will exclusively copy the specified variables to the original ctx, only.
A hash passed to :input
results in the called task only “seeing” the specified variables, but renamed to the hash values.
Callable
As usual, you may provide your own code for dynamic filtering or renaming.
class Memo::Create < Trailblazer::Activity::Path
step :authorize,
input: ->(original_ctx, **) do {params: original_ctx[:parameters]} end,
output: ->(scoped_ctx, **) do {current_user: scoped_ctx[:user]} end
step :create_model
# ...
end
From the :input
callable, you can return a hash containing the values that #authorize
may see. All other variables you don’t include in that hash will be unavailable. This is called a scope and resembles the arguments you pass into a normal Ruby method along with a method that doesn’t have access to variables outside its scope.
Trailblazer will automatically create a new Context
object around your custom input hash. You can write to that without interferring with the original context.
The :output
callable receives the scoped, new context object that you wrote to in #authorize
. In :output
, you return the hash of variables that you want to be visible in the following steps. This hash will be automatically merged into the original context.
In both filters, you’re able to rename and coerce variables. This gives you a bit more control than the simpler DSL.
For better readability, you may use instance methods for your filters.
class Memo::Create < Trailblazer::Activity::Path
step :authorize,
input: :authorize_input,
output: :authorize_output
def authorize_input(original_ctx, **)
{params: original_ctx[:parameters]}
end
def authorize_output(scoped_ctx, user:, **)
{current_user: scoped_ctx[:user]}
end
They receive the identical set of arguments that other callables are called with.
You may use keyword arguments in your filters for type safety and better readable code.
step :authorize,
input: ->(original_ctx, parameters:, **) do {params: parameters} end,
output: ->(scoped_ctx, user:, **) do {current_user: user} end
:input
provides all variables from the original context as kw args.:output
will receive a list of all variables you added to the scoped context.
Notes
- You can mix any
:input
style with any:output
style. - Any DSL style will always create a new, scoped context that contains your filtered variables in
:input
, and it will always dispose of that very context in:output
, copying desired variables over to the original ctx. - Please note that the I/O DSL is only providing the most-used requirements. Feel free to use the low-level taskWrap API to build your own variable mapping with different scoping techniques.
- When omitting either
:input
or:output
, defaults will be provided. Default:input
will pass through all variables. Default:output
copies all written variables from the scoped context to the original one.
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.
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 find out the structure and composition details about any activity, use the Introspect
API.
You may use Graph#find
with a block for a more complex search.
node = graph.find { |node| node.task.class == Trailblazer::Activity::TaskBuilder }
Alternatively, using the Graph#find
method with an ID provided, you can retrieve a particular node in the activity’s circuit.
Consider the following activity.
class Memo::Update < Trailblazer::Activity::Railway
step :find_model
step :validate, Output(:failure) => End(:validation_error)
step :save
fail :log_error
end
You can introspect a certain element by using find(id)
.
graph = Trailblazer::Activity::Introspect.Graph(Memo::Update)
node = graph.find(:validate)
Note that all queries go via a Graph
instance.
The returned node instance exposes several inspect helpers.
The ID of the element is retrieved by using #id
.
puts node.id.inspect #=> :validate
To see what actual task sits behind this circuit’s node, use #task
. This is always the low-level task that responds to a circuit interface. It might be a wrapper around your actual logic, provided by the DSL.
puts node.task #=> #Trailblazer::Activity::TaskBuilder::Task user_proc=validate>
Outgoing connections from a task can be queried using #outgoings
. This returns an array of all outgoing arrows.
left, right = node.outgoings # returns array
An Outgoing
provides several interesting fields.
You can retrieve the connected element by using #task
. It returns the actual low-level task.
puts left.task #=> #Trailblazer::Activity::End semantic=:validation_error>
The #output
method returns the Output
instance that connects the task to the next element.
puts left.output.signal #=> Trailblazer::Activity::Left
puts left.output.semantic #=> :failure
The Output#signal
field returns the signal object the task returns to trigger that connection. To see the semantic of this output, use Output#semantic
.
The Node#outputs
method returns an array of Output
objects defining each output of outgoing connections of a specific node.
outputs = node.outputs
left = outputs[0] #=> output object
Build Structures
The Activity DSL is only one way to define activities. Under the hood, the DSL simply creates a handful of generic objects such as an intermediate structure or an implementation. Those standardized objects then get compiled into an Activity
instance to be used at run-time.
This section discusses those underlying concepts - it will be helpful if you want to better understand how the DSL works, write your own DSL or generate activities from your own editor.
When defining an activity, two objects are used: an Intermediate
and an Implementation
structure. The intermediate object is a generic definition of the structure of the activity: which task got what connections?
It simply lists all tasks, along with the connections they have. The little gray bubbles on the task border are outputs. An output has a certain semantic plus a connection (an arrow) pointing to the following task.
Intermediate = Trailblazer::Activity::Schema::Intermediate # shortcut alias.
intermediate = Intermediate.new(
{
Intermediate::TaskRef(:"Start") => [Intermediate::Out(:success, :A)],
Intermediate::TaskRef(:A) => [Intermediate::Out(:success, :B),
Intermediate::Out(:failure, :C)],
Intermediate::TaskRef(:B) => [Intermediate::Out(:success, :"End")],
Intermediate::TaskRef(:C) => [Intermediate::Out(:success, :B)],
Intermediate::TaskRef(:"End", stop_event: true) => [Intermediate::Out(:success, nil)] # :)
},
[:"End"], # end events
[:"Start"], # start
)
Basically, it resembles a hash where the key is an Intermediate::TaskRef
instance referencing a task’s ID, and its values an array of possible Intermediate::Out
outputs going from this very task. Again, only IDs are used to point to the following task.
An Intermediate
structure is not used at run-time. It might come from a DSL, or from a generator, for example, from the PRO editor.
The idea is to allow serializing intermediate structures without a complex deserialization of task logic. Only task IDs are referenced, and no signal objects used. Instead, the heavy-lifting after defining the structure is done in the Implementation
.
After defining the structure, the actual start and end events, and the tasks have to be specified. This happens in an Implementation
object. It references “real” Ruby callables for each task. Usually, tasks and events are defined in some sort of namespace or module.
module Upsert
module_function
def a((ctx, flow_options), *)
ctx[:seq] << :a
return Trailblazer::Activity::Right, [ctx, flow_options]
end
# ~mod
extend T.def_tasks(:b, :c)
# ~mod end
end
start = Activity::Start.new(semantic: :default)
_end = Activity::End.new(semantic: :success)
The implementation object lists all the tasks and events.
Activity = Trailblazer::Activity # shortcut alias.
Implementation = Trailblazer::Activity::Schema::Implementation
implementation = {
:"Start" => Implementation::Task(start, [Activity::Output(Activity::Right, :success)], []),
:A => Implementation::Task(Upsert.method(:c), [Activity::Output(Activity::Right, :success),
Activity::Output(Activity::Left, :failure)], []),
:B => Implementation::Task(Upsert.method(:c), [Activity::Output(Activity::Right, :success)], []),
:C => Implementation::Task(Upsert.method(:c), [Activity::Output(Activity::Right, :success)], []),
:"End" => Implementation::Task(_end, [Activity::Output(_end, :success)], []), # :)
}
An Implementation::Task
needs the actual Ruby callable that responds to the circuit interface and a list of Activity::Output
s. Outputs consist of the actual signal the task returns (like Activity::Right
) and a semantic that is needed in the next step, the Activity
compilation.
Note that all tasks, even start and end events, need to be defined on this very low-level.
In order to combine intermediate structure with the implementation, you need to compile an activity from both.
schema = Intermediate.(intermediate, implementation)
Activity.new(schema)
This will create a callable Activity
instance that you’re used to.
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::Graph(Create).find { |node| node.id == :validate }.task
}
signal, (ctx, flow_options) = 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
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: Create::Implementation.new
}
signal, (ctx, flow_options) = 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
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 elements.
- 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, though. A task does inform the circuit about its outcome, it’s the circuit’s job to wire that specific result to the following task.
class Memo::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
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 using the TaskBuilder::Binary
strategy, 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.
class Memo::Create < Trailblazer::Activity::Railway
DatabaseError = Class.new(Trailblazer::Activity::Signal) # subclass Signal
def self.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 method(:create_model),
Output(DatabaseError, :handle_error) => Id(:handle_db_error)
step method(:handle_db_error),
id: :handle_db_error, magnetic_to: nil, Output(:success) => Track(:failure)
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
).
wiring
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
Overview
Extension
Runtime
-> tracing tracing variable mapping
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.