Activity

  • Last updated 15 Jun 23

Overview

An activity is an executable circuit of tasks. Each task is arbitrary Ruby code, usually encapsulated in a callable object. Depending on its return value and its outgoing connections, the next task to invoke is picked.

Activities are tremendously helpful for modelling and implementing any kind of logic and any level of complexity. They’re useful for a hash merge algorithm, an application’s function to validate form data and update models with it, or for implementing long-running business workflows that drive entire application lifecycles.

The activity gem is an extraction from Trailblazer 2.0, where we only had operations. Operations expose a linear flow which goes into one direction, only. While this was a massive improvement over messily nested code, we soon decided it’s cool being able to model non-linear flows. This is why activities are the major concept since Trailblazer 2.1.

Anatomy

To understand the mechanics behind Trailblazer’s activities, you need to know a few simple concepts.

  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(Trailblazer::Activity::Left, :failure) => Id(:create)
  step :update
  step :create, magnetic_to: nil, Output(Trailblazer::Activity::Right, :success) => Id(:update)

  # ...
end

The Activity::Path class is the simplest DSL strategy. It automatically connects each step to the previous one, unless you use the :magnetic_to option. In our case, this is necessary to connect #find (A) to #create (C). The Output method helps to define what signal and semantic an output has, and using Id you can point those to a specific neighbor task.

If unsure, use the [developer tools] to render the circuit.

Trailblazer::Developer.render(A::Upsert)

Alternatively, use the PRO editor tools.

Invocation

Before you can use your activity, the tasks need to be written. Using the [task interface] this is pretty straight-forward. Note that you can return either a boolean value or a [signal subclass] in order to dictate the direction of flow.

class Upsert < Trailblazer::Activity::Path
  # ...

  def find_model(ctx, id:, **) # A
    ctx[:memo] = Memo.find(id)
    ctx[:memo] ? Trailblazer::Activity::Right : Trailblazer::Activity::Left # can be omitted.
  end

  def update(ctx, params:, **) # B
    ctx[:memo].update(**params)
    true # can be omitted
  end

  def create(ctx, **)
    ctx[:memo] = Memo.new
  end
end

You don’t have to stick to the task interface! The [circuit interface] is a bit more clumsy, but gives you much better control over how ctx and signals are handled.

To run your activity, use its call method. Activitys always use the [circuit interface].

ctx = {id: 1, params: {text: "Hydrate!"}}

signal, (ctx, flow_options) = A::Upsert.([ctx, {}])

The ctx will be whatever the most recently executed task returned, and hopefully contain what you’re expecting.

# FIXME

After this brief introduction, you should check out how [nesting] of activities will help you, what [operations] are, and what awesome debugging tools such as [tracing] we provide.

:activity is guaranteed to match the currently invoked activity

STRATEGY

Path

The simplest strategy is Path, which does nothing but connecting each task’s :success output to the following task.

class Create < Trailblazer::Activity::Path
  step :validate
  step :create
  # ...
end

Without any additional DSL options, this results in a straight path.

In turn, this means that only true return values in your tasks will work. The DSL will, per default, wrap every task with the Binary interface, meaning returning true will result in Activity::Right, and false in Activity::Left. Currently, only Right signals are wired up.

You may add as many outputs to a task as you need. The DSL provides the Output() helper to do so.

class Create < Trailblazer::Activity::Path
  step :validate, Output(Trailblazer::Activity::Left, :failure) => End(:invalid)
  step :create
  # ...
end

The Path strategy only maintains the :success/Activity::Right semantic/signal combination. Any other combination you need to define explicitly using Output(signal, semantic).

The End() helper allows creating a new end event labelled with the specified semantic.

class Create < Trailblazer::Activity::Path
  step :validate, Output(Trailblazer::Activity::Left, :failure) => End(:invalid)
  step :create
  # ...
end

This will result in the following circuit.

The validate task now has a success and a failure output. Since it’s wrapped using Binary it may return true or false to dictate the used output (or Activity::Right/Activity::Left since it’s the [task interface]).

class Create < Trailblazer::Activity::Path
  # ...
  def validate(ctx, params:, **)
    ctx[:input] = Form.validate(params) # true/false
  end

  def create(ctx, input:, **)
    Memo.create(input)
  end
end

The activity will halt on the :invalid-labelled end if validate was falsey.

ctx = {params: nil}
signal, (ctx, flow_options) = Memo::Create.([ctx, {}])

puts signal #=> #<Trailblazer::Activity::End semantic=:invalid>

Note that repeatedly using the same semantic (End(:semantic)) will reference the same end event.

class Create < Trailblazer::Activity::Path
  step :validate, Output(Trailblazer::Activity::Left, :failure) => End(:invalid)
  step :create,   Output(Trailblazer::Activity::Left, :failure) => End(:invalid)
  # ...
end

Since we’re adding a :failure output, create now has two outgoing connections.

Railway

The Railway pattern is used for “automatic” error handling. You arrange your actual chain of logic on the “success” track, if a problem occurs, the processing jumps to the parallel “failure” track, skipping the rest of the tasks on the success track.

Once on the failure track, it stays there (unless you instruct not to do so!).

Three possible execution paths this activity might take.

  • No errors: First validate, 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).

trailblazer-activity-dsl-linear 1.2.2

To avoid syntax problems with several editors, #fail is also aliased as #left.

class Create < Trailblazer::Activity::Railway
  step :validate
  left :log_error
  # ...
end

The signature and accepted arguments are identical to #fail.

FIXME

  • Using Railway, tasks always get two outputs assigned: :success/Right and :failure/Left.

FastTrack

Based on the Railway strategy, the FastTrack pattern allows to “short-circuit” tasks and leave the circuit at specified events.

The infamous Trailblazer::Operation is a thin public API around Activity::FastTrack.

The :pass_fast option wires the :success output straight to the new pass_fast end.

class Create < Trailblazer::Activity::FastTrack
  step :validate, pass_fast: true
  fail :log_error
  step :create
  # ...
end

If validate returns a true value, it will skip the remaining tasks on the success track and end in End.pass_fast.

Note that in the example, the create task not accessable anymore.

The counter-part for :pass_fast is :fail_fast.

class Create < Trailblazer::Activity::FastTrack
  step :validate, fail_fast: true
  fail :log_error
  step :create
  # ...
end

A falsey return value from #validate will deviate the flow and go straight to End.fail_fast.

Again, this specific example renders the log_errors task unreachable.

It’s possible to wire a task to the two FastTrack ends End.fail_fast and End.pass_fast in addition to the normal Railway wiring.

class Create < Trailblazer::Activity::FastTrack
  step :validate, fast_track: true
  fail :log_error
  step :create

  def validate(ctx, params:, **)
    begin
      ctx[:input] = Form.validate(params) # true/false
    rescue
      return Trailblazer::Activity::FastTrack::FailFast # signal
    end

    ctx[:input] # true/false
  end

  # ...
end

The validate task now has four outputs. You can instruct the two new FastTrack outputs by returning either Trailblazer::Activity::FastTrack::FailFast or Trailblazer::Activity::FastTrack::PassFast (see also [returning signals]).

Note that you don’t have to use both outputs.

The standard FastTrack setup allows you to communicate and model up to four states from one task.

FIXME

  • All options (:pass_fast, :fail_fast and :fast_track) may be used 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.

Since a particular output is triggered by a particular signal, note that each output must be configured with a unique signal per activity.

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

  step :find_provider, Output(UsePaypal, :paypal) => Track(:paypal)
  step :charge_creditcard
end

The find_provider task now has three possible outcomes that can be triggered by returning either Right, Left, or UsePaypal.

End()

Use End() to connect outputs to an existing end, or create a new end.

You may reference existing ends by their semantic.

class Execute < Trailblazer::Activity::Railway
  step :find_provider
  step :charge_creditcard, Output(:failure) => End(:success)
