Macro

  • Last updated 10 Aug 23

Overview

trailblazer-macro >= 2.1.11

Macros provide shorthand methods to create and configure a particular step in your operation. Trailblazer ships with a handful of macros. Those are implemented in trailblazer-macro and trailblazer-macro-contract.

Writing you own macros is a very simple thing to do if you follow the macro API. API docs

General notes

Most macros internally use variable mapping to allow injection of configuration variables or to limit their own scope. Macros do so by leveraging the composable API using In(), Inject() and Out().

This allows you to add your own variable mapping to a macro, as illustrated in the example below.

module Song::Activity
  class Create < Trailblazer::Operation
    step Model(Song, :find_by),
      In() => ->(ctx, my_id:, **) { ctx.merge(params: {id: my_id}) } # Model() needs {params[:id]}.
    # ...
  end
end

When running the above operation, the exemplary Model() macro will pick the ID from :my_id.

result = Create.(my_id: 1)

Please note that you shall not use :input to configure your macro as it will override internal filters and breaks the macro’s configuration. Go with the new composable variable mapping which is available from trailblazer 2.1.1 and onwards.

Nested

Only use the Nested() macro if you’re planning to nest an activity that can only be chosen at runtime.

  • If you know the nested activity upfront, use Subprocess().
  • If you know the activities upfront, and need only need to choose at run-time, use the :auto_wire option.

Dynamic

Use Nested() to dynamically decide which nested activity to invoke, but when you don’t know all activities to pick from. This is sometimes the case in polymorphic setups, when a part of an operation needs to be “decided” at runtime, with no way to know what this will look like when writing the operation class.

Consider the following activity to create a song. Depending on the input in params, you either want to run a nested Id3Tag processor activity for an MP3 song, or VorbisComment at a specific point during the song creation.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :model
    step Nested(:decide_file_type) # Run either {Id3Tag} or {VorbisComment}
    step :save
    # ...
    def decide_file_type(ctx, params:, **)
      params[:type] == "mp3" ? Id3Tag : VorbisComment
    end
  end
end

When using Nested() with only one argument, the decider, we call this the “dynamic style”. Since we don’t know the possible nested activities upfront, the macro needs to compile several internals at runtime, which will be slower than its static equivalent.

The decider can be any “option style” callable: an instance method in the hosting Create activity, any lambda or proc, or a callable object or class. In our example, it’s an instance method.

The decider receives ctx and keyword arguments just like any other step. It is run directly before the nested activity is run, it’s return value decides about which one that will be: Id3Tag or VorbisComment.

The nested activity (or operation) will, per default, receive the same ctx that a step in place of Nested() would receive.

module Song::Activity
  class Id3Tag < Trailblazer::Activity::Railway
    step :parse
    step :encode_id3
    # ...
  end
end

Auto_wire

The recommended way of maintaining dynamically nested operations (or activities) is to use Nested() with the :auto_wire option. It works exactly like its purely dynamic counterpart but requires you to specify all possible nested activities at compile-time.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :model
    step Nested(:decide_file_type,
      auto_wire: [Id3Tag, VorbisComment]) # explicitely define possible nested activities.
    step :save
    # ...

    def decide_file_type(ctx, params:, **)
      params[:type] == "mp3" ? Id3Tag : VorbisComment
    end
  end
end

The :auto_wire option expects an array of nested activities. While this might seem a bit clumsy, this brings several benefits. First of all, execution is much faster since the entire activity graph is created at compile-time. Second, you can use the Debugging API to introspect things.

You can use any type of decider step. In this example, it’s a instance method #decide_file_type on the Nested()-hosting class.

Internally, the “auto-wire” Nested() macro simply returns a small activity as illustrated above in gray. It has a decide step to figure out which nested activity to run, then runs the actual nested activity. All well-known termini (success, failure, pass_fast, fail_fast) of the nested activities are automatically connected to the Nested()’s activity’s termini.

Given we had a nested activity VorbisComment implemented like so.

module Song::Activity
  class VorbisComment < Trailblazer::Activity::Railway
    step :prepare_metadata
    step :encode_cover, Output(:failure) => End(:unsupported_file_format)
    # ...
  end
end

If the #encode_cover step fails, the activity will stop in the End.unsupported_file_format terminus - an end event very specific to the VorbisComment activity.

