{{ page.title }}

  • Last updated 05 May 2017
  • representable v3.0

Activity

  • Last updated 05 May 2017
  • activity v1.1.1.1.0

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.

  1. An activity is a circuit of tasks - boxes being connected by arrows.
  2. It has one start and at least one end event. Those are the circles in the diagrams.
  3. A task is a unit of business logic. They’re visualized as boxes. This is where your code goes!
  4. Each task has one or more outputs. From one particular output you can draw one connecting line to the next task.
  5. An output is triggered by a signal. The last line in a task usually decides what output to pick, and that happens by returning a specific object, a signal.
  6. Besides the signal, a semantic is assigned to an output. This is a completely arbitrary “meaning”. In Trailblazer, we use success and failure as conventional semantics.
  7. In a railway activity, for instance, the “failure” and “success” track mean nothing more than following the failure or success-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. Activitys 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, then create, then ends in End.success. The activity was successful.
  • Validation error: First validate, which returns a Left (failure) signal, leading to log_error, then End.failure.
  • Creation error: First validate, then create, which deviates to the failure track, leading to End.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 with step, pass or fail. 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.


class Execute < Trailblazer::Activity::Railway
  UsePaypal = Class.new

  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 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 thedeclined 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]

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.

  1. The task receives a context with only one variable set, which is :params passed into the activity invocation.
  2. 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.
  3. Before the private ctx gets disposed of, its :user key gets copied into the original ctx under the name :current_user.
  4. 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 :outputs, sequence options like :before or even :extension.

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

  # ...
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::Outputs. 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 = 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 is call((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’s ctx, 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 using flow_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 same circuit_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 calling 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 of Activity::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

Hello hello early birds

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque ac ligula convallis, mollis velit eu, porta odio. Proin nibh ipsum, bibendum eu auctor volutpat, consectetur vitae erat. Duis condimentum dapibus hendrerit.