LIKE US FOR UPDATES + GET A FREE STICKER PACK!

Operation

Wiring API

Last updated 19 December 2017 trailblazer-operation v2.1

Where’s the EXAMPLE CODE?

Overview

When you run an operation like Memo::Create.(), it will internally execute its circuit. This simply means the operation will traverse its railway, call the steps you defined, deviate to different tracks, and so on. This document describes how those circuits are created, the so called wiring API.

An operation provides three DSL methods to define the circuit.

  • step is used when the result of the step logic is important.
  • pass will always remain on the “right” track.
  • fail is the opposite, and will stay on the “left” track.

To illustrate this, please take a look at the operation code along with a diagram of its circuit.

class Memo::Create < Trailblazer::Operation
  step :create_model
  step :validate
  fail :assign_errors
  step :index
  pass :uuid
  step :save
  fail :log_errors
  # ...
end

Ignoring the actual implementation of those steps, here’s the corresponding circuit diagram for this operation.

If you follow the diagram’s flow from left to right, you will see that the order of the DSL calls reflects the order of the tasks (the boxes) in the circuit. The three DSL methods have the following characteristics.

  • step always puts the task on the upper, “right” track, but with two outputs per box: one to the next successful step, one to the nearest fail box. The chain of “successful” boxes in the top is the right track. The lower chain is the infamous left track.
  • pass is on the right track, but without an outgoing connection to the left track. It is always assumed successful, as seen in the uuid task.
  • fail puts the box on the lower track and doesn’t connect it back to the right track.

It becomes obvious that the circuit has well-defined properties. This model is called a railway and we shamelessly stole this concept. The great idea here is that error handling comes for free via the left track since you do not need to orchestrate your code with if and else but simply implement the tasks and Trailblazer takes over flow control.

Fast Track

You can “short-circuit” specific tasks using a built-in mechanism called fast track.

Fast Track: pass_fast

To short-circuit the successful connection of a task use :pass_fast.

class Memo::Create < Trailblazer::Operation
  step :create_model
  step :validate,     pass_fast: true
  fail :assign_errors
  step :index
  pass :uuid
  step :save
  fail :log_errors
  # ...
end

If validate turned out to be successful, no other task won’t be invoked, as visible in the diagram.

As you can see, validate will still be able to deviate to the left track, but all following success tasks like index can’t be reached anymore, so this option has its limits. You might use :pass_fast with multiple steps.

Fast Track: fail_fast

The :fail_fast option comes in handy when having to early-out from the error (left) track.

class Memo::Create < Trailblazer::Operation
  step :create_model
  step :validate
  fail :assign_errors, fail_fast: true
  step :index
  pass :uuid
  step :save
  fail :log_errors
  # ...
end

The marked task(s) will be connected to the fail-fast end.

There won’t be an ongoing connection to the next left track task. As always, you can use that option multiple times, all fail_fast connections will end on the same End.fail_fast end.

Fast Track: fail_fast with step

You can also use :fail_fast with step tasks.

class Memo::Create < Trailblazer::Operation
  step :create_model
  step :validate
  fail :assign_errors, fail_fast: true
  step :index,         fail_fast: true
  pass :uuid
  step :save
  fail :log_errors
  # ...
end

The resulting diagram shows that index won’t hit any other left track boxes in case of failure, but errors-out directly.

All fail_fast connections will end on the same End.fail_fast end.

Fast Track: fast_track

Instead of hard-wiring the success or failure output to the respective fast-track end, you can decide what output to take dynamically, in the task. However, this implies you configure the task using the :fast_track option.

class Memo::Create < Trailblazer::Operation
  step :create_model,  fast_track: true
  step :validate
  fail :assign_errors, fast_track: true
  step :index
  pass :uuid
  step :save
  fail :log_errors
  # ...
end

By marking a task with :fast_track, you can create up to four different outputs from it.

Both create_model and assign_errors have two more outputs in addition to their default ones: one to End.pass_fast, one to End.fail_fast (note that this option works with pass, too). To make the execution take one of the fast-track paths, you need to emit a special signal from that task, though.

def create_model(options, create_empty_model:false, **)
  options[:model] = Memo.new
  create_empty_model ? Railway.pass_fast! : true
end

In this example, the operation would end successfully with an instantiated Memo model and no other steps taken, if called with an imaginary option create_empty_model: true. This is because it then returns the Railway.pass_fast! signal. Here’s what the invocation could look like.

result = Memo::Create.( create_empty_model: true )
puts result.success?        #=> true
puts result[:model].inspect #=> #<Memo text=nil>

Identically, the task on the left track assign_errors, could pick what path it wants the token to travel.

result = Memo::Create.( {} )
puts result.success?          #=> false
puts result[:model].inspect   #=> #<Memo text=nil>
puts result[:errors].inspect  #=> "Something went wrong!"