This new terminus has to be wired explicitely in the nesting activity.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step :model
    step Nested(
        :decide_file_type,
        auto_wire: [Id3Tag, VorbisComment]
      ),
      # Output and friends are used *after* Nested().
      # Connect VorbisComment's {unsupported_file_format} to our {failure} track:
      Output(:unsupported_file_format) => Track(:failure)

    step :save
    # ...
  end
end

Using Output(:unsupported_file_type) from the Wiring API you can connect this specific terminus to wherever you need it, here it’s routed to the failure track in Create. Note that Wiring API options are used after Nested(...), not within its parenthesis.

The complete activity graph would look a bit like the following diagram.

Compliance

Nested setups are traceable using #wtf?.

Trailblazer::Developer.wtf?(Song::Activity::Create, params: {type: "vorbis"})

The above examples result in a flow as follows.

The trace is fully compatible with the Debugging API and allows compile-time introspection. For example, you can specify the path to a nested activity Id3Tag and inspect it.

Trailblazer::Developer.render(Song::Activity::Create,
  path: [
    "Nested/decide_file_type", # ID of Nested()
    Song::Activity::Id3Tag      # ID of the nested {Id3Tag} activity.
  ]
)

Note that the ID of the nested activities are the class constants.

Wrap

Sometimes you need to run a sequence of steps within some block you provide. Often, this is required when certain steps must be wrapped in a database transaction. The Wrap() macro does just that.

module Song::Activity
  class Upload < Trailblazer::Activity::FastTrack
    step :model
    step Wrap(MyTransaction) {
      step :update   # this changes the database.
      step :transfer # this might even break!
    }
    step :notify
    fail :log_error
    # ...
  end
end

In an imaginary song Upload operation that transfers a music file to some streaming service platform while updating fields on the database, needs the steps #update and #transfer being run inside a transaction. The code running and interpreting this block is provided via the custom transaction MyTransaction.

Wrap handler

The most simple “transaction” (we call it wrap handler) could look as follows.

class MyTransaction
  def self.call((ctx, flow_options), **, &block)
    signal, (ctx, flow_options) = yield # calls the wrapped steps

    return signal, [ctx, flow_options]
  end
end

Your transaction handler can be any type of [callable] exposing the circuit interface.

In the most simple transaction ever written, we simply run the wrapped steps by calling yield. This will return a circuit-interface return set. The interesting parts are the returned ctx, which is the ctx that left the wrapped steps, and the signal, which indicates the outcome of running the wrapped steps.

Here, we simply return the original signal of the wrapped activity.

Note that internally #update and #transfer are put into a separate activity. The terminus reached in that activity is the signal.

Given that both #update and #transfer are railway steps, the wrapped code can terminate in a success terminus, or a failure. Now, it’s your handler that is responsible to interpret that. In the example above, we simply pass on the wrapped activity’s terminal signal, making the Upload activity either continue from success or from failure.

Let’s assume the #transfer step had a custom output that goes to a super-special terminus End.timeout.

step Wrap(MyTransaction) {
  step :update   # this changes the database.
  step :transfer,
    Output(:failure) => End(:timeout) # creates a third terminus.
},

The resulting wrapped activity now has three termini.

With our handler, which simply returns the wrapped activity’s signal, it’s possible to route the :timeout signal in Upload. For instance, if a :timeout should be resulting in Upload jumping to the fail_fast track after #transfer terminated in timeout, you can do so.

step Wrap(MyTransaction) {
  step :update   # this changes the database.
  step :transfer,
    Output(:failure) => End(:timeout) # creates a third terminus.
},
  Output(:timeout) => Track(:fail_fast) # any wiring is possible here.

Here’s what the circuit would look like.

Transaction

Wrapping steps into a real database transaction, and rolling back in case of a failure outcome in one of the steps, could look like so.

class MyTransaction
  def self.call((ctx, flow_options), **)
    handler_signal = nil # this is the signal we decide to return from Wrap().

    ActiveRecord::Base.transaction do
      signal, (ctx, flow_options) = yield # call the Wrap block

      # It's now up to us to interpret the wrapped activity's outcome.
      terminus_semantic = signal.to_h[:semantic]

      if [:success, :pass_fast].include?(terminus_semantic)
        handler_signal = Trailblazer::Activity::Right
      else # something in the wrapped steps went wrong:
        handler_signal = Trailblazer::Activity::Left

        raise ActiveRecord::Rollback # This is the only way to tell ActiveRecord to rollback!
      end
    end # transaction

    return handler_signal, [ctx, flow_options]
  end