end

This reconnects both outputs to the same end, always ending in a - desirable, yet unrealistic - successful state.

Providing a new semantic to the End() function will create a new end event.

class Execute < Trailblazer::Activity::Railway
  step :find_provider
  step :charge_creditcard, Output(:failure) => End(:declined)
end

Adding ends to an activity is a beautiful way to communicate more than two outcomes to the outer world without having to use a state field in the ctx. It also allows wiring those outcomes to different tracks in the container activity. [See nesting]

This activity now maintains three end events. The path to the declined end is taken from the task’s failure output.

Successive uses of the same End(:semantic) will all connect to the same end.

Id()

An output can be connected to a particular task by using Id().

class Execute < Trailblazer::Activity::Railway
  step :find_provider
  step :charge_creditcard, Output(:failure) => Id(:find_provider)
end

This connects the failure output to the previous task, which might create an infinity loop and waste your computing time - it is solely here for demonstrational purposes.

Track()

The Track() function will snap the output to the next task that is “magnetic to” the track’s semantic.

class Execute < Trailblazer::Activity::Railway
  step :find_provider, Output(:success) => Track(:failure)
  step :charge_creditcard
  fail :notify
end

Since notify sits on the “failure” track and hence is “magnetic to” :failure, find_provider will be connected to it.

Using Track() with a new track semantic only makes sense when using the [:magnetic_to option] on other tasks.

Use [Path()] if you want to avoid Track() and :magnetic_to - this helper does nothing but providing those values to your convenience.

Terminus

trailblazer-activity-dsl-linear 1.0.0

In addition to the strategy’s termini, you can add your own end events using #terminus. This is an important design tool helping you to communicate outcomes other than “success” or “failure” to the outer world (in a Railway activity).

module Payment::Operation
  class Create < Trailblazer::Activity::Railway
    step :find_provider

    terminus :provider_invalid # , id: "End.provider_invalid", magnetic_to: :provider_invalid
    # ...
  end
end

The above code adds a new terminus named End.provider_invalid to the activity.

As visible, this terminus is not connected to anything as its magnetic_to property is set to :provider_invalid.

You could now connect find_provider’s failure output to the new terminus by using the Track() helper.

module Payment::Operation
  class Create < Trailblazer::Activity::Railway
    step :find_provider,
      # connect {failure} to the next element that is magnetic_to {:provider_invalid}.
      Output(:failure) => Track(:provider_invalid)

    terminus :provider_invalid
    # ...
  end
end

The failure output will be connected to the next following element that is magnetic_to :provider_invalid, which is the new terminus we created.

Invoking this activity with an unsolicited provider will stop on the newly added terminus.

signal, (ctx, _) = Payment::Operation::Create.(provider: "bla-unknown")
puts signal.to_h[:semantic] #=> :provider_invalid

The default semantic is :provider_invalid. Note that the following options :id and :magnetic_to can be passed to #terminus:

  • :id
  • :magnetic_to
  • :task which has to be a subclass of Trailblazer::Activity::End.

Path()

trailblazer-activity-dsl-linear 1.2.0

For branching out a separate path in an activity, use the Path() macro. It’s a convenient, simple way to declare alternative routes, even if you could do everything it does manually.

module Song::Activity
  class Charge < Trailblazer::Activity::Railway
    # ...
    step :validate
    step :decide_type, Output(:failure) => Path(terminus: :with_cc) do
      step :authorize
      step :charge
    end
    step :direct_debit
  end
end
module Song::Operation
  class Charge < Trailblazer::Operation
    # ...
    step :validate
    step :decide_type, Output(:failure) => Path(terminus: :with_cc) do
      step :authorize
      step :charge
    end
    step :direct_debit
  end
end

If #decide_type returns false, the path will be executed and terminate on the new terminus End.with_cc because of the :terminus option.

Note that you don’t necessarily have to reuse the :failure output in order to branch out a new path. You might as well use an additional signal, or any other predefined output of the task.

If you want the path to reconnect and join the activity at some point, use the :connect_to option.

module Song::Activity
  class Charge < Trailblazer::Activity::Railway
    # ...
    step :validate
    step :decide_type, Output(:failure) => Path(connect_to: Id(:finalize)) do
      step :authorize
      step :charge
    end
    step :direct_debit
    step :finalize
  end
end
module Song::Operation
  class Charge < Trailblazer::Operation
    # ...
    step :validate
    step :decide_type, Output(:failure) => Path(connect_to: Id(:finalize)) do
      step :authorize
      step :charge
    end
    step :direct_debit
    step :finalize
  end
end

There won’t be an additional terminus created.

You can use Path() in any Trailblazer strategy, for example in Railway.

module Song::Activity
  class Charge < Trailblazer::Activity::Railway
    MySignal = Class.new(Trailblazer::Activity::Signal)
    # ...
    step :validate
    step :decide_type, Output(MySignal, :credit_card) => Path(connect_to: Id(:finalize)) do
      step :authorize
      step :charge
    end
    step :direct_debit
    step :finalize
  end
end
module Song::Operation
  class Charge < Trailblazer::Operation
    MySignal = Class.new(Trailblazer::Operation::Signal)
    # ...
    step :validate
    step :decide_type, Output(MySignal, :credit_card) => Path(connect_to: Id(:finalize)) do
      step :authorize
      step :charge
    end
    step :direct_debit
    step :finalize
  end
end

In this example, we add a third output to #decide_type to handle the credit card payment scenario (you could also “override” or re-configure the existing :failure or :success outputs).

Only when decide_type returns MySignal, the new path alternative is taken.

def decide_type(ctx, model:, **)
  if model.is_a?(CreditCard)
    return MySignal # go the Path() way!
  elsif model.is_a?(DebitCard)
    return true
  else
    return false
  end
end

Output() in combination with Path() allows for a simple modeling of alternative routes.

In older versions before trailblazer-activity-dsl-linear-1.2.0, connecting the Path() to a separate terminus required you to pass two options :end_task and :end_id.

Output(...) => Path(end_task: Activity::End.new(semantic: :valid), end_id: "End.valid") do
  # ...
end

This is now simplified (and more consistent) by introducing the :terminus option.

Output(...) => Path(terminus: :valid) do
  # ...
end

If you haven’t updated your code you will see a deprecation warning.

[Trailblazer] <file.rb> Using `:end_task` and `:end_id` in Path() is deprecated, use `:terminus` instead.
  Please refer to https://trailblazer.to/2.1/docs/activity.html#activity-wiring-api-path-end_task-end_id-deprecation

Subprocess

While you could nest an activity into another manually, the Subprocess macro will come in handy.

Consider the following nested activity.

class Memo::Validate < Trailblazer::Activity::Railway
  step :check_params
  step :check_attributes
  # ...
end

Use Subprocess to nest it into the Create activity.

class Memo::Create < Trailblazer::Activity::Railway
  step :create_model
  step Subprocess(Memo::Validate)
  step :save
  # ...
  # ...

The macro automatically wires all of Validate’s ends to the known counter-part tracks.

The Subprocess macro will go through all outputs of the nested activity, query their semantics and search for tracks with the same semantic.

Note that the failure track starting from create_model will skip the nested activity, just as if it was simple task.

You can use the familiar DSL to reconnect ends.

class Memo::Create < Trailblazer::Activity::Railway
  step :create_model
  step Subprocess(Memo::Validate), Output(:failure) => Track(:success)
  step :save
  # ...
end

The nested’s failure output now goes to the outer success track.

In this example, regardless of nested’s outcome, it will always be interpreted as a successful invocation.

A nested activity doesn’t have to have two ends, only.

class Memo::Validate < Trailblazer::Activity::Railway
  step :check_params, Output(:failure) => End(:invalid_params)
  step :check_attributes
  # ...
end

Subprocess will try to match the nested ends’ semantics to the tracks it knows. You may wire custom ends using Output.