This time, the second error handler log_errors won’t be hit.

Signals

A signal is the object that is returned from a task. It can be any kind of object, but per convention, we derive signals from Trailblazer::Activity::Signal. When using the wiring API with step and friends, your tasks will automatically get wrapped so the returned boolean gets translated into a signal.

You can bypass this by returning a signal directly.

def validate(options, params: {}, **)
  if params[:text].nil?
    Trailblazer::Activity::Left  #=> left track, failure
  else
    Trailblazer::Activity::Right #=> right track, success
  end
end

Historically, the signal name for taking the success track is Right whereas the signal for the error track is Left. Instead of using the signal constants directly (which some users, for whatever reason, prefer), you may use signal helpers. The following snippet is identical to the one above.

def validate(options, params: {}, **)
  if params[:text].nil?
    Railway.fail! #=> left track, failure
  else
    Railway.pass! #=> right track, success
  end
end

Available signal helpers per default are Railway.pass!, Railway.fail!, Railway.pass_fast! and Railway.fail_fast!.

Note that those signals must have outputs that are connected to the next task, otherwise you will get a IllegalOutputSignalError exception. The PRO editor or tracing can help understanding.

Also, keep in mind that the more signals you use, the harder it will be to understand. This is why the operation enforces the :fast_track option when you want to use pass_fast! and fail_fast! - so both the developer reading your operation and the framework itself know about the implications upfront.

Nested Activities

The easiest way to nest operations or activities is to use the Nested macro.

Note that the Nested() macro currently comes with the trailblazer gem (not the operation gem) but its behavior might be moved to the DSL in the future so this macro might become obsolete.

A nestable object can be anything, for example an Operation.

class Lib::Authenticate < Trailblazer::Operation
  step :verify_input, fail_fast: true
  step :user_ok?
  # ...
end

Note that the first step, if unsuccessful, will error out on the fail_fast track and stop in its End.fail_fast end.

When nesting this operation into another Memo::Create, the Nested macro helps connecting the nested outputs.

class Memo::Create < Trailblazer::Operation
  step :validate
  step Nested( Lib::Authenticate ) # fail_fast goes to End.fail_fast
  step :create_model
  step :save
  # ...
end

All ends with known semantics will be automatically connected to its corresponding tracks in the outer operation.

As you can see, per default, if the nested operation ends on its End.fail_fast, it will also skip the rest of the outer operation and error out on the outer fail_fast track.

You can use the wiring API to reconnect outputs of nested activities.

class Memo::Create < Trailblazer::Operation
  step :validate
  step Nested( Lib::Authenticate ), Output(:fail_fast) => :failure
  step :create_model
  step :save
  # ...
end

In this example, we reconnect the inner’s End.fail_fast to the failure track on the outside.

This wiring will result in the user of Memo::Create not “seeing” that the inner operation errored out via fail_fast and will instead use the outer failure track that could contain additional error handlers, recover, etc.

You may use the entire wiring API to connect nested outputs at your convenience.

Connections

The four standard tracks in an operation represent an extended railway. While they allow to handle many situations, they sometimes can be confusing as they create hidden semantics. This is why you can also define explicit, custom connections between tasks and even attach task not related to the railway model.

Connections: By ID

If you need to connect two tasks or events explicitly, you may do so by defining an Output from the outgoing task.

class Memo::Upload < Trailblazer::Operation
  step :new?, Output(:failure) => "index"
  step :upload
  step :validate
  fail :validation_error
  step :index, id: "index"
  # ...
end

This operation uploads a file. In the first step, it figures out whether or not that file already exists, and skips the upload part if it has seen the file before. Here’s the circuit.

The existing output can be reconnected by using Output and specifying a semantic, only. For a normal step task, this means the output supposed to go on the left track will be rewired, or in other words, a falsey value returned from new? will go straight to index.

Referencing an explicit target must happen by id, and can both point forward or backward.

Note that you can also reference Start.default, and end events like End.success.

Recover

Error handlers on the left track are the perfect place to “fix things”. This means you might want to return to the right track. We call this a recover task. For example, if you need to upload a file to S3, if that doesn’t work, try with Azure, and if that still doesn’t play, with Backblaze. This is a common pattern when dealing with external APIs.

You can simply put recover steps on the left track, and wire their :success output back to the right track (which the operation knows as :success).

class Memo::Upload < Trailblazer::Operation
  step :upload_to_s3
  fail :upload_to_azure,  Output(:success) => :success
  fail :upload_to_b2,     Output(:success) => :success
  fail :log_problem
  # ...
end

The resulting circuit looks as follows.

The Output(:success) DSL call will find the task’s :success-colored output and connect it to the right (:success) track. The recover tasks themselves can now return a boolean to direct the flow.