end

This might look a bit clumsy, but most of the code is very straight-forward.

  1. Your main intent, wrapping a sequence of steps into an ActiveRecord::Base.transaction block, contains the yield invocation that runs the wrapped steps.
  2. Having the signal of the wrapped activity returned, you can decide how you’d like to interpret that. Usually, looking at the signals :semantic field indicates the outcome.
  3. In case you’re not happy, a raise ActiveRecord::Rollback will make ActiveRecord undo whatever database commits happened in the block.
  4. Instead of returning the nested activity’s signal, which is completely legit, Wrap() also allows you to return a Right or Left signal to communicate the outcome of the entire Wrap() component.

Exception handling

Catching and intercepting exceptions in Wrap() works identical to transactions. In case you don’t want to use our canonical Rescue() macro here is a sample wrap handler that uses rescue for intercepting exceptions thrown in #update or #transfer.

class MyRescue
  def self.call((ctx, flow_options), **, &block)
    signal, (ctx, flow_options) = yield # calls the wrapped steps

    return signal, [ctx, flow_options]
  rescue
    ctx[:exception] = $!.message
    return Trailblazer::Activity::Left, [ctx, flow_options]
  end
end

With any exception being thrown, the original signal from the wrapped activity is returned, as if the steps were part of the Upload operation flow.

The code below rescue is only run when an exception was thrown. Here, we write a new variable :exception to the ctx and return Left, indicating an error.

Compliance

You can use tracing with Wrap().

Trailblazer::Developer.wtf?(Song::Activity::Upload, params: {id: 1})

The trace per default shows Wrap’s ID.

You can use any Introspect mechanics on activities using Wrap().

node, _ = Trailblazer::Developer::Introspect.find_path(
  Song::Activity::Upload,
  ["Wrap/MyTransaction", :transfer])
#=> #<Node ...>

Wrap() is compatible with the Patch API, as in, you may replace nested steps or entire activities within the wrapped part.

Consider the Upload activity used throughout this example.

module Song::Activity
  class Upload < Trailblazer::Activity::FastTrack
    step :model
    step Wrap(MyTransaction) {
      step :update   # this changes the database.
      step :transfer # this might even break!
    }
    step :notify
    fail :log_error
    # ...
  end
end

Say you want to replace the #update step within the wrap with a new step #upsert, you can use Patch as so.

upload_with_upsert = Trailblazer::Activity::DSL::Linear::Patch.call(
  Song::Activity::Upload,
  ["Wrap/MyTransaction"],
  -> { step :upsert, replace: :update }
)

The returned new activity upload_with_upsert is a copy of Upload with the respective step replace. Note that in this very example, the method #upsert has to be included in the nested activity.

Each

trailblazer-macro >= 2.1.12

The Each() macro allows to loop over a dataset while invoking a particular operation per iteration, as if multiple identical operations were serially connected, but receiving different input ctxs.

module Song::Activity
  class Cover < Trailblazer::Activity::Railway
    step :model
    step Each(dataset_from: :composers_for_each) {
      step :notify_composers
    }
    step :rearrange

    # "decider interface"
    def composers_for_each(ctx, model:, **)
      model.composers
    end

    def notify_composers(ctx, index:, item:, **)
      Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
    end
    # ...
  end
end
module Song::Operation
  class Cover < Trailblazer::Operation
    step :model
    step Each(dataset_from: :composers_for_each) {
      step :notify_composers
    }
    step :rearrange

    # "decider interface"
    def composers_for_each(ctx, model:, **)
      model.composers
    end

    def notify_composers(ctx, index:, item:, **)
      Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
    end
    # ...
  end
end

You can either pass a block with steps to iterate, or an entire operation/activity. In this example, a block is used. Note that you need to use the {} curly braces due to Ruby’s precedence, just as it’s done in Wrap().

dataset_from

While you could simply assign a ctx variable :dataset in a step preceding the Each() macro, the recommended way is using :dataset_from and implementing a dedicated dataset provider.

step Each(dataset_from: :composers_for_each) {
  step :notify_composers
}