class Memo::Create < Trailblazer::Activity::Railway
  step :create_model
  step Subprocess(Memo::Validate), Output(:invalid_params) => Track(:failure)
  step :save
  # ...
end

The new special end is now wired to the failure track of the containing activity.

There will be an exception thrown if you don’t connect unknown ends.

DSL Options

#step and friends accept a bunch of options in order to insert a task at a specific location, add pre-defined connections and outputs, or even configure its taskWrap.

magnetic_to

In combination with [Track()], the :magnetic_to option allows for a neat way to spawn custom tracks outside of the conventional Railway or FastTrack schema.

class Execute < Trailblazer::Activity::Railway
  step :find_provider, Output(:failure) => Track(:paypal)
  step :charge_creditcard
  step :charge_paypal, magnetic_to: :paypal
end

The failure output of the find_provider task will now snap to the next task being :magnetic_to its semantic - which obviously is the charge_paypal task.

When creating a new branch (or path) in this way, it’s a matter of repeating the use of Track() and :magnetic_to to add more tasks to the branch.

extensions

Inherit

Sequence Options

In addition to wiring options, there are a handful of other options known as sequence options. They configure where a task goes when inserted, and helps with introspection and tracing.

The DSL will provide default names for tasks. You can name explicitely using the :id option.

class Memo::Create < Trailblazer::Activity::Path
  step :create_model
  step :validate
  step :save, id: :save_the_world
  # ...
end

The IDs are as follows.

Trailblazer::Developer.railway(Memo::Create)
#=> [>create_model,>validate,>save_the_world]

This is advisable when planning to override a step via a module or inheritance or when reconnecting it. Naming also shows up in tracing and introspection. Defaults names are given to steps without the :id options, but these might be awkward sometimes.

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

class Memo::Create::Admin < Memo::Create
  step nil, delete: :validate
end

The :delete option can be helpful when using modules or inheritance to build concrete operations from base operations. In this example, a very poor one, the validate task gets removed, assuming the Admin won’t need a validation.

Trailblazer::Developer.railway(Memo::Create::Admin)
#=> [>create_model,>save_the_world]

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

To insert a new task before an existing one, for example in a subclass, use :before.

class Memo::Create::Authorized < Memo::Create
  step :policy, before: :create_model
  # ...
end

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

Trailblazer::Developer.railway(Memo::Create::Authorized)
#=> [>policy,>create_model,>validate,>save_the_world]

To insert after an existing task, you might have guessed it, use the :after option with the exact same semantics as :before.

class Memo::Create::Logging < Memo::Create
  step :logger, after: :validate
  # ...
end

The task is inserted after, as the introspection shows.

Trailblazer::Developer.railway(Memo::Create::Logging)
#=> [>create_model,>validate,>logger,>save_the_world]

Replacing an existing task is done using :replace.

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

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

Trailblazer::Developer.railway(Memo::Update)
#=> [>update_memo,>validate,>save_the_world]

Patching

Working with Subprocess and nested activities is a great way to encapsulate and create reusable code especially with complex logic. However, it can be a PITA if you want to customize one of those deeply nested components and add, replace or remove a certain step.

Suppose the following 3-level nested activity.

The public operation Destroy contains Delete as a nested activity, which itself contains DeleteAssets. In order to customize the latter one and add another step tidy_storage, you’d normally have to subclass all three activities and override steps.

Using #patch, you can modify nested activities from the uppermost activity and let Trailblazer do the legwork.

As #patch is mostly used when leveraging inheritance we introduce a subclass of Destroy which is called Erase and introduces the #tidy_storage step. As illustrated above, this new step should be inserted in DeleteAssets activity that itself is nested in Delete, which again is one step of Destroy.

class Erase < Destroy # we're inheriting from Song::Operation::Destroy
  # ...
  def self.tidy_storage(ctx, **)
    # delete files from your amazing cloud
  end
  # ...
  # These steps are inherited:
  # step :policy
  # step :find_model
  # step Subprocess(Delete), id: :delete

  extend Trailblazer::Activity::DSL::Linear::Patch::DSL

  # Note the path you pass to #patch.
  patch(:delete, :delete_assets) {
    step Erase.method(:tidy_storage), after: :rm_images
  }
end

The patching logic accepts a path to the particular activity that you want to modify.

patch(:delete, :delete_assets) { ... }

The provided block is executed within that targeted activity and executed as if you’d extend that class. However, the entire modification will only change Erase, all other traversed activities are copied and then modified, leaving the original implemenation unchanged.

The #patch method is perfect when using inheritance to first copy over behavior and control flow, and then fine-tune it for the specific use case.

If you’re not using inheritance and want to tweak a nested activity ad-hoc the Subprocess() helper accepts a :patch option.

class Destroy < Trailblazer::Activity::Railway
  def self.tidy_storage(ctx, **)
    # delete files from your amazing cloud
    true
  end
  # ...
  step :policy
  step :find_model
  step Subprocess(Delete,
    patch: {
      [:delete_assets] => -> { step Destroy.method(:tidy_storage), before: :rm_uploads }
    }
  )
end
class Destroy < Trailblazer::Operation
  def self.tidy_storage(ctx, **)
    # delete files from your amazing cloud
    true
  end
  # ...
  step :policy
  step :find_model
  step Subprocess(Delete,
    patch: {
      [:delete_assets] => -> { step Destroy.method(:tidy_storage), before: :rm_uploads }
    }
  )
end

This works just like the #patch function but returns the patched activity.

Subprocess() accepts the :patch option which consists of a hash of the path to the customized activity, and its patch.

This will result in an identical operation as in the above example with #patch. However, Delete is now the operation containing the customization, not a new class Erase.

Patching can be also done at the top-level activity by passing :patch as a block (Take Delete from above example).

step Subprocess(
  Delete,
  patch: -> { step Destroy.method(:tidy_storage), before: :delete_model }
), id: :delete

Variable Mapping

trailblazer-activity-dsl-linear 1.2.0

Since TRB 2.1 it is possible to define the input and output variables for each step. This is called variable mapping, or I/O in short. It provides an interface to define what variable go in and come out of a task, enabling you to limit what steps “see” and what “output” they can add to the context.

It’s one of the most frequently used features in Trailblazer.

Overview

Imagine a complex application where policies are protecting your operation code from unsolicited access. This code component - the policy - sits as a step in every business operation and decides whether or not the current user is permitted to execute this very operation.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create # an imaginary policy step.
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create # an imaginary policy step.
    # ...
  end
end

The Policy::Create implementation is a simple callable class following the step interface.

module Policy
  # Explicit policy, not ideal as it results in a lot of code.
  class Create
    def self.call(ctx, model:, user:, **)
      decision = ApplicationPolicy.can?(model, user, :create) # FIXME: how does pundit/cancan do this exactly?
      # ...
    end
  end
end

Note that it requires two variables :model and :user from the ctx. For whatever reasons, the author of this class dictated that the “current user” must be passed named :user, not, as it’s a convention in Trailblazer, named :current_user.

Last, depending on the policy decision, the step code returns true or false.

When executing the Create operation using the :current_user variable, an ArgumentError is raised.

result = Trailblazer::Activity.(Song::Activity::Create, current_user: Module)

#=> ArgumentError: missing keyword: :user
result = Song::Operation::Create.(current_user: Module)

#=> ArgumentError: missing keyword: :user

Since the “current user” is handed into the operation as the :current_user variable, and no other step preceding Policy::Create is setting this variable, the step expecting :user crashes.

And this is why we need variable mapping in Trailblazer.

Composable I/o

Variable mapping (short: i/o) can be done manually, with ugly “helper” steps before or after the respective step, or by using In(), Out() and Inject(). Before these helpers got introduced, we used the :input and :output option - both works, the latter one coming with several drawbacks.