class Memo::Upload < Trailblazer::Operation
  def upload_to_s3(options, s3:, file:, **)
    s3.upload_file(file) # returns true or false
  end
end

Decider

If you want to stay on one path but need to branch-and-return to model a decision, use the decider pattern.

class Memo::Upsert < Trailblazer::Operation
  step :find_model, Output(:failure) => :create_route
  step :update
  step :create, magnetic_to: [:create_route]
  step :save
  # ...
end

In this example, the success track from find_model will go to update whereas the failure output gets connected to create, giving the circuit a diamond-shaped flow.

Note that we’re using properties of the magnetic API here: by polarizing (or coloring) the failure output of find_model to :create_route (which is a random name we picked), and making create being attracted to that very polarization, the failure output “snaps” to that task automatically.

The cool feature with the magnetic API in this example is that you don’t need to know what is the specific target of a connection, allowing to push multiple tasks onto that new :create_route track, if you needed that.

End

When traversing the railway, per default the circuit will deviate to the error track (:failure) when a step returns a falsey value. You can also wire the step’s error output to a custom end. This is incredibly helpful if your operation needs to communicate what exactly happened inside to the outer world, a pattern used in Endpoint.

class Memo::Update < Trailblazer::Operation
  step :find_model, Output(:failure) => End("End.model_not_found", :model_not_found)
  step :update
  fail :db_error
  step :save
  # ...
end

The End DSL method will create a new end event, the first argument being the name, the second the semantic.

The diagram now has a new “error” track.

The find_model step now has its dedicated failure end. This allows to detect a 404 error without having to guess what might have happened. Please note how that new “error track” does not interfere with other fail tasks.

Path

Task Implementation (?)

Terminology

TRB flows, you implement

delete

Doormat Step

Very often, you want to have one or multiple “last steps” in an operation, for instance to generically log errors or success messages. We call this a doormat step.

Doormat Step: Before

The most elementary way to achieve this is using the :before option.

class Memo::Create < Trailblazer::Operation
  step :create_model
  step :log_success

  step :validate, before: :log_success
  step :save,     before: :log_success

  fail :log_errors
  # ...
end

Note that :before is a DSL option and not exactly related to the wiring API. Using this option, the inserted step will be “moved up” as if you had actually called it before the targeted :before step.

Doormat Step: Group

An easier way to place particular steps always into the end section is to use the :group option.

class Memo::Create < Trailblazer::Operation
  step :create_model
  step :log_success,  group: :end, before: "End.success"

  step :validate
  step :save

  fail :log_errors,   group: :end, before: "End.failure"
  # ...
end

The resulting Memo::Create’s circuit is identical to the last example.

Note how this can be used for “template operations” where the inherited class really only adds its concrete steps into the existing layout.

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.

Sequence Options: id

You can name each step using the :id option.

class Memo::Create < Trailblazer::Operation
  step :create_model, id: "create_memo"
  step :validate,     id: "validate_params"
  step :save
  # ...
end

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.

Trailblazer::Operation.introspect( Memo::Create )
#=> [>create_model,>validate_params,>save]

Sequence Options: delete

When it’s necessary to remove a task, you can use :delete.

class Memo::Create::Admin < Memo::Create
  step nil, delete: "validate_params", id: ""
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::Operation.introspect( Memo::Create::Admin )
#=> [>create_model,>save]

All steps are inherited, then the deletion is applied, as the introspection shows.

Sequence Options: before

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_memo"
  # ...
end

The circuit now yields a new policy step before the inherited tasks.

Sequence Options: after

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_params"
  # ...
end

The task is inserted after, as the introspection shows.

Trailblazer::Operation.introspect( Memo::Create::Logging )
#=> [>create_memo,>validate_params,>logger,>save]

Sequence Options: replace

Replacing an existing task is done using :replace.

class Memo::Update < Memo::Create
  step :find_model, replace: "create_memo", id: "update_memo"
  # ...
end

Replacing, obviously, only replaces in the applied class, not in the superclass.

Trailblazer::Operation.introspect( Memo::Update )
#=> [>update_memo,>validate_params,>save]

Group

The :group option is the ideal solution to create template operations, where you declare a basic circuit layout which can then be enriched by subclasses.

class Memo::Operation < Trailblazer::Operation
  step :log_call,  group: :start
  step :log_success,  group: :end, before: "End.success"
  fail :log_errors,   group: :end, before: "End.failure"
  # ...
end

The resulting circuit, admittedly rather useless, will look as follows.

Subclasses can now insert their actual steps without any sequence options needed.

class Memo::Create < Memo::Operation
  step :create_model
  step :validate
  step :save
  # ...
end

Since all logging steps defined in the template operation are placed into groups, the concrete steps sit in the middle.

It is perfectly fine to use the :group and other sequence options again, in subclasses. Also, multiple inheritance levels will work.