This can be an instance method or any kind of callable, following the “decider interface”.

def composers_for_each(ctx, model:, **)
  model.composers
end

Note that our dataset provider is an instance method #composers_for_each defined on the operation class hosting Each(). It exposes a step signature with ctx and handy keyword arguments and simply returns the dataset. It explicitely does not write to ctx!

The only requirement to the dataset provider’s return value is that it returns an enumerable object - usually, that’s an array.

Iterated Block

Note that the iterated block’s :instance_methods are defined in the Each()-hosting activity, too.

def notify_composers(ctx, index:, item:, **)
  Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
end

Per default, Each() will loop over the dataset using #each and pass the iterated item and its index into each step of the block, named :item and :index.

If you don’t like the :item keyword argument in your steps, you can configure it using the :item_key option for Each().

step Each(dataset_from: :composers_for_each, item_key: :composer) {
  step :notify_composers
}

The iterated steps now receive a :composer ctx variable instead of :item.

module Song::Activity
  class Cover < Trailblazer::Activity::Railway
    # ...
    def notify_composers(ctx, index:, composer:, **)
      Mailer.send(to: composer.email, message: "#{index}) You, #{composer.full_name}, have been warned about your song being copied.")
    end
  end
end
module Song::Operation
  class Cover < Trailblazer::Operation
    # ...
    def notify_composers(ctx, index:, composer:, **)
      Mailer.send(to: composer.email, message: "#{index}) You, #{composer.full_name}, have been warned about your song being copied.")
    end
  end
end

If you would like the iterated steps to be within the “block”, use a dedicted activity or operation.

module Song::Activity
  class Cover < Trailblazer::Activity::Railway
    step :model
    step Each(Notify, dataset_from: :composers_for_each)
    step :rearrange
    # ...
  end
end
module Song::Operation
  class Cover < Trailblazer::Operation
    step :model
    step Each(Notify, dataset_from: :composers_for_each)
    step :rearrange
    # ...
  end
end

The iterated operation is composed of steps that have an identical interface with the block version.

module Song::Activity
  class Notify < Trailblazer::Activity::Railway
    step :send_email

    def send_email(ctx, index:, item:, **)
      Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
    end
  end
end
module Song::Operation
  class Notify < Trailblazer::Operation
    step :send_email

    def send_email(ctx, index:, item:, **)
      Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
    end
  end
end

Collect option

Ctx variables set within an iteration are discarded per default. While you could configure collecting values yourself, you can use the :collect option.

step Each(dataset_from: :composers_for_each, collect: true) {
  step :notify_composers
}

If one of your iterated steps sets ctx[:value] within Each(), this value will be collected.

def notify_composers(ctx, index:, item:, **)
  ctx[:value] = [index, item.full_name]
end

All collected values are available at ctx[:collected_from_each] when Each() is finished.

ctx = {params: {id: 1}} # Song 1 has two composers.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Cover, ctx)

puts ctx[:collected_from_each] #=> [[0, "Fat Mike"], [1, "El Hefe"]]
ctx = {params: {id: 1}} # Song 1 has two composers.

result = Song::Operation::Cover.(ctx)

puts result[:collected_from_each] #=> [[0, "Fat Mike"], [1, "El Hefe"]]

Variable mapping

The Each() macro allows to configure what goes in and out of each iteration. However, it provides a default setting.

step Each(dataset_from: :composers_for_each) {
  step :notify_composers
  step :write_to_ctx
}
  • Per default, the iterated steps can see the entire outer ctx, plus :index and :item.
  • Any variable written to ctx is discarded after the iteration.
def write_to_ctx(ctx, index:, seq:, **)
  # ...
  ctx[:variable] = index # this is discarded!
end

This default setting assures that no data from the iteration bleeds into the outer ctx.

You may configure what goes in and out of the iterated activity. Use the variable mapping API by passing arguments to Each().

For example, say you want to collect messages from each iteration.

module Song::Activity
  class Cover < Trailblazer::Activity::Railway
    step :model
    step Each(dataset_from: :composers_for_each,
      Inject(:messages) => ->(*) { {} },

      # all filters called before/after each iteration!
      Out() => [:messages]
    ) {
      step :write_to_ctx
    }
    step :rearrange

    def write_to_ctx(ctx, item:, messages:, index:, **)
      ctx[:messages] = messages.merge(index => item.full_name)
    end
    # ...
  end