Helpers can be used multiple times, depending on how complex the incoming or outcoming variables are, forming a pipeline of filters around the actual task.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => [:message]
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => [:message]
    # ...
  end
end

Not only are those input and output pipelines easy to debug, they also allow to be altered in derived operations, when using inheritance, and work in combination with macros.

In()

In() allows to configure variables going into the step. The helper accepts either a mapping hash, a limiting array or a callable object (often a lambda), to compute variables at runtime.

Be wary that once you use In() your are white-listing: only the variables defined in your filters will be passed into the step. All other variables from ctx are invisible in the step.

Picking up the example from above, here’s how a mapping hash “translates” the selected variables from the original ctx object to a new ctx, one that is compatible with Policy::Create’s interface.

class Create < Trailblazer::Activity::Railway
  step :create_model
  step Policy::Create,
    In() => {
      :current_user => :user, # rename {:current_user} to {:user}
      :model        => :model # add {:model} to the inner ctx.
    }
  # ...
end

The In() filter will result in :current_user being renamed to :user. Since the policy step also needs :model we need to mention this variable as well, no renaming happening here. The beauty of I/O: this is only visible to Policy::Create!

To instantly see what new ctx is passed into the configured step, you could replace the original policy step with a #show_ctx method.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step :show_ctx,
      In() => {
        :current_user => :user, # rename {:current_user} to {:user}
        :model        => :model # add {:model} to the inner ctx.
      }

    def show_ctx(ctx, **)
      p ctx.to_h
      #=> {:user=>#<User email:...>, :model=>#<Song name=nil>}
    end
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step :show_ctx,
      In() => {
        :current_user => :user, # rename {:current_user} to {:user}
        :model        => :model # add {:model} to the inner ctx.
      }

    def show_ctx(ctx, **)
      p ctx.to_h
      #=> {:user=>#<User email:...>, :model=>#<Song name=nil>}
    end
    # ...
  end
end

You should use the mapping hash when variables need to be renamed. If variables need to be added without renaming, a limiting array is your friend.

In() accepts an array, listed variables are passed into the new ctx (whether they exist in the original ctx or not!).

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model]
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model]
    # ...
  end
end

This configuration will lead to the exact same new ctx for Policy::Create as in the example above, producing a new ctx that will look as below.

#=> {
#     :user  => #<User email:...>,
#     :model => #<Song name=nil>}
#   }

As always, you may implement your own input filter with any callable object [adhering to the step interface])(#activity-internals-step-interface).

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => ->(ctx, **) do
        # only rename {:current_user} if it's there.
        ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
      end,
      In() => [:model]
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => ->(ctx, **) do
        # only rename {:current_user} if it's there.
        ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
      end,
      In() => [:model]
    # ...
  end
end

Callable In() filters have to return a hash. This hash will be merged with the other In() filters and comprise the new ctx.

And again, when the operation is invoked with a :current_user, this will, result in the same new ctx as above.

#=> {
#     :user  => #<User email:...>,
#     :model => #<Song name=nil>}
#   }

However, if :current_user is nil, Policy::Create will raise an exception complaining about the :user keyword missing.

Following the TRB option standard, an In() filter may even be implemented as an instance method. All you need to do is pass a symbol to In().

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => :input_for_policy, # You can use an {:instance_method}!
      In() => [:model]

    def input_for_policy(ctx, **)
      # only rename {:current_user} if it's there.
      ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
    end
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => :input_for_policy, # You can use an {:instance_method}!
      In() => [:model]

    def input_for_policy(ctx, **)
      # only rename {:current_user} if it's there.
      ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
    end
    # ...
  end
end

The method needs to expose a step interface just like any other callable.

Both callables and filter methods for In() can receive ctx variables as keyword arguments, making it a convenient access and have Ruby perform a loose existance test automatically.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
                    # vvvvvvvvvvvv keyword arguments rock!
      In() => ->(ctx, current_user: nil, **) do
        current_user.nil? ? {} : {user: current_user}
      end,
      In() => [:model]
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
                    # vvvvvvvvvvvv keyword arguments rock!
      In() => ->(ctx, current_user: nil, **) do
        current_user.nil? ? {} : {user: current_user}
      end,
      In() => [:model]
    # ...
  end
end

Keep in mind that when not defaulting the keyword argument your filter might crash at runtime when the expected variables were not passed.

Out()

Without any output configuration on the exemplary policy step, any variable written to ctx will be automatically set on the outer ctx, passing along internal variabes to the following step.

Here, both :status and :message variables that were written in Policy::Create are passed into the outer ctx. The behavior is identical to the way before you were using i/o.

However, it is often necessary to rename or limit the outgoing variables of a particular step. Especially when using nested operations you probably don’t want the entire nested ctx to be copied into the outer context. This is where output filters enter the stage.

Consider the following updated Policy::Create step.

module Policy
  # Explicit policy, not ideal as it results in a lot of code.
  class Create
    def self.call(ctx, model:, user:, **)
      decision = ApplicationPolicy.can?(model, user, :create) # FIXME: how does pundit/cancan do this exactly?

      if decision.allowed?
        return true
      else
        ctx[:status]  = 422 # we're not interested in this field.
        ctx[:message] = "Command {create} not allowed!"
        return false
      end
    end
  end
end

Both ctx[:status] and ctx[:message] will be visible in all steps following Policy::Create. This might lead to “misunderstandings” and bugs in more complex applications.

As soon as you use Out(), only variables specified through the filters will be merged with the original (outer) ctx and passed on to the next step.

In order to limit variables added to the outer ctx, Out() accepts an array similar to In(). Consider this as a whitelisting to specify exposed variables.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => [:message]
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => [:message]
    # ...
  end
end

This single Out() usage will result in only the :message variable being written to the outer ctx that is passed on. The :status variable is discarded.

You may pass any number of variables in the limiting array.

Renaming variables from the inner to the outer ctx works by providing a mapping hash, where the “old” inner name points to the outer name that you want to use in the operation hosting that step.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => {:message => :message_from_policy}
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => {:message => :message_from_policy}
    # ...
  end
end

Here, steps following Policy::Create will see a variable :message_from_policy merged into the ctx - which is the original :message, renamed.

An Out() filter can be any callable object following the step interface.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => ->(ctx, **) do
        return {} unless ctx[:message]

        { # you always have to return a hash from a callable!
          :message_from_policy => ctx[:message]
        }
      end
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => ->(ctx, **) do
        return {} unless ctx[:message]

        { # you always have to return a hash from a callable!
          :message_from_policy => ctx[:message]
        }
      end
    # ...
  end
end

The callable receives the inner ctx that just left the actual step, here Policy::Create. You may run any Ruby code in the callable, even ifs.

Note that a callable always must return a hash, which is then merged with the original outer ctx.

Be adviced that it is usually a better idea to maintain multiple smaller Out() callables for different variables. You might later decide to override them, debugging will be easier and the code is more maintainable. This was different when :output was the only way to filter outgoing variables and you had to create one big hash in a one single filter.

You may also use an :instance_method to filter outgoing variables, similar to how it’s done with In().

Just as with In() callables can receive keyword arguments.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => ->(ctx, message: nil, **) do
        return {} if message.nil?

        { # you always have to return a hash from a callable!
          :message_from_policy => message
        }
      end
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => ->(ctx, message: nil, **) do
        return {} if message.nil?

        { # you always have to return a hash from a callable!
          :message_from_policy => message
        }
      end
    # ...
  end
end

Any variable readable on the inner ctx that just left Policy::Create is available as a keyword argument for a callable. Note that you need to default it if its presence is not guaranteed.

You can access the outer, original ctx by passing the :with_outer_ctx option to Out().

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => [:message],

      Out(with_outer_ctx: true) => ->(inner_ctx, outer_ctx:, **) do
        {
          errors: outer_ctx[:errors].merge(policy_message: inner_ctx[:message])
        }
      end
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => [:message],

      Out(with_outer_ctx: true) => ->(inner_ctx, outer_ctx:, **) do
        {
          errors: outer_ctx[:errors].merge(policy_message: inner_ctx[:message])
        }
      end
    # ...
  end
end

While the callable still needs to return a hash that is then merged with the original ctx, it’s possible to access variables from the outer ctx through the :outer_ctx keyword argument. This allows for merging deeper data structures, such as error objects.

Inject()

An Inject() filter, as opposed to In(), does an existance check on the ctx using ctx.key?(:variable) before performing its logic. It is helpful in combination with In() filters, when using defaulted keyword arguments in a step or in nested operations.

  • It allows defaulting a variable when it’s absent in the ctx.
  • It can pass-through a variable when it is present in the ctx, and only then.
  • You can also statically set a variable, whether or not it is present using the :override option.

Note that Inject() can be used without In().

Check the following exemplary policy code.

module Policy
  class Check
                                    # vvvvvvvvvvvvvvv-- defaulted keyword arguments
    def self.call(ctx, model:, user:, action: :create, **)
      decision = ApplicationPolicy.can?(model, user, action) # FIXME: how does pundit/cancan do this exactly?
      # ...
    end
  end
end

This policy implementation uses keyword arguments to automatically extract :model, :user and :action from the ctx. Note that the latter is defaulted to :create. Defaulting kwargs only works when the keyword variable is not passed into the step - if it’s nil, the defaulting will not get triggered.

You could now use In() filters to embed this policy step into your operation.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Check,
      In() => {:current_user => :user},
      In() => [:model, :action]
    # ...
  end
end

However, this will break because the action variable will never be defaulted to :create. The In() filter will always pass :action through when calling the policy, even when it’s absent.

The Inject() helper is designed to handle this case.

Use Inject() in combination with In() to add variables to the filtered ctx, but only when they’re present in the outer ctx.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Check,
      In() => {:current_user => :user},
      In() => [:model],
      Inject() => [:action]
    # ...
  end
end

We call this qualified pass-through, it means the :action variable will only be passed into the filtered ctx if it exists on ctx when the filter is invoked.

Instead of hard-wiring defaulted keyword arguments into your step implementations, you can configure Inject() to set a default value to variables, if they’re absent in the ctx.

Here’s an example policy without any defaulting in the signature.

module Policy
  class Check
                                    # vvvvvvv-- no defaulting!
    def self.call(ctx, model:, user:, action:, **)
      decision = ApplicationPolicy.can?(model, user, action) # FIXME: how does pundit/cancan do this exactly?
      # ...
    end
  end
end

Defaulting the :action variable via Inject() will improve the policy component’s reusability.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Check,
      In() => {:current_user => :user},
      In() => [:model],
      Inject(:action) => ->(ctx, **) { :create }
    # ...
  end
end

The lambda is executed at runtime, just before the actual step is invoked. It provides access to the ctx object and allows extracting keyword arguments.

Use the :override option to always set a variable, even if it is already present in the incoming ctx.

Inject(:action, override: true) => ->(*) { :create } # always used.

This is helpful to set configuration variables for an activity while still using the well-established keyword argument mechanics. The Policy::Create class defines :action as a kwarg. This doesn’t necessarily mean the user can always inject and dictate the very value. Instead, we can override any injected value with the “hard-coded” :create value.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Check,
      In() => {:current_user => :user},
      In() => [:model],
      Inject(:action, override: true) => ->(*) { :create } # always used.
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :create_model
    step Policy::Check,
      In() => {:current_user => :user},
      In() => [:model],
      Inject(:action, override: true) => ->(*) { :create } # always used.
    # ...
  end
end

The call as below will always use action: :create, even if something else is injected.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Create,
  current_user: current_user,
  action: :update # this is always overridden.
)
puts ctx[:model] #=> #<Song id: 1, ...>
result = Song::Operation::Create.(
  current_user: current_user,
  action: :update # this is always overridden.
)
puts result[:model] #=> #<Song id: 1, ...>

