Chapters

2.1 Macro Documentation

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.

General notes

General notes Variable mapping

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.

Dynamic Decider

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.

Dynamic Nested activity

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.

Auto_wire Output Wiring

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

Compliance Debugger

Nested setups are traceable using #wtf?.


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

The above examples result in a flow as follows.

Compliance Introspect

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.

Compliance Patch

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.

Wrap handler Routing

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

Compliance Debugger

You can use tracing with Wrap().


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

The trace per default shows Wrap’s ID.

Compliance Introspect

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

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

Compliance Patch

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
    def model(ctx, params:, **)
      ctx[:model] = Song.find_by(id: params[:id])
    end

    include T.def_steps(:rearrange)
  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.

Iterated Block item key

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

Iterated Block operation

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

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

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

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.

Variable mapping Filtering

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
    include EachTest::CoverMethods
    include EachTest::ComposersForEach
  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

Compliance Debugger

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.

Compliance Introspect

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 ...>

Compliance Patch

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.

Model::Find Find_by

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

Model::Find params_key

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

Model::Find id_from

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.

Model::Find Not Found

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>

Model::Find find

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.

Model::Find Debugging

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
    include T.def_steps(:validate, :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>

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

Model Find_by

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
    include T.def_steps(:validate, :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>

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>

Model Key

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
    include T.def_steps(:validate, :save)
  end
end

Model find

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.

Model Arbitrary Finder

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
    include T.def_steps(:validate, :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">

Model Not Found

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
    include T.def_steps(:validate, :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>

Model Dependency Injection

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: []
)

You can even leave Model() entirely unconfigured.

module Song::Activity
  class Create < Trailblazer::Activity::Railway
    step Model()
    step :validate
    step :save
    include T.def_steps(:validate, :save)
  end

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

Model Model class

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
    include T.def_steps(:validate, :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)

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.

Pundit API

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).

Pundit Name

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

Pundit Dependency Injection

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

Guard API

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.

Guard Callable

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

Guard Instance Method

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

Guard Name

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

Guard Dependency Injection

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.

Guard Position

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.

Contract

A contract is an abstraction to handle validation of arbitrary data or object state. It is a fully self-contained object that is orchestrated by the operation.

The actual validation can be implemented using Reform with ActiveModel::Validation or dry-validation, or a Dry::Schema directly without Reform.

The Contract macros helps you defining contracts and assists with instantiating and validating data with those contracts at runtime.

overview: reform

Most contracts are Reform objects that you can define and validate in the operation. Reform is a fantastic tool for deserializing and validating deeply nested hashes, and then, when valid, writing those to the database using your persistence layer such as ActiveRecord.

# app/concepts/song/contract/create.rb
module Song::Contract
  class Create < Reform::Form
    property :title
    property :length

    validates :title,  length: 2..33
    validates :length, numericality: true
  end
end

The contract then gets hooked into the operation.

class Song::Create < Trailblazer::Operation
  step Model( Song, :new )
  step Contract::Build( constant: Song::Contract::Create )
  step Contract::Validate()
  step Contract::Persist()
end

As you can see, using contracts consists of five steps.

  1. Define the contract class (or multiple of them) for the operation.
  2. Plug the contract creation into the operation’s pipe using Contract::Build.
  3. Run the contract’s validation for the params using Contract::Validate.
  4. If successful, write the sane data to the model(s). This will usually happen in the Contract::Persist macro.
  5. After the operation has been run, interpret the result. For instance, a controller calling an operation will render a erroring form for invalid input.

You don’t have to use any of the TRB macros to deal with contracts, and do everything yourself. They are an abstraction that will save code and bugs, and introduce strong conventions. However, feel free to use your own code.

Here’s what the result would look like after running the Create operation with invalid data.

result = Song::Create.(params: { title: "A" })
result.success? #=> false
result[:"contract.default"].errors.messages
#=> {:title=>["is too short (minimum is 2 characters)"], :length=>["is not a number"]}

Definition

Trailblazer offers a few different ways to define contract classes and use them in an operation.

Definition Explicit

The preferred way of defining contracts is to use a separate file and class, such as the example below.

# app/concepts/song/contract/create.rb
module Song::Contract
  class Create < Reform::Form
    property :title
    property :length

    validates :title,  length: 2..33
    validates :length, numericality: true
  end
end

This is called explicit contract.

The contract file could be located just anywhere, but it’s clever to follow the Trailblazer conventions.

Using the contract happens via Contract::Build, and the :constant option.

class Song::Create < Trailblazer::Operation
  step Model( Song, :new )
  step Contract::Build( constant: Song::Contract::Create )
  step Contract::Validate()
  step Contract::Persist()
end

Since both operations and contracts grow during development, the completely encapsulated approach of the explicit contract is what we recommend.

Definition Inline

Contracts can also be defined in the operation itself.

# app/concepts/song/create.rb
class Create < Trailblazer::Operation
  class MyContract < Reform::Form
    property :title
    property :length

    validates :title,  presence: true
    validates :length, numericality: true
  end

  step Model( Song, :new )
  step Contract::Build(constant: MyContract)
  step Contract::Validate()
  step Contract::Persist( method: :sync )
end

Defining the contract happens via the contract block. This is called an inline contract. Note that you need to extend the class with the Contract::DSL module. You don’t have to specify anything in the Build macro.

While this is nice for a quick example, this usually ends up quite convoluted and we advise you to use the explicit style.

Build

The Contract::Build macro helps you to instantiate the contract. It is both helpful for a complete workflow, or to create the contract, only, without validating it, e.g. when presenting the form.

class Song::New < Trailblazer::Operation
  step Model( Song, :new )
  step Contract::Build( constant: Song::Contract::Create )
end

This macro will grab the model from options["model"] and pass it into the contract’s constructor. The contract is then saved in options["contract.default"].

result = Song::New.(params: {})
result["model"] #=> #<struct Song title=nil, length=nil>
result["contract.default"]
#=> #<Song::Contract::Create model=#<struct Song title=nil, length=nil>>

The Build macro accepts the :name option to change the name from default.

Build Dependency Injection / Contract class

Instead of defining the contract class in the Build() macro the very option can be injected at run-time, when calling the operation. The operation class doesn’t need any hard-wired reference to a contract class at all.

class Song::Create < Trailblazer::Operation
  step Model(Song, :new)
  step Contract::Build() # no constant provided here!
  step Contract::Validate()
  step Contract::Persist(method: :sync)
end

A prerequisite for that is that the contract class is defined somewhere.

class MyContract < Reform::Form
  property :title
  validates :title, length: 2..33
end

When invoking the operation, you now have to provide the default contract class as a variable (or dependency) using the :"contract.default.class" option. The Build() step will use the passed class constant for instantiating the contract.

Song::Create.(
  params:                   { title: "Anthony's Song" },
  "contract.default.class": MyContract # dependency injection!
)

This will work with any contract name if you follow the naming conventions.

Validate

The Contract::Validate macro is responsible for validating the incoming params against its contract. That means you have to use Contract::Build beforehand, or create the contract yourself. The macro will then grab the params and throw then into the contract’s validate (or call) method.

class Song::ValidateOnly < Trailblazer::Operation
  step Model( Song, :new )
  step Contract::Build( constant: Song::Contract::Create )
  step Contract::Validate()
end

Depending on the outcome of the validation, it either stays on the right track, or deviates to left, skipping the remaining steps.

result = Song::ValidateOnly.(params: {}) # empty params
result.success? #=> false

Note that Validate really only validates the contract, nothing is written to the model, yet. You need to push data to the model manually, e.g. with Contract::Persist.

result = Song::ValidateOnly.(params: { title: "Rising Force", length: 13 })

result.success? #=> true
result[:model] #=> #<struct Song title=nil, length=nil>
result[:"contract.default"].title #=> "Rising Force"

Validate will use options["params"] as the input. You can change the nesting with the :key option.

Internally, this macro will simply call Form#validate on the Reform object.

Note that Reform comes with sophisticated deserialization semantics for nested forms, it might be worth reading a bit about Reform to fully understand what you can do in the Validate step.

Validate Key

Per default, Contract::Validate will use ctx[:params] as the data to be validated. Use the :key option if you want to validate a nested hash from the original params structure.

class Song::Create < Trailblazer::Operation
  step Model( Song, :new )
  step Contract::Build( constant: Song::Contract::Create )
  step Contract::Validate( key: "song" )
  step Contract::Persist( )
end

This automatically extracts the nested "song" hash.

result = Song::Create.(params: { "song" => { title: "Rising Force", length: 13 } })
result.success? #=> true

If that key isn’t present in the params hash, the operation fails before the actual validation.

result = Song::Create.(params: { title: "Rising Force", length: 13 })
result.success? #=> false

Note that string vs. symbol do matter here since the operation will simply do a hash lookup using the key you provided.

Validate Dependency Injection / Key

The Validate() macro allows to injecting the :key option at run-time instead of providing it when using the macro on the class level. You may omit the :key option in the Validate() macro call as below.

class Song::Create < Trailblazer::Operation
  # ...
  step Contract::Validate() # we don't define a key here! E.g. {key: "song"}
  step Contract::Persist()
end

Defining the :key option in Operation.call is now achieved by passing the "contract.default.extract_key" option.

res = Song::Create.(
  params:                         params,
  "contract.default.extract_key": "song"
)

Note that the .default part might differ depending on the name of your contract.

Validate Dependency injection / Contract instance

Instead of using Contract::Build() to let the macro create the contract instance used for Validate(), an arbitrary contract object may be injected at run-time. You may omit Model() and Contract::Build() in that case.

class Song::Create < Trailblazer::Operation
  # we omit the {Model()} call as the run-time contract contains the model.
  # we don't have a {Contract::Build()} step here.
  step Contract::Validate(key: "song") # you could use an injection here, too!
  step Contract::Persist()
end

In order for Validate() to work you have to inject a contract via the :"contract.default" option.

res = Song::Create.(
  params:             params,
  "contract.default": Song::Contract::Create.new(Song.new) # we build the contract ourselves!
)

As always, the .default part might differ depending on the name of your contract.

Invalid Termini

If the Contract::Validate() deviates on a failure track, it is possible to emit a new signal suggesting contract failure.

This becomes really handy when used along with the endpoint. It avoids any conditional checks and can be wired to render 422 response without accessing the ctx. In order to add this new termini in your operation’s terminuses, you need to pass invalid_data_terminus kwarg.

class Song::Create < Trailblazer::Operation
  step Model(Song, :new)
  step Contract::Build(constant: Song::Contract::Create)
  step Contract::Validate(key: :song, invalid_data_terminus: true)
  step Contract::Persist()
end

Based on the given name to this macro (default is ofcourse, default), it will assign End semantic as "contract.#{name}.invalid".

result = Song::Create.(params: { title: "Rising Force", length: 13 })
result.success? #=> false
result.event    #=> #<Trailblazer::Activity::End semantic=:"contract.default.invalid">

Persist

To push validated data from the contract to the model(s), use Persist. Like Validate, this requires a contract to be set up beforehand.

class Song::Create < Trailblazer::Operation
  step Model( Song, :new )
  step Contract::Build( constant: Song::Contract::Create )
  step Contract::Validate()
  step Contract::Persist()
end

After the step, the contract’s attribute values are written to the model, and the contract will call save on the model.

result = Song::Create.(params: { title: "Rising Force", length: 13 })
result.success? #=> true
result["model"] #=> #<Song title="Rising Force", length=13>

You can also configure the Persist step to call sync instead of Reform’s save.


step Persist( method: :sync )

This will only write the contract’s data to the model without calling save on it.

Name

Explicit naming for the contract is possible, too.

class Song::Create < Trailblazer::Operation
  step Model( Song, :new )
  step Contract::Build(    name: "form", constant: Song::Contract::Create )
  step Contract::Validate( name: "form" )
  step Contract::Persist(  name: "form" )
end

You have to use the name: option to tell each step what contract to use. The contract and its result will now use your name instead of default.

result = Song::Create.(params: { title: "A" })
result[:"contract.form"].errors.messages #=> {:title=>["is too short (minimum is 2 ch...

Use this if your operation has multiple contracts.

Dry-Validation

Instead of using ActiveModel::Validation you may use the very popular dry-validation gem for validations in your Reform class.

require "reform/form/dry"
class Create < Trailblazer::Operation
  # contract to verify params formally.
  class MyContract < Reform::Form
    feature Dry
    property :id
    property :title

    validation name: :default do
      params do
        required(:id).filled
      end
    end

    validation name: :extra, if: :default do
      params do
        required(:title).filled(min_size?: 2)
      end
    end
  end

  step Model( Song, :new )                      # create the op's main model.
  step Contract::Build( constant: MyContract )  # create the Reform contract.
  step Contract::Validate()                     # validate the Reform contract.
  step Contract::Persist( method: :sync)        # persist the contract's data via the model.
end

All you need to do is including the feature Dry extension and applying dry-validation’s syntax within the validation blocks. The operation’s macros will work seamlessly with dry’s logic.

(TODO Jan 2021) We are rewriting the Dry-validation documentation shortly and will link to a more concise reference here.

Dry-Validation Dry-Schema

It is possible to use Dry’s Validation::Contract directly as a contract in an operation, using the Contract::Validate() macro. You don’t need a model here, and the contract cannot be persisted. However, this is great for formal validations, e.g. to make sure the params have the correct format.

module Song::Operation
  class Archive < Trailblazer::Operation
    Schema = Dry::Validation.Contract do
      params do
        required(:id).filled
      end
    end

    # step Model(Song, :new)                              # You don't need {ctx[:model]}.
    step Contract::Validate(constant: Schema, key: :song) # Your validation.
    # ...
  end
end

Invoking the operation works exactly as it did with a “normal” contract.

result = Song::Operation::Archive.(params: {song: {id: nil}})

Note that if you don’t use the :song key in your incoming data, you can configure the Validate() macro to refrain from looking for that key.

The “errors” object after running the contract validation can be found at `ctx[:”result.contract.default”]

result[:"result.contract.default"].errors[:id] #=> ["must be filled"]

(TODO Jan 2021) We are working on an operation-wide Errors object which will be available at ctx[:errors].

Manual Extraction

You can plug your own complex logic to extract params for validation into the pipe.

class Create < Trailblazer::Operation
  class MyContract < Reform::Form
    property :title
  end

  def type
    "evergreen" # this is how you could do polymorphic lookups.
  end

  step Model( Song, :new )
  step Contract::Build(constant: MyContract)
  step :extract_params!
  step Contract::Validate( skip_extract: true )
  step Contract::Persist( method: :sync )

  def extract_params!(options, **)
    options[:"contract.default.params"] = options[:params][type]
  end
end

Note that you have to set the self["params.validate"] field in your own step, and - obviously - this has to happen before the actual validation.

Keep in mind that & will deviate to the left track if your extract_params! logic returns falsey.

Manual Build

To manually build the contract instance, e.g. to inject the current user, use builder:.

class Create < Trailblazer::Operation

  class MyContract < Reform::Form
    property :title
    property :current_user, virtual: true

    validate :current_user?
    validates :title, presence: true

    def current_user?
      return true if defined?(current_user)
      false
    end
  end

  step Model( Song, :new )
  step Contract::Build( constant: MyContract, builder: :default_contract! )
  step Contract::Validate()
  step Contract::Persist( method: :sync )

  def default_contract!(options, constant:, model:, **)
    constant.new(model, current_user: options [:current_user])
  end
end

Note how the contract’s class and the appropriate model are offered as kw arguments. You’re free to ignore these options and use your own assets.

As always, you may also use a proc.

Result Object

The operation will store the validation result for every contract in its own result object.

The path is result.contract.#{name}.

result = Create.(params: { length: "A" })

result[:"result.contract.default"].success?        #=> false
result[:"result.contract.default"].errors          #=> Errors object
result[:"result.contract.default"].errors.messages #=> {:length=>["is not a number"]}

Each result object responds to success?, failure?, and errors, which is an Errors object. TODO: design/document Errors. WE ARE CURRENTLY WORKING ON A UNIFIED API FOR ERRORS (FOR DRY AND REFORM).