end
module Song::Operation
  class Cover < Trailblazer::Operation
    step :model
    step Each(dataset_from: :composers_for_each,
      Inject(:messages) => ->(*) { {} },

      # all filters called before/after each iteration!
      Out() => [:messages]
    ) {
      step :write_to_ctx
    }
    step :rearrange

    def write_to_ctx(ctx, item:, messages:, index:, **)
      ctx[:messages] = messages.merge(index => item.full_name)
    end
    # ...
  end
end
  1. When starting the iteration, the ctx variable :messages will be initialized to an empty hash (unless you provide it in the outside ctx).
  2. The #write_to_ctx step within Each() sees that :messages variable and can override it, adding its non-sense message to the hash.
  3. Since you configured Out() => [:messages], the following iteration will see that very :messages variable from the last iteration.

This allows to collect outgoing variables from the iterations, even in case of failures.

Note how the Inject() and Out() calls are within the parenthesis of Each().

Failure

If not configured, a failing iterated step leads the entire iteration to be stopped. The Each() activity will terminate on its failure terminus.

You can still access ctx[:collected_from_each] for each iterated block. Note that you can even see which iteration failed in the trace!

In combination with TRB’s debugger, this gives you a powerful tool for finding bugs in your code or understanding the flow, without having to jump around through iterations using pry or other tools.

Compliance

You can use tracing with Each().

Trailblazer::Developer.wtf?(Song::Activity::Cover, [{
  params: {id: 1},
  # ...
}])

Note that a virtual step invoke_block_activity is displayed to group the iterations, suffixed with the number of the iteration.

TODO: show how runtime data can be accessed for each iterated block.

You can use any Introspect mechanics on the nested activity using Each().

node, _ = Trailblazer::Developer::Introspect.find_path(
  Song::Activity::Cover,
  ["Each/composers_for_each", "Each.iterate.block", "invoke_block_activity", :notify_composers])
#=> #<Node ...>

You may patch the iterated activity.

cover_patched = Trailblazer::Activity::DSL::Linear::Patch.(
  Song::Activity::Cover,
  ["Each/composers_for_each", "Each.iterate.block"],
  -> { step :log_email }
)

Here, the Each.iterate.block task represents the nested iterated activity.

Model

An operation can automatically find or create a model for you using the Model() macro. The produced model is written to ctx[:model].

You could easily write this code yourself, nevertheless, our macro comes with a bunch of helpful features.

Model::Find

trailblazer-macro 2.1.16

In operations that target existing models, the Model::Find() macro is the right tool to use.

To find a model by its ID using Song.find_by(id: id) use the macro like so.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model::Find(Song, find_by: :id)
    step :validate
    step :save
    # ...
  end
end

The id value is extracted from id = params[:id].

Note that the finder method doesn’t have to be find_by - you may pass any method name to Find(), for example :where.

The id: key is also up to you. As an example, you can dictate a query with a different key using the following code.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model::Find(Song, find_by: :short_id) # Song.find_by(short_id: params[:short_id])
    step :validate
    step :save
    # ...
  end
end

Sometimes the ID key and the params key differ. Use the :params_key option if you want to look into params using a different key.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model::Find(Song, find_by: :id, params_key: :slug) # Song.find_by(id: params[:slug])
    step :validate
    step :save
    # ...
  end
end

If the ID extraction is more complicated, maybe you need to look into a deeply nested hash in params, use the block style to implement your own extraction.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model::Find(Song, find_by: :id) { |ctx, params:, **|
      params[:song] && params[:song][:id]
    }
    step :validate
    step :save
    # ...
  end
end

The block receives the same ctx, **kws style arguments that an ordinary step would.

You can wire the Model::Find() step to a dedicated terminus :not_found in case of a missing model (instead of connecting it to the default failure track). The new terminus represents an explicit outcome and could, for instance, be used in an endpoint to render a 404 response without having to check if ctx[:model] is present or not.

To add the explicit End.not_foundterminus, pass the :not_found_terminus option to the macro.

class Song
  module Activity
    class Update < Trailblazer::Activity::Railway
      step Model::Find(Song, find_by: :id, not_found_terminus: true)
      step :validate
      step :save
      include T.def_steps(:validate, :save)
    end
  end