Macro

As all DSL options the In(), Out() and Inject() helpers can be used from macros, providing the macro author a convenient way to define default filters. API docs

module Policy
  def self.Create()
    {
      task: Policy::Create,
      wrap_task: true,
      Trailblazer::Activity::Railway.In()  => {:current_user => :user},
      Trailblazer::Activity::Railway.In()  => [:model],
      Trailblazer::Activity::Railway.Out() => {:message => :message_from_policy},
    }
  end
end

In the options hash that a macro must return, you can use the helpers by referencing Trailblazer::Activity::Railway. Except for the prefixed constant, there is no difference or limitation to their usage.

They can be extended with options the macro user provides.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step Policy::Create(),
      Out() => {:message => :copied_message} # user options!
    # ...
  end
end

The user options will be merged into the macro options, resulting in :message being renamed to :message_from_policy and copied to :copied_message.

Before trailblazer-activity-dsl-linear-1.0.0 and the In() and Out() helper shipped with it, any :input from the user would always override the macro’s :input option.

Inheritance

Subclasses can add and remove input and output filters - hence the term composable. This is a great tool when inherited operations replace particular steps and need to fine-tune ingoing or returned variables.

Consider the following base operation.

module Song::Activity
  class Create < Trailblazer::Activity::Railway

    step :create_model
    step Policy::Create,
      In() => {:current_user => :user},
      In() => [:model],
      Out() => [:message],
      id: :policy
    # ...
  end
end

It defines two input and one output filter.

A sub operation could now replace the policy step. However, instead of redefining the i/o filters, they can be inherited and extended.

Here’s a potential inheriting operation.

module Song::Activity
  class Admin < Create
    step Policy::Create,
      Out() => {:message => :raw_message_for_admin},
      inherit: [:variable_mapping],
      id: :policy,      # you need to reference the :id when your step
      replace: :policy
  end
end

This configuration is adding another Out() filter, resulting in a total filter setup as follows in the introspection.

puts Trailblazer::Developer::Render::TaskWrap.(Song::Activity::Admin, id: :policy)

Song::Activity::Admin
# `-- policy
#     |-- task_wrap.input..................Trailblazer::Activity::DSL::Linear::VariableMapping::Pipe::Input
#     |   |-- input.init_hash.............................. ............................................. VariableMapping.initial_aggregate
#     |   |-- input.add_variables.0.994[...]............... {:current_user=>:user}....................... VariableMapping::AddVariables
#     |   |-- input.add_variables.0.592[...]............... [:model]..................................... VariableMapping::AddVariables
#     |   `-- input.scope.................................. ............................................. VariableMapping.scope
#     |-- task_wrap.call_task..............Method
#     `-- task_wrap.output.................Trailblazer::Activity::DSL::Linear::VariableMapping::Pipe::Output
#         |-- output.init_hash............................. ............................................. VariableMapping.initial_aggregate
#         |-- output.add_variables.0.599[...].............. [:message]................................... VariableMapping::AddVariables::Output
#         |-- output.add_variables.0.710[...].............. {:message=>:raw_message_for_admin}........... VariableMapping::AddVariables::Output
#        `-- output.merge_with_original................... ............................................. VariableMapping.merge_with_original

The new Out() filter setting :raw_message_for_admin is placed behind the inherited filter.

Note that inherit: true will, besides other options, also invoke the variable mapping inheritance.

Introspect

You can visualize the pipelines around each step by using the trailblazer-developer gem.

puts Trailblazer::Developer::Render::TaskWrap.(Song::Activity::Create, id: :policy)

This handy invocation will render the task wrap around Song::Operation::Create’s step :policy.