end

When running the Update activity with an invalid ID, it terminates on End.not_found.

signal, (ctx, _) = Trailblazer::Activity::TaskWrap.invoke(Song::Activity::Update, [{params: {id: nil}},{}])
puts signal #=> #<Trailblazer::Activity::End semantic=:not_found>

In case your model needs to be retrieved using a positional ID argument, use the :find style without a hash.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model::Find(Song, :find) # Song.find(id)
    step :validate
    step :save
    # ...
  end
end

This will result in an invocation like Song.find(id). As always :find can be anything, for example, :[] in case you’re using Sequel.

With tracing turned on, you can see that Model::Find() actually creates a tiny activity with two steps.

  1. The first extracts the ID from params. If this step fails, your configured ID couldn’t be found
  2. The second step runs the finder.

Model::Build

For Create operations without an existing model you can use Model::Build to instantiate a new model.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step Model::Build(Song, :new)
    step :validate
    step :save
    # ...
  end
end

Here, :new might be replace with any method you want to be called on Song, e.g. :build.

Dependency Injection

Model

The Model() macro will be deprecated and removed in Trailblazer 2.2. Please switch over to the more powerful Model::Find and friends.

An operation can automatically find or create a model for you using the Model() macro.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step Model(Song, :new)
    step :validate
    step :save
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step Model(Song, :new)
    step :validate
    step :save
    # ...
  end
end

After this step, there is a fresh model instance under ctx[:model] that can be used in all following steps and the returned result object.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Create, params: {})
puts ctx[:model] #=> #<struct Song id=nil, title=nil>
result = Song::Operation::Create.(params: {})
puts result[:model] #=> #<struct Song id=nil, title=nil>

Internally, the Model() macro will simply invoke Song.new to populate ctx[:model].

You can also find models using :find_by. This is helpful for Update or Delete operations.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model(Song, :find_by)
    step :validate
    step :save
    # ...
  end
end
module Song::Operation
  class Update < Trailblazer::Operation
    step Model(Song, :find_by)
    step :validate
    step :save
    # ...
  end
end

The Model macro will invoke the following code for you.

ctx[:model] = Song.find_by(id: params[:id])

This will assign ctx[:model] for you by invoking find_by.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Update, params: {id: 1}, seq: [])
ctx[:model] #=> #<Song id=1, ...>
puts signal #=> #<Trailblazer::Activity::End semantic=:success>
result = Song::Operation::Update.(params: {id: 1}, seq: [])
result[:model] #=> #<Song id=1, ...>
result.success? # => true

If Song.find_by returns nil, this will deviate to the failure track, skipping the rest of the operation.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Update, params: {})
ctx[:model] #=> nil
puts signal #=> #<Trailblazer::Activity::End semantic=:failure>
result = Song::Operation::Update.(params: {})
result[:model] #=> nil
result.success? # => false

It is also possible to find_by using an attribute other than :id.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model(Song, :find_by, :title) # third positional argument.
    step :validate
    step :save
    # ...
  end
end
module Song::Operation
  class Update < Trailblazer::Operation
    step Model(Song, :find_by, :title) # third positional argument.
    step :validate
    step :save
    # ...
  end
end

Note that, instead of find_by, you may also use :find. This is not recommended, though, since it raises an exception, which is not the preferred way of flow control in Trailblazer.

It’s possible to specify any finder method, which is helpful with ROMs such as Sequel.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model(Song, :[])
    step :validate
    step :save
    # ...
  end
end
module Song::Operation
  class Update < Trailblazer::Operation
    step Model(Song, :[])
    step :validate
    step :save
    # ...
  end
end

The provided method will be invoked and Trailblazer passes to it the value ofparams[:id].

Song[params[:id]]

Given your database gem provides that finder, it will result in a query.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Update, params: {id: 1}, seq: [])
ctx[:model] #=> #<struct Song id=1, title="Roxanne">
result = Song::Operation::Update.(params: {id: 1}, seq: [])
result[:model] #=> #<struct Song id=1, title="Roxanne">

You can wire the Model() step to a dedicated terminus :not_found in case of a missing model, instead of connecting it to the default failure track. The new terminus represents an explicit outcome and could, for instance, be used in an endpoint to render a 404 response without having to check if ctx[:model] is present or not.