Song::Activity::Create
`-- policy
    |-- task_wrap.input..................Trailblazer::Activity::DSL::Linear::VariableMapping::Pipe::Input
    |   |-- input.init_hash.............................. ............................................. VariableMapping.initial_aggregate
    |   |-- input.add_variables.0.994[...]............... {:current_user=>:user}....................... VariableMapping::AddVariables
    |   |-- input.add_variables.0.592[...]............... [:model]..................................... VariableMapping::AddVariables
    |   `-- input.scope.................................. ............................................. VariableMapping.scope
    |-- task_wrap.call_task..............Method
    `-- task_wrap.output.................Trailblazer::Activity::DSL::Linear::VariableMapping::Pipe::Output
        |-- output.init_hash............................. ............................................. VariableMapping.initial_aggregate
        |-- output.add_variables.0.599[...].............. [:message]................................... VariableMapping::AddVariables::Output
        `-- output.merge_with_original................... ............................................. VariableMapping.merge_with_original

In i/o context, the interesting branches here are task_wrap.input and task_wrap.output. Sandwiched between generic library steps are your filter steps. The visualizer even renders filter configuration where possible.

We’re planning improvements on this part of trailblazer-developer. If you want to help out with better rendering, please come chat to us.

Input / Output

With trailblazer-2.1.1 and the bundled trailblazer-activity-dsl-linear-1.0.0 gems, the recommended way of I/O is using composable variable mapping via In() and Out().

Before the introduction of the composable In(), Out() and Inject() filters, variable mapping was done with the :input and :output option. This is still supported and not planned to be dropped. However, there are a bunch of drawbacks with using the monolithic, non-composable options.

  • Once used, the :input, :output and :inject option will overwrite any options set earlier (or later) via In(), Out() and Inject(). This will often lead to problems when using macros.
  • The superseded options are basically impossible to debug, whereas the composable In() approach can nicely display the computed set of variables going in or out of a step.
  • In future versions of Trailblazer we’re planning automatic “contracts” for steps along with type checking. This is not possible with the monolithic :input option.

The :input option accepts any callable following the option interface.

step :create_model,
  input: :input_for_create_model
  # becomes
  In() => :input_for_create_model

:input works identically to a single In() call. API docs

The :output option, just like :input, accepts any callable following the option interface.

step :create_model,
  output: :output_for_create_model
  # becomes
  Out() => :output_for_create_model

:output works identically to a single Out() call. API docs

The :output_with_outer_ctx option is documented here.

step :create_model,
  output: :output_for_create_model,
  output_with_outer_ctx: true
  # becomes
  Out(with_outer_ctx: true) => :output_for_create_model

:inject works identically to a single Inject() call. API docs

step :create_model,
  inject: :inject_for_create_model
  # becomes
  Inject() => :inject_for_create_model

Dependency Injection

WIP: This section is not final, yet.

Overview

Very often your activity or one of the steps contained require particular objects and values to get their job done. Instead of hard-wiring those “dependencies” in the code it is good style to allow providing those objects by passing them into the activity at run-time. This is called dependency injection and is a common technique in software engineering.

One way for using dependency injection is using keyword arguments for variables you need, and defaulting those in the step signature.

Mapping

TODO

Dry container

TODO

defaulting in macros

Macro API

Macros are short-cuts for inserting a task along with options into your activity.

Definition

They’re simple functions that return a hash with options described here.

module MyMacro
  def self.NormalizeParams(name: :myparams, merge_hash: {})
    task = ->((ctx, flow_options), _) do
      ctx[name] = ctx[:params].merge(merge_hash)

      return Trailblazer::Activity::Right, [ctx, flow_options]
    end

    # new API
    {
      task: task,
      id:   name
    }
  end
end

Two required options are :id and :task, the latter being the actual task you want to insert. The callable task needs to implement the [circuit interface].

Please note that the actual task doesn’t have to be a proc! Use a class, constant, object, as long as it exposes a #call method it will flow.

Usage

To actually apply the macro you call the function in combination with step, pass, fail, etc.

class Create < Trailblazer::Activity::Railway
  step MyMacro::NormalizeParams(merge_hash: {role: "sailor"})
end

There’s no additional logic from Trailblazer happening here. The function returns a well-defined hash which is passed as an argument to step.

Options

In the returned hash you may insert any valid DSL [step option], such as sequence options like :before, Output() and friends from the wiring API or even :extensions.

The following FindModel macro retrieves a configured model just like trailblazer-macro’s Model() and automatically wires the step’s failure output to a new terminus not_found.

module MyMacro
  def self.FindModel(model_class)
    # the inserted task.
    task = ->((ctx, flow_options), _) do
      model         = model_class.find_by(id: ctx[:params][:id])

      return_signal = model ? Trailblazer::Activity::Right : Trailblazer::Activity::Left
      ctx[:model]   = model

      return return_signal, [ctx, flow_options]
    end

    # the configuration needed by Trailblazer's DSL.
    {
      task: task,
      id:   :"find_model_#{model_class}",
      Trailblazer::Activity::Railway.Output(:failure) => Trailblazer::Activity::Railway.End(:not_found)
    }
  end
end

See how you can simply add Output wirings by using the well-established mechanics from the wiring API? Remember you’re not in an Activity or Operation namespace and hence need to use the fully-qualified constant reference Trailblazer::Activity::Railway.Output().

To insert that step and its extended wiring, simply call the macro.

class Create < Trailblazer::Activity::Railway
  step MyMacro::FindModel(User)
end

When running the activity without a valid model ID, it will now terminate on End.not_found.

signal, (ctx, _) = Trailblazer::Developer.wtf?(User::Create, [{params: {id: nil}}])
signal #=> #<Trailblazer::Activity::End semantic=:not_found>

`-- User::Create
    |-- Start.default
    |-- find_model_User
    `-- End.not_found

Using the wiring API in your own macros gives you a powerful tool for harnessing extended wiring without requiring the user to know about the details - the crucial point for a good API.

You can even use other macros in custom macros, such as the Subprocess() helper for nesting activities.

Consider the following Logger activity.

class Logger < Trailblazer::Activity::Railway
  step :log

  def log(ctx, logged:, **)
    ctx[:log] = logged.inspect
  end
end

Along with the nested Logger step should also go :input and :output configuration. When using the Logger in multiple operation, you would need to repeat the options, so why not pack the entire configuration in a macro?

module Macro
  def self.Logger(logged_name: )
    {
      id: "logger",
      input:  {logged_name => :logged},
      output: [:log],
      **Trailblazer::Activity::Railway.Subprocess(Logger), # nest
    }
  end
end

The nesting activity can now elegantly use the macro without inconvenient options.

class Create < Trailblazer::Activity::Railway
  step Macro::Logger(logged_name: :model) # we want to log {ctx[:model]}
end

Internals

This section discusses low-level structures and is intended for engineers interested in changing or adding their own DSLs, the activity build process, or who want to optimize the Trailblazer internals (which is always appreciated!).

Introspection API

trailblazer-activity 0.16.0

To introspect an activity and find out about steps, IDs and configuration, use the Introspect.Nodes() function.

Consider the following activity.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :validate
    step Subprocess(Save),
      id: :save
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step :validate
    step Subprocess(Save),
      id: :save
  end
end

You can retrieve a datastructure describing a particular task by ID.

attrs = Trailblazer::Activity::Introspect.Nodes(Song::Activity::Create, id: :validate)

The returned Attributes object exposes #id, #data and #task.

puts attrs.id   #=> :validate
puts attrs.task #=> #<Trailblazer::Activity::TaskBuilder::Task user_proc=validate>
puts attrs.data[:extensions] => []

It also accepts a :task option if you need to find attributes for a step where you only know the code component, not the ID. This is, for example, used in the tracing code.

attrs = Trailblazer::Activity::Introspect.Nodes(Song::Activity::Create, task: Song::Activity::Save)
attrs.id #=> :save

The returned Attributes instance behaves identically to the above.

Note that you can use Attributes to query custom data from the DSL.

Circuit Interface

Activities and all tasks (or “steps”) are required to expose a circuit interface. This is the low-level interface. When an activity is executed, all involved tasks are called with that very signature.

Most of the times it is hidden behind the task interface that you’re probably used to from your operations when using step. Under the hood, however, all callable circuit elements operate through that very interface.

The circuit interface consists of three things.

  • A circuit element has to expose a call method.
  • The signature of the call method 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::Nodes(B::Create, id: :validate).task
}

signal, (ctx, flow_options) = B::Create.([ctx, flow_options], **circuit_options)

Starting with :validate, the :create task will be skipped and only :validate and then :save will be executed.

Note that this is a low-level option that should not be used to build “reuseable” activities. If you want different behavior for differing contexts, you should compose different activities.

When using the step :method_name DSL style, the :exec_context option controls what object provides the method implementations at runtime.

Usually, Activity#call will automatically set this, but you can invoke the circuit instead, and inject your own exec_context. This allows you to have a separate structure and implementation.

The following activity is such an “empty” structure.

class Create < Trailblazer::Activity::Railway
  step :create
  step :save
end

You may then use a class, object or module to define the implementation of your steps.

class Create::Implementation
  def create(ctx, params:, **)
    ctx[:model] = Memo.new(params)
  end

  def save(ctx, model:, **)
    ctx[:model].save
  end
end
    end

This is really just a container of the desired step logic, with the familiar interface.

When invoking the Create activity, you need to call the circuit directly and inject the :exec_context option.

circuit_options = {
  exec_context: C::Create::Implementation.new
}

signal, (ctx, flow_options) = C::Create.to_h[:circuit].([ctx, flow_options], **circuit_options)

While this bypasses Activity#call, it gives you a powerful tool for advanced activity design.

When using the DSL, use the :task option if you want your added task to be called directly with the circuit interface. This skips the TaskBuilder::Binary wrapping.


class Create < Trailblazer::Activity::Railway
  # ...
  step task: method(:validate)
end

Step Interface

a.k.a. Task interface

The convenient high-level interface for a task implementation is - surprisingly - called task interface. It’s the one you will be working with 95% of your time when writing task logic.

This interface comprises of two parts.

  • The signature receives a mutable ctx object, and an optional list of keywords, often seen as (ctx, **).
  • The return value can be true, false, or a subclass of Activity::Signal to dictate the control flow.

The return value does not control what is the next task. Instead, it informs the circuit runner about its outcome, and the circuit runner will find the task executed next.

module Memo::Operation
  class Create < Trailblazer::Activity::Railway
    def self.create_model(ctx, **)
      attributes = ctx[:attrs]           # read from ctx

      ctx[:model] = Memo.new(attributes) # write to ctx

      ctx[:model].save ? true : false    # return value matters
    end

    step method(:create_model)
    # ...
  end
end

Components (such as methods or callable objects) exposing the step interface always receive the ctx as the first (and only) positional argument. Keyword arguments may be used to extract variables from the ctx.

Depending on the step’s logic, you can write variables to the ctx object.

The return value can be either a subclass of Trailblazer::Activity::Signal or it will be evaluated to true or false.

A cleaner way to access data from the ctx object is to use keyword arguments in the method signature. Trailblazer makes all ctx options available as kw args.

def self.create_model(ctx, attrs:, **) # kw args!
  ctx[:model] = Memo.new(attrs)        # write to ctx

  ctx[:model].save ? true : false      # return value matters
end

You may use as many keyword arguments as you need - it will save you reading from ctx manually, gives you automatic presence checks, and allows defaulting, too.

Using the DSL, your task will usually be wrapped in a TaskBuilder::Binary object, which translates a nil and false return value to an Activity::Left signal, and all other return values to Activity::Right.

def self.create_model(ctx, attrs:, **) # kw args!
  # ...
  ctx[:model].save ? true : false      # return value matters
end

In a Railway activity, a true value will usually result in the flow staying on the “success” path, where a falsey return value deviates to the “failure” track. However, eventually it’s the developer’s decision how to wire signals to connections.

You are not limited to true and falsey return values. Any subclass of Activity::Signal will simply be passed through without getting “translated” by the Binary wrapper. This allows to emit more than two possible states from a task.

module Memo::Operation
  class Create < Trailblazer::Activity::Railway
    DatabaseError = Class.new(Trailblazer::Activity::Signal) # subclass Signal

    def create_model(ctx, attrs:, **)
      ctx[:model] = Memo.new(attrs)

      begin
        return ctx[:model].save ? true : false  # binary return values
      rescue
        return DatabaseError                    # third return value
      end
    end
    # ...

    step :create_model,
      Output(DatabaseError, :handle_error) => Id(:handle_db_error)
    step :handle_db_error,
      magnetic_to: nil, Output(:success) => Track(:failure)
  end
end

The exemplary DatabaseError is being passed through to the routing and interpreted. It’s your job to make sure this signal is wired to a following task, track, or end (line 16).

Note that you don’t have to use the default binary signals at all (Left and Right). API docs

The most convenient way is to use instance methods. Those may be declared after the step definitions, allowing you to first define the flow, then implement it.

class Memo::Create < Trailblazer::Activity::Railway
  step :authorize
  # ...

  def authorize(ctx, current_user:, **)
    current_user.can?(Memo, :create)
  end
end

Use :method_name to refer to instance methods.

Do not use instance variables (@ivar) ever as they’re not guaranteed to work as expected. Always transport state via ctx.

A class method can implement a task of an activity. It needs to be declared as a class method using self.method_name and must precede the step declaration. Using Ruby’s #method, it can be passed to the step DSL.

class Memo::Create < Trailblazer::Activity::Railway
  def self.authorize(ctx, current_user:, **)
    current_user.can?(Memo, :create)
  end

  step method(:authorize)
end

Instead of prefixing every method signature with self. you could use Ruby’s class << self block to create class methods.

class Memo::Create < Trailblazer::Activity::Railway
  class << self
    def authorize(ctx, current_user:, **)
      current_user.can?(Memo, :create)
    end
    # more methods...
  end

  step method(:authorize)
end

In TRB 2.0, instance methods in operations were the preferred way for implementing tasks. This was a bit more convenient, but required the framework to create an object instance with every single activity invocation. It also encouraged users to transport state via the activity instance itself (instead of the ctx object), which led to bizarre issues.

Since 2.1, the approach is as stateless and functional as possible, as we now prefer class methods.

As a matter of fact, you can use any callable object. That means, any object that responds to #call is suitable as a task implementation.

class Memo::Create < Trailblazer::Activity::Railway
  # ...
  step AuthorizeForCreate
end

When using a class, it needs to expose a class method #call. This is ideal for more complex task code that needs to be decomposed into smaller private methods internally.

class AuthorizeForCreate
  def self.call(ctx, current_user:, **)
    current_user.can?(Memo, :create)
  end
end

The signature of #call is identical to the other implementation styles.

Keep in mind that you don’t have to implement every task in the activity itself - it can be outsourced to a module.

module Authorizer
  module_function

  def memo_create(ctx, current_user:, **)
    current_user.can?(Memo, :create)
  end
end

When using module_function, every method will be a “class” method automatically.

In the activity, you can reference the module’s methods using our old friend method.

class Memo::Create < Trailblazer::Activity::Railway
  step Authorizer.method(:memo_create)
  # ...
end

TaskWrap

The taskWrap is the “around_filter” of Trailblazer. It allows adding steps before and after actual tasks without having to change the activity, and without having to introduce ifs.

Some prominent examples for taskWrap usage in the wild are variable mapping and tracing happening in #wtf?. Those are features completely separated from the core code. You can use the taskWrap mechanics to inject static wraps at compile time (as it’s done for, say, the In() feature), or at runtime, the way #wtf? is built.

It’s possible to configure a specific taskWrap for a particular step, or run the same one for all steps encountered while running an activity, even with nesting.

Example