To add the explicit End.not_foundterminus, pass the :not_found_terminus option to the macro.

module Song::Activity
  class Update < Trailblazer::Activity::Railway
    step Model(Song, :find_by, not_found_terminus: true)
    step :validate
    step :save
    # ...
  end
end
module Song::Operation
  class Update < Trailblazer::Operation
    step Model(Song, :find_by, not_found_terminus: true)
    step :validate
    step :save
    # ...
  end
end

When running the Update activity with an invalid ID, it terminates on End.not_found.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Update, params: {id: nil})
puts signal #=> #<Trailblazer::Activity::End semantic=:not_found>
result = Song::Operation::Update.(params: {id: nil})
result.success? # => false

The following Model() options can be injected using variables when calling the operation.

  • :"model.class" which represents the first argument for Model().
  • :"model.action" representing the second argument.
  • :"model.find_by_key" which represents the third argument.

As per your design, you may inject one or more of those variables as follows.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Create,
  params:               {title: "Olympia"}, # some random variable.
  "model.class":        Hit,
  "model.action":       :find_by,
  "model.find_by_key":  :title, seq: []
)
result = Song::Operation::Create.(
  params:               {title: "Olympia"}, # some random variable.
  "model.class":        Hit,
  "model.action":       :find_by,
  "model.find_by_key":  :title, seq: []
)

You can even leave Model() entirely unconfigured.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step Model()
    step :validate
    step :save
    # ...
  end
module Song::Operation
  class Create < Trailblazer::Operation
    step Model()
    step :validate
    step :save
    # ...
  end

This implies you inject all required variables at run-time.

Usually, the specific model class for Model() is set directly in the macro.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step Model(Song, :new)
    step :validate
    step :save
    # ...
  end
end
module Song::Operation
  class Create < Trailblazer::Operation
    step Model(Song, :new)
    step :validate
    step :save
    # ...
  end
end

Nevertheless, it’s possible to override it using the :"model.class" variable when invoking the operation/activity.

signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Create, params: {}, :"model.class" => Hit)
result = Song::Operation::Create.(params: {}, :"model.class" => Hit)

Note that you don’t have to configure any model class at all when injecting it.

Rescue

While you could be implementing your own begin/rescue/end mechanics using Wrap, Trailblazer offers you the Rescue() macro to catch and handle exceptions that might occur while running any series of steps.

class Song::Activity::Create < Trailblazer::Activity::Railway
  step :create_model
  step Rescue() {
    step :upload
    step :rehash
  }
  step :notify
  fail :log_error
  # ...
end

Any exception raised during a step in the Rescue block will cause the execution to stop, and continues after the block on the left (or failure) track.

Options

You can specify what particular exceptions to catch and an optional handler that is called when an exception is encountered.

class Song::Activity::Create < Trailblazer::Activity::Railway
  step :create_model
  step Rescue(RuntimeError, handler: MyHandler) {
    step :upload
    step :rehash
  }
  step :notify
  fail :log_error
  # ...
end

The handler’s #call method receives the exception and the circuit-interface arguments.

class MyHandler
  def self.call(exception, (ctx), *)
    ctx[:exception_class] = exception.class
  end
end

Alternatively, you can use a Callable object for :handler.

Full example

The Nested, Wrap and Rescue macros can also be nested, allowing an easily extendable business workflow with error handling along the way.

TODO: add example

Policy

The Policy macros Policy::Pundit, and Policy::Guard help to implement permission decider steps.

Pundit

The Policy::Pundit module allows using Pundit-compatible policy classes in an operation.

A Pundit policy has various rule methods and a special constructor that receives the current user and the current model.

class MyPolicy
  def initialize(user, model)
    @user, @model = user, model
  end

  def create?
    @user == Module && @model.id.nil?
  end

  def new?
    @user == Class
  end
end

In pundit policies, it is a convention to have access to those objects at runtime and build rules on top of those.

You can plug this policy into your pipe at any point. However, this must be inserted after the "model" skill is available.

class Create < Trailblazer::Operation
  step Model( Song, :new )
  step Policy::Pundit( MyPolicy, :create? )
  # ...
end

Note that you don’t have to create the model via the Model macro - you can use any logic you want. The Pundit macro will grab the model from ["model"], though.

This policy will only pass when the operation is invoked as follows.