Let’s discuss the taskWrap with the following simple Create activity that you’ve probably met before.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step :validate
    left :handle_errors
    step :notify
    # ...
  end
end

We’re using an imaginary MyAPM gem that provides a convenient interface for starting and stopping instrumentation.

span = MyAPM.start_span("validate", payload: {time: Time.now})
# do whatever you have to...
span.finish(payload: {time: Time.now})

Step

In order to execute the instrumentation, we have to write two taskWrap steps, one to start, one for finishing the instrumentation. Those steps look similar to “ordinary” activity steps, but expose the taskWrap interface.

module MyAPM # Advanced performance monitoring, done right!
  module Extension
    def self.start_instrumentation(wrap_ctx, original_args)
      (ctx, _flow_options), circuit_options = original_args

      activity  = circuit_options[:activity] # currently running Activity.
      task      = wrap_ctx[:task]            # the current "step".

      task_id   = Trailblazer::Activity::Introspect.Nodes(activity, task: task).id

      span      = MyAPM.start_span("operation.step", payload: {id: task_id})

      wrap_ctx[:span] = span

      return wrap_ctx, original_args
    end
  end
end

A taskWrap step receives two positional arguments, the wrap_ctx that is used to transport taskWrap-specific data across taskWrap steps, and the original_args that represent how the circuit called the currently invoked task.

Above, you can see how it’s possible to retrieve the currently invoked task and its activity.

We then store the span object in the wrap_ctx so the finishing code can close that span in the second taskWrap step #finish_instrumentation.

module MyAPM # Advanced performance monitoring, done right!
  module Extension
    def self.finish_instrumentation(wrap_ctx, original_args)
      ctx   = original_args[0][0]
      span  = wrap_ctx[:span]

      span.finish(payload: ctx.inspect)

      return wrap_ctx, original_args
    end
  end
end

As you can see, the span object can be fetched from wrap_ctx as the second step is run somewhere after start_instrumentation.

call_task

Each taskWrap has at least one element, the call_task step that calls the actual task. Sometimes this task is a method, such as #validate, sometimes a nested activity, and so on.

Runtime

The taskWrap can be extended at runtime, when calling an activity. This allow for adding features to steps without having to change the actual business code.

To actually run the Song::Activity::Create activity with this new taskWrap, we need to build a runtime extension.

apm_extension = Trailblazer::Activity::TaskWrap::Extension(
  [MyAPM::Extension.method(:start_instrumentation),  id: "my_apm.start_span",  prepend: "task_wrap.call_task"],
  [MyAPM::Extension.method(:finish_instrumentation), id: "my_apm.finish_span", append: "task_wrap.call_task"],
)

You can configure where to insert the taskWrap steps using :append or :prepend.

We’re now all set to inject that new taskWrap into the execution of our business activity. In the following example, the my_wrap hash is configured in a way that our extension is run around every task in Create, even for Create itself.

my_wrap = Hash.new(apm_extension)

Song::Activity::Create.invoke(
  [
    # ctx:
    {
      song: {title: "Timebomb"},
      seq: []
    }
  ],
  wrap_runtime: my_wrap # runtime taskWrap extensions!
)

This will result in the same taskWrap being applied to every step of the activity execution path, and for #validate, looks like so.

You don’t have to run a taskWrap extension around every step. It is possible to configure an extension only being applied to a particular step in your activity. Let’s invoke the new APM taskWrap only around #validate.

validate_task = Trailblazer::Activity::Introspect
  .Nodes(Song::Activity::Create, id: :validate) # returns Node::Attributes
  .task                                         # and the actually executed task from the circuit.

my_wrap = {validate_task => apm_extension}

Note that my_wrap can map specific tasks to their taskWrap extension.

When running Create with the new configuration, the APM code is only called for #validate.

Song::Activity::Create.invoke(
  [
    # ctx:
    {
      song: {title: "Timebomb"},
      seq: []
    }
  ],
  wrap_runtime: my_wrap # runtime taskWrap extensions!
)

It’s important to understand that even the top activity Song::Activity::Create is run using a taskWrap, so if you want to apply APM only to this particular “step”, you could do so as follows.

my_wrap = {Song::Activity::Create => apm_extension}

Static

Instead of dynamically adding taskWrap extensions at runtime, in some cases you might want to do so when defining the activity class, at compile-time. For example, the In() and Out() filter DSL uses a static taskWrap extension to add steps around a specific task.

This can be achieved with the WrapStatic() helper.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :create_model
    step :validate,
      Extension() => Trailblazer::Activity::TaskWrap::Extension::WrapStatic(
        [MyAPM::Extension.method(:start_instrumentation),  id: "my_apm.start_span",  prepend: "task_wrap.call_task"],
        [MyAPM::Extension.method(:finish_instrumentation), id: "my_apm.finish_span", append: "task_wrap.call_task"],
      )
    left :handle_errors
    step :notify
    include T.def_steps(:create_model, :validate, :notify)
  end
end

Obviously, you’d introduce a macro here to hide all those specific details to the user of your extension.

When running, the APM instrumentation is run only around #validate.

signal, (ctx, _) = Song::Activity::Create.invoke([ctx, {}])

Introspection

You can use our web-based debugger to introspect the static taskWrap of any step.

Troubleshooting

Even though tracing and wtf? attempt to make your developer experience as smooth as possible, sometimes there are annoying issues.

Type Error

It’s a common error to use a bare Hash (with string keys!) instead of a Trailblazer::Context object when running an activity. While symbolized hashes are not a problem, string keys will fail.

ctx = {"message" => "Not gonna work!"} # bare hash.
Bla.([ctx])

The infamous TypeError means your context object can’t convert strings into symbol keys. This is required when calling your steps with keyword arguments.

TypeError: wrong argument type String (expected Symbol)

Use Trailblazer::Context as a wrapper.

ctx = Trailblazer::Context({"message" => "Yes, works!"})

signal, (ctx, _) = Bla.([ctx])

The Context object automatically converts string keys to symbols.

Wrong circuit

When using the same task multiple times in an activity, you might end up with a wiring you’re not expecting. This is due to Trailblazer internally keying tasks by their object identity.

class Update < Trailblazer::Activity::Railway
  class CheckAttribute < Trailblazer::Activity::Railway
    step :valid?
  end

  step :find_model
  step Subprocess(CheckAttribute), id: :a
  step Subprocess(CheckAttribute), id: :b # same task!
  step :save
end

When introspecting this activity, you will see that the CheckAttribute task is present only once.

You need to create a copy of the method or the class of your callable task in order to fix this and have two identical steps.

class Update < Trailblazer::Activity::Railway
  class CheckAttribute < Trailblazer::Activity::Railway
    step :valid?
  end

  step :find_model
  step Subprocess(CheckAttribute), id: :a
  step Subprocess(Class.new(CheckAttribute)), id: :b # different task!
  step :save
end

Illegal Signal Error

As the name suggests, the IllegalSignalError exception is raised when a step returns a signal that is not registered at compile time. The routing algorithm is not able to find a connection for the returned signal and raises an error at run-time.

Usually, you encounter this beautiful exception when using the circuit interface signature for a step, and returning a “wrong” signal that is not wired to an on-going next task.

Other common cases may be

  • Steps which are not wrapped by [TaskBuilder], for example: step task: method(:validate)
  • User defined macros.
class Create < Trailblazer::Activity::Railway
  def self.validate((ctx, flow_options), **circuit_options)
    return :invalid_signal, [ctx, flow_options], circuit_options
  end

  step task: method(:validate)
end

ctx = {"message" => "Not gonna work!"} # bare hash.
Create.([ctx])

# IllegalSignalError: Create:
# Unrecognized Signal `:invalid_signal` returned from `Method: Create.validate`. Registered signals are,
# - Trailblazer::Activity::Left
# - Trailblazer::Activity::Right

The exception helps by displaying both the actually returned signal and the registered, wired signals for this step.