Create.(current_user: User.find(1))

Any other call will cause a policy breach and stop the pipe from executing after the Policy::Pundit step.

Add your polices using the Policy::Pundit macro. It accepts the policy class name, and the rule method to call.

class Create < Trailblazer::Operation
  step Model( Song, :new )
  step Policy::Pundit( MyPolicy, :create? )
  # ...
end

The step will create the policy instance automatically for you and passes the "model" and the "current_user" skill into the policies constructor. Just make sure those dependencies are available before the step is executed.

If the policy returns falsey, it deviates to the left track.

After running the Pundit step, its result is readable from the Result object.

result = Create.(params: {}, current_user: Module)
result[:"result.policy.default"].success? #=> true
result[:"result.policy.default"][:policy] #=> #<MyPolicy ...>

Note that the actual policy instance is available via ["result.policy.#{name}"]["policy"] to be reinvoked with other rules (e.g. in the view layer).

You can add any number of Pundit policies to your pipe. Make sure to use name: to name them, though.

class Create < Trailblazer::Operation
  step Model( Song, :new )
  step Policy::Pundit( MyPolicy, :create?, name: "after_model" )
  # ...
end

The result will be stored in "result.policy.#{name}"

result = Create.(params: {}, current_user: Module)
result[:"result.policy.after_model"].success? #=> true

Override a configured policy using dependency injection.

Create.(params: {},
  current_user:            Module,
  :"policy.default.eval" => Trailblazer::Operation::Policy::Pundit.build(AnotherPolicy, :create?)
)

You can inject it using "policy.#{name}.eval". It can be any object responding to call.

Guard

A guard is a step that helps you evaluating a condition and writing the result. If the condition was evaluated as falsey, the pipe won’t be further processed and a policy breach is reported in Result["result.policy.default"].

class Create < Trailblazer::Operation
  step Policy::Guard(->(options, pass:, **) { pass })
  step :process

  def process(options, **)
    options[:x] = true
  end
end

The only way to make the above operation invoke the second step :process is as follows.

result = Create.({ pass: true })
result["x"] #=> true

Any other input will result in an abortion of the pipe after the guard.

result = Create.()
result["x"] #=> nil
result["result.policy.default"].success? #=> false

Learn more about → dependency injection to pass params and current user into the operation. TODO: fix link

The Policy::Guard macro helps you inserting your guard logic. If not defined, it will be evaluated where you insert it.

step :process

def process(options, **)
  options[:x] = true
end

The options object is passed into the guard and allows you to read and inspect data like params or current_user. Please use kw args.

As always, the guard can also be a Callable-marked object.

class MyGuard
  def call(options, pass:, **)
    pass
  end
end

Insert the object instance via the Policy::Guard macro.

class Create < Trailblazer::Operation
  step Policy::Guard( MyGuard.new )
  step :process

  # ...
end

As always, you may also use an instance method to implement a guard.

class Create < Trailblazer::Operation
  step Policy::Guard( :pass? )

  def pass?(options, pass:, **)
    pass
  end
  step :process
  # ...
end

The guard name defaults to default and can be set via name:. This allows having multiple guards.

class Create < Trailblazer::Operation
  step Policy::Guard( ->(options, current_user:, **) { current_user }, name: :user )
  # ...
end

The result will sit in result.policy.#{name}.

result = Create.(:current_user => true)
result[:"result.policy.user"].success? #=> true

Instead of using the configured guard, you can inject any callable object that returns a Result object. Do so by overriding the policy.#{name}.eval path when calling the operation.

Create.(
  :current_user           => Module,
  :"policy.default.eval"  => Trailblazer::Operation::Policy::Guard.build(->(options, **) { false })
)

An easy way to let Trailblazer build a compatible object for you is using Guard.build.

This is helpful to override a certain policy for testing, or to invoke it with special rights, e.g. for an admin.

You may specify a position.

class Create < Trailblazer::Operation
  step :model!
  step Policy::Guard( :authorize! ),
    before: :model!
end

Resulting in the guard inserted before model!, even though it was added at a later point.

  Trailblazer::Developer.railway(Create, style: :rows) #=>
   # 0 ========================>operation.new
   # 1 ==================>policy.default.eval
   # 2 ===============================>model!

This is helpful if you maintain modules for operations with generic steps.