LIKE US FOR UPDATES + GET A FREE STICKER PACK!

Operation

Operation API

Last updated 12 April 2017 trailblazer v2.0 v1.1

This document describes Trailblazer’s Operation API.

The generic implementation can be found in the trailblazer-operation gem. This gem only provides the pipe and dependency handling.

Higher-level abstractions, such as form object or policy integration, are implemented in the trailblazer gem.

Invocation

An operation is designed like a function and it can only be invoked in one way: via Operation::call.

Song::Create.call( name: "Roxanne" )

Ruby allows a shorthand for this which is commonly used throughout Trailblazer.

Song::Create.( name: "Roxanne" )

The absence of a method name here is per design: this object does only one thing, and hence what it does is reflected in the class name.

Running an operation will always return its → result object. It is up to you to interpret the content of it or push data onto the result object during the operation’s cycle.

result = Create.call( name: "Roxanne" )

result["model"] #=> #<Song name: "Roxanne">

Invocation: Dependencies

Dependencies other than the params input, such as a current user, are passed via the second argument.

result = Create.( { title: "Roxanne" }, "current_user" => current_user )

External dependencies will be accessible via options in every step.

class Create < Trailblazer::Operation
  step     Model( Song, :new )
  step     :assign_current_user!
  # ..
  def assign_current_user!(options)
    options["model"].created_by = options["current_user"]
  end
end

They are also readable in the result.

result["current_user"] #=> #<User name="Ema">
result["model"]        #=> #<Song id=nil, title=nil, created_by=#<User name="Ema">>

Keep in mind that there is no global state in Trailblazer, anything you need in the operation has to be injected via the second call argument. This also applies to tests.

Flow Control

The operation’s sole purpose is to define the pipe with its steps that are executed when the operation is run. While traversing the pipe, each step orchestrates all necessary stakeholders like policies, contracts, models and callbacks.

The flow of an operation is defined by a two-tracked pipeline.

class Song::Create < Trailblazer::Operation
  step    Model( Song, :new )
  step    :assign_current_user!
  step    Contract::Build( constant: MyContract )
  step    Contract::Validate()
  failure :log_error!
  step    Contract::Persist()

  def log_error!(options)
    # ..
  end

  def assign_current_user!(options)
    options["model"].created_by =
      options["current_user"]
  end
end

Per default, the right track will be run from top to bottom. If an error occurs, it will deviate to the left track and continue executing error handler steps on this track.

The flow pipetree is a mix of the Either monad and “Railway-oriented programming”, but not entirely the same.

The following high-level API is available.

  • step adds a step to the right track. If its return value is falsey, the pipe deviates to the left track. Can be called with macros, which will run their own insertion logic.
  • success always add step to the right. The return value is ignored.
  • failure always add step to the left for error handling. The return value is ignored.

Flow Control: Outcome

If the operation ends on the right track, the result object will return true on success?.

result = Song::Create.({ title: "The Feeling Is Alright" }, "current_user": current_user)
result.success? #=> true

Otherwise, when the run ends on the left track, failure? will return true.

result = Song::Create.({ })
result.success? #=> false
result.failure? #=> true

Incredible, we know.

Flow Control: Step

The step method adds your step to the right track. The return value decides about track deviation.

class Create < Trailblazer::Operation
  step :model!

  def model!(options, **)
    options["model"] = Song.new # return value evals to true.
  end
end

The return value of model! is evaluated.

Since the above example will always return something “truthy”, the pipe will stay on the right track after model!.

However, if the step returns falsey, the pipe will change to the left track.

class Update < Trailblazer::Operation
  step :model!

  def model!(options, params:, **)
    options["model"] = Song.find_by(params[:id]) # might return false!
  end
end

In the above example, it deviates to the left should the respective model not be found.

When adding step macros with step, the behavior changes a bit. Macros can command step to internally use other operators to attach their step(s).

class Create < Trailblazer::Operation
  step Model( Song, :find_by )
end

However, most macro will internally use step, too. Note that some macros, such as Contract::Validate might add several steps in a row.

Flow Control: Success

If you don’t care about the result, and want to stay on the right track, use success.

class Update < Trailblazer::Operation
  success :model!

  def model!(options, params:, **)
    options["model"] = Song.find_by(params[:id]) # return value ignored!
  end
end

Here, if model! returns false or nil, the pipe stays on right track.

Flow Control: Failure

Error handlers on the left track can be added with failure.

class Create < Trailblazer::Operation
  step    :model!
  failure :error!

  # ...

  def error!(options, params:, **)
    options["result.model"] = "Something went wrong with ID #{params[:id]}!"
  end
end

Just as in right-tracked steps, you may add failure information to the result object that you want to communicate to the caller.

def error!(options, params:, **)
  options["result.model"] = "Something went wrong with ID #{params[:id]}!"
end

Note that you can add as many error handlers as you want, at any position in the pipe. They will be executed in that order, just as it works on the right track.

Flow Control: Fail Fast Option

If you don’t want left track steps to be executed after a specific step, use the :fail_fast option.

class Update < Trailblazer::Operation
  step    Model( Song, :find_by )
  failure :abort!,                                fail_fast: true
  step    Contract::Build( constant: MyContract )
  step    Contract::Validate( )
  failure :handle_invalid_contract!  # won't be executed if #abort! is executed.

  def abort!(options, params:, **)
    options["result.model.song"] = "Something went wrong with ID #{params[:id]}!"
  end
  # ..
end

This will not execute any failure steps after abort!.

  result = Update.(id: 1)
  result["result.model.song"] #=> "Something went wrong with ID 1!"

Note that this option in combination with failure will always fail fast once its reached, regardless of the step’s return value.

:fail_fast also works with step.

class Update < Trailblazer::Operation
  step :empty_id?,             fail_fast: true
  step Model( Song, :find_by )
  failure :handle_empty_db!   # won't be executed if #empty_id? returns falsey.

  def empty_id?(options, params:, **)
    params[:id] # returns false if :id missing.
  end
end

Here, if step returns a falsey value, the rest of the pipe is skipped, returning a failed result. Again, this will not execute any failure steps after :empty_id?.

  result = Update.({ id: nil })

  result.failure? #=> true
  result["model"] #=> nil

Flow Control: Fail Fast

Instead of hardcoding the flow behavior you can have a dynamic skipping of left track steps based on some condition. This works with the fail_fast! method.

class Update < Trailblazer::Operation
  step :filter_params!         # emits fail_fast!
  step Model( Song, :find_by )
  failure :handle_fail!

  def filter_params!(options, params:, **)
    unless params[:id]
      options["result.params"] = "No ID in params!"
      return Railway.fail_fast!
    end
  end

  def handle_fail!(options, **)
    options["my.status"] = "Broken!"
  end
end

This will not execute any steps on either track, but will result in a failed operation.

  result = Update.(id: 1)
  result["result.params"] #=> "No ID in params!"
  result["my.status"]     #=> nil

Note that you have to return Railway.fail_fast! from the track. You can use this signal from any step, e.g. step or failure.

Step Implementation

A step can be added via step, success and failure. It can be implemented as an instance method.

class Create < Trailblazer::Operation
  step :model!

  def model!(options, **)
    options["model"] = Song.new
  end
end

Note that you can use modules to share steps across operations.

Step Implementation: Lambda

Or as a proc.

class Create < Trailblazer::Operation
  step ->(options, **) { options["model"] = Song.new }
end

Step Implementation: Callable

Or, for more reusability, as a Callable.

class MyModel
  extend Uber::Callable
  def self.call(options, **)
    options["model"] = Song.new
  end
end

Simply pass the class (or stateless instance) to the step operator.

class Create < Trailblazer::Operation
  step MyModel
end

Step Arguments

Each step receives the context object as a positional argument. All runtime data is also passed as keyword arguments to the step. Whether method, proc or callable object, use the positional options to write, and make use of kw args wherever possible.

For example, you can use kw args with a proc.

class Create < Trailblazer::Operation
  step ->(options, params:, current_user:, **) {  }
end

Or with an instance method.

class Create < Trailblazer::Operation
  step :setup!

  def setup!(options, params:, current_user:, **)
    # ...
  end
end

The first options is the positional argument and ideal to write new data onto the context. This is the mutable part which transports mutable state from one step to the next.

After that, only extract the parameters you need (such as params:). Any unspecified keyword arguments can be ignored using **.

Keyword arguments work fine in Ruby 2.1 and >=2.2.3. They are broken in Ruby 2.2.2 and have a to-be-confirmed unexpected behavior in 2.0.

Result Object

Calling an operation returns a Result object. Sometimes we also called it a context object as it’s available throughout the call. It is passed from step to step, and the steps can read and write to it.

Consider the following operation.

class Song::Create < Trailblazer::Operation
  step     :model!
  step     :assign!
  step     :validate!

  def model!(options, current_user:, **)
    options["model"] = Song.new
    options["model"].created_by = current_user
  end

  def assign!(*, params:, model:, **)
    model.title= params[:title]
  end

  def validate!(options, model:, **)
    options["result.validate"] = ( model.created_by && model.title )
  end
end

All three steps add data to the options object. That data can be used in the following steps.

def validate!(options, model:, **)
  options["result.validate"] = ( model.created_by && model.title )
end

It is a convention to use a "namespaced.key" on the result object. This will help you structure and manage the data. It’s clever to namespace your data with something like my..

In future versions of Trailblazer, the Hash Explore API™ will allow to search for fragments or namespace paths on the result object. That's why it's a good idea to follow our namespacing convention.

Some steps, such as Contract or Policy will add nested result objects under their own keys, like ["result.policy"]. It’s a convention to add binary Result objects to the “global” result under the ["result.XXX"] key.

Result: API

After running the operation, the result object can be used for reading state.

result = Song::Create.({ title: "Roxanne" }, "current_user" => current_user)

result["model"] #=> #<Song title="Roxanne", "created_by"=<User ...>
result["result.validate"] #=> true

You can ask about the outcome of the operation via success? and failure?.

result.success? #=> true
result.failure? #=> falsee

Please note that the result object is also used to transport externally injected dependencies and class dependencies..

result["current_user"] #=> <User ...>

Use Result#inspect to test a number of dependencies.

result.inspect("current_user", "model") #=> "<Result:true [#<User email=\"nick@tra... "

Result: Interpretation

The result object represents the interface between the operation’s inner happenings and the outer world, or, in other words, between implementation and user.

It’s up to you how you interpret all this available data. The binary state will help you, but arbitrary state can be transported. For a generic handling in HTTP context (Rails controllers or Rack routes), see Endpoint.

Dependencies

In an operation, there is only one way to manage dependencies and state: the options object (sometimes also called skills hash or context object) is what gives access to class, runtime and injected runtime data.

State can be added on the class layer.

class Song::Create < Trailblazer::Operation
  self["my.model.class"] = Song

  # ...
end

Unsurprisingly, this is also readable on the class layer.

Song::Create["my.model.class"] #=> Song

This mechanism is used by all DSL methods such as contract and by almost all step macros (e.g. Contract::Build) to save and access class-wide data.

Class data is also readable at runtime in steps.

class Song::Create < Trailblazer::Operation
  self["my.model.class"] = Song

  step :model!

  def model!(options, **)
    options["my.model"] =           # setting runtime data.
      options["my.model.class"].new # reading class data at runtime.
  end
end

In steps, you can set runtime data (e.g. my.model).

After running the operation, this options object turns into the result object.

result = Song::Create.({})

result["my.model.class"] #=> Song
result["my.model"] #=> #<Song title=nil>

Dependency Injection

Both class data as well as runtime data described above can be overridden using dependency injection.

result = Song::Create.({}, "my.model.class" => Hit)

result["my.model"] #=> #<Hit id=nil>

Note that injected dependencies need to be in the second argument to Operation::call.

Since all step macros, i.e. Policy::Pundit or Contract::Validate use the same mechanism, you can override hard-coded dependencies such as policies or contracts from the outside at runtime.

Be careful, though, with DI: It couples your operation to the caller and should be properly unit-tested or further encapsulated in e.g. Endpoint.

Dependency Injection: Auto_inject

The operation supports Dry.RB’s auto_inject.

# this happens somewhere in your Dry system.
my_container = Dry::Container.new
my_container.register("repository.song", Song::Repository)

require "trailblazer/operation/auto_inject"
AutoInject = Trailblazer::Operation::AutoInject(my_container)

class Song::Create < Trailblazer::Operation
  include AutoInject["repository.song"]

  step :model!

  def model!(options, params:, **)
    options["model"] =
      options["repository.song"].find_or_create( params[:id] )
  end
end

Including the AutoInject module will make sure that the specified dependencies are injected (using dependency injection) into the operation’s context at instantiation time.

Inheritance

To share code and pipe, use class inheritance.

Try to avoid inheritance and use composition instead.

You can inherit from any kind of operation.

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

In this example, the New class will have a pipe as follows.

 0 =======================>>operation.new
 1 ==========================&model.build
 2 =======================>contract.build

In addition to Ruby’s normal class inheritance semantics, the operation will also copy the pipe. You may then add further steps to the subclass.

class Create < New
  step Contract::Validate()
  step Contract::Persist()
end

This results in the following pipe.

 0 =======================>>operation.new
 1 ==========================&model.build
 2 =======================>contract.build
 3 ==============&contract.default.params
 4 ============&contract.default.validate
 5 =========================&persist.save

Inheritance is great to eliminate redundancy. Pipes and step code can easily be shared amongst groups of operations.

Be weary, though, that you are tightly coupling flow and implementation to each other. Once again, try to use Trailblazer’s compositional semantics over inheritance.

Inheritance: Override

When using inheritance, use :override to replace existing steps in subclasses.

Consider the following base operation.

module MyApp::Operation
  class New < Trailblazer::Operation
    extend Contract::DSL

    contract do
      property :title
    end

    step Model( nil, :new )
    step Contract::Build()
  end
end

Subclasses can now override predefined steps.

class Song::New < MyApp::Operation::New
  step Model( Song, :new ), override: true
end

The pipe flow will remain the same.

Song::New["pipetree"].inspect(style: :row)
 0 =======================>>operation.new
 1 ==========================&model.build
 2 =======================>contract.build

Refrain from using the :override option if you want to add steps.

Options

When adding steps using step, failure and success, you may name steps explicitly or specify the position.

Options: Name

A step macro will name itself.

class New < Trailblazer::Operation
  step Model( Song, :new )
end

You can find out the name by inspecting the pipe.

 0 =======================>>operation.new
 1 ==========================&model.build

For every kind of step, whether it’s a macro or a custom step, use :name to specify a name.

class New < Trailblazer::Operation
  step Model( Song, :new ), name: "build.song.model"
  step :validate_params!,   name: "my.params.validate"
  # ..
end

When inspecting the pipe, you will see your names.

 0 =======================>>operation.new
 1 =====================&build.song.model
 2 ===================&my.params.validate

Assign manual names to steps when using macros multiple times, or when planning to alter the pipe in subclasses.

Options: Position

Whenever inserting a step, you may provide the position in the pipe using :before or :after.

class New < Trailblazer::Operation
  step Model( Song, :new )
  step :validate_params!,   before: "model.build"
  # ..
end

This will insert the custom step before the model builder.

 0 =======================>>operation.new
 1 =====================&validate_params!
 2 ==========================&model.build

Naturally, :after will insert the step after an existing one.

Options: Inheritance

The position options are extremely helpful with inheritance.

class Create < New
  step :policy!, after: "operation.new"
end

It allows inserting new steps without repeating the existing pipe.

 0 =======================>>operation.new
 1 ==============================&policy!
 2 =====================&validate_params!
 3 ==========================&model.build

Options: Replace

Replace existing (or inherited) steps using :replace.

class Update < New
  step Model(Song, :find_by), replace: "model.build"
end

The existing step will be discarded with the newly provided one.

 0 =======================>>operation.new
 2 ==========================&model.build

Options: Delete

Step Macros

Trailblazer provides predefined steps to for all kinds of business logic.

  • Contract implements contracts, validation and persisting verified data using the model layer.
  • Nested, Wrap and Rescue are step containers that help with transactional features for a group of steps per operation.
  • All Policy-related macros help with authentication and making sure users only execute what they’re supposed to.
  • The Model macro can create and find models based on input.

Macro API

Implementing your own macros helps to create reusable code.

It’s advised to put macro code into namespaces to not pollute the global namespace.

module Macro
  def self.MyPolicy(allowed_role: "admin")
    step = ->(input, options) { options["current_user"].type == allowed_role }

    [ step, name: "my_policy.#{allowed_role}" ] # :before, :replace, etc. work, too.
  end
end

The macro itself is a function. Per convention, the name is capitalized. You can specify any set of arguments (e.g. using kw args), and it returns a 2-element array with the actual step to be inserted into the pipe and default options.

Note that in the macro, the code design is up to you. You can delegate to other functions, objects, etc.

The macro step receives (input, options) where input is usually the operation instance and options is the context object passed from step to step. It’s your macro’s job to read and write to options. It is not advisable to interact with the operation instance.

Operations can then use your macro the way you’ve done it with our Trailblazer macros.

class Create < Trailblazer::Operation
  step Macro::MyPolicy( allowed_role: "manager" )
  # ..
end

When looking at the operation’s pipe, you can see how, in our example, the default options provide a convenient step name.

  puts Create["pipetree"].inspect(style: :rows) #=>
   0 ========================>operation.new
   1 ====================>my_policy.manager

It is not advised to test macros in isolation. Note that it is possible by simply calling your macro in a test case. However, macros should be tested via an operation unit test to make sure the wiring is correct.

In future versions (TRB 2.0.2+) we will have public APIs for creating nested pipes, providing temporary arguments to steps and allowing injectable options.

Model

An operation can automatically find or create a model for you depending on the input, with the Model macro.

class Create < Trailblazer::Operation
  step Model( Song, :new )
  # ..
end

After this step, there is a fresh model instance under options["model"] that can be used in all following steps.

result = Create.({})
result["model"] #=> #<struct Song id=nil, title=nil>

Internally, Model macro will simply invoke Song.new to populate "model".

Model: Find_by

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

class Update < Trailblazer::Operation
  step Model( Song, :find_by )
  # ..
end

The Model macro will invoke the following code for you.

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

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

result = Update.({ id: 1 })
result["model"] #=> #<struct Song id=1, title="Roxanne">

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

result = Update.({})
result["model"] #=> nil
result.success? #=> false

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

Nested

It is possible to nest operations, as in running an operation in another. This is the common practice for “presenting” operations and “altering” operations, such as Edit and Update.

class Edit < Trailblazer::Operation
  extend Contract::DSL

  contract do
    property :title
  end

  step Model( Song, :find )
  step Contract::Build()
end

Note how Edit only defines the model builder (via :find) and builds the contract.

Running Edit will allow you to grab the model and contract, for presentation and rendering the form.

result = Edit.(id: 1)

result["model"]            #=> #<Song id=1, title=\"Bristol\">
result["contract.default"] #=> #<Reform::Form ..>

This operation could now be leveraged in Update.

class Update < Trailblazer::Operation
  step Nested( Edit )
  step Contract::Validate()
  step Contract::Persist( method: :sync )
end

The Nested macro helps you invoking an operation at any point in the pipe.

Nested: Data

The nested operation (Edit) will, per default, only receive runtime data from the composing operation (Update). Mutable data is not available to protect the nested operation from unsolicited input.

After running the nested Edit operation its runtime data (e.g. "model") is available in the Update operation.

result = Update.(id: 1, title: "Call It A Night")

result["model"]            #=> #<Song id=1 , title=\"Call It A Night\">
result["contract.default"] #=> #<Reform::Form ..>

Should the nested operation fail, for instance because its model couldn’t be found, then the outer pipe will also jump to the left track.

Nested: Callable

If you need to pick the nested operation dynamically at runtime, use a callable object instead.

class Delete < Trailblazer::Operation
  step Nested( MyBuilder )
  # ..
end

The object’s call method has the exact same interface as any other step.

class MyBuilder
  extend Uber::Callable

  def self.call(options, current_user:nil, **)
    current_user.admin? ? Create::Admin : Create::NeedsModeration
  end
end

Note that Nested also works with :instance_method and lambdas.

class Update < Trailblazer::Operation
  step Nested( :build! )

  def build!(options, current_user:nil, **)
    current_user.admin? ? Create::Admin : Create::NeedsModeration
  end
end

Nested: Input

Per default, only the runtime data will be passed into the nested operation. You can use :input to change the data getting passed on.

The following operation multiplies two factors.

class Multiplier < Trailblazer::Operation
  step ->(options, x:, y:, **) { options["product"] = x*y }
end

It is Nested in another operation.

class MultiplyByPi < Trailblazer::Operation
  step ->(options) { options["pi_constant"] = 3.14159 }
  step Nested( Multiplier, input: ->(options, mutable_data:, runtime_data:, **) do
    { "y" => mutable_data["pi_constant"],
      "x" => runtime_data["x"] }
  end )
end

The examplary composing operation uses both runtime and mutable data. Its invocation could look as follows.

result = MultiplyByPi.({}, "x" => 9)
result["product"] #=> [28.27431]

The :input option for Nested allows to specify what options will go into the nested operation. It passes mutable_data and runtime_data to the option for your convenience.

While the runtime data normally gets passed on to nested operations automatically, mutual data won’t. :input has to compile all data being passed on to the nested operation and return that hash.

Use a Callable to share mapping code.

class MyInput
  extend Uber::Callable

  def self.call(options, mutable_data:, runtime_data:, **)
    {
      "y" => mutable_data["pi_constant"],
      "x" => runtime_data["x"]
    }
  end
end

It will improve readability.

class MultiplyByPi < Trailblazer::Operation
  step ->(options) { options["pi_constant"] = 3.14159 }
  step Nested( Multiplier, input: MyInput )
end

Nested: Output

After running a nested operation, its mutable data gets copied into the options of the composing operation.

Use :output to change that, should you need only specific values.

class Update < Trailblazer::Operation
  step Nested( Edit, output: ->(options, mutable_data:, **) do
    {
      "contract.my" => mutable_data["contract.default"],
      "model"       => mutable_data["model"]
    }
  end )
  step Contract::Validate( name: "my" )
  step Contract::Persist( method: :sync, name: "my" )
end

This works with lambdas, :method and Callable.

Wrap

Steps can be wrapped by an embracing step. This is necessary when defining a set of steps to be contained in a database transaction or a database lock.

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

  step Wrap ->(*, &block) { Sequel.transaction do block.call end } {
    step Model( Song, :new )
    step Contract::Build( constant: MyContract )
    step Contract::Validate( )
    step Contract::Persist( method: :sync )
  }
  failure :error! # handle all kinds of errors.
  step :notify!

  def error!(options)
    # handle errors after the wrap
  end

  def notify!(options)
    MyNotifier.mail
  end
end

The Wrap macro helps you to define the wrapping code (such as a Sequel.transaction call) and allows you to define the wrapped steps. (Because of the precedence works in Ruby, you need to use {...} instead of do...end.)

class Create < Trailblazer::Operation
  # ...
  step Wrap ->(*, &block) { Sequel.transaction do block.call end } {
    step Model( Song, :new )
    # ...
  }
  failure :error! # handle all kinds of errors.
  # ...
end

As always, you can have steps before and after Wrap in the pipe.

The proc passed to Wrap will be called when the pipe is executed, and receives block. block.call will execute the nested pipe.

You may have any amount of Wrap nesting.

Wrap: Return Value

All nested steps will simply be executed as if they were on the “top-level” pipe, but within the wrapper code. Steps may deviate to the left track, and so on.

However, the last signal of the wrapped pipe is not simply passed on to the “outer” pipe. The return value of the actual Wrap block is crucial: If it returns falsey, the pipe will deviate to left after Wrap.

step Wrap ->(*, &block) { Sequel.transaction do block.call end; false } {

In the above example, regardless of Sequel.transaction’s return value, the outer pipe will deviate to the left track as the Wrap’s return value is always false.

Wrap: Callable

For reusable wrappers, you can also use a Callable object.

class MyTransaction
  extend Uber::Callable

  def self.call(options, *)
    Sequel.transaction { yield } # yield runs the nested pipe.
    # return value decides about left or right track!
  end
end

This can then be passed to Wrap, making the pipe extremely readable.

class Create < Trailblazer::Operation
  # ...
  step Wrap( MyTransaction ) {
    step Model( Song, :new )
    # ...
  }
  failure :error! # handle all kinds of errors.
  # ...
end

Rescue

While you can write your own begin/rescue/end mechanics using Wrap, Trailblazer offers you the Rescue macro to catch and handle exceptions that might occur while running the pipe.

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

  step Rescue {
    step Model(Song, :find)
    step Contract::Build( constant: MyContract )
  }
  step Contract::Validate()
  step Contract::Persist( method: :sync )
end

Any exception raised during a step in the Rescue block will stop the nested pipe from being executed, and continue after the block on the left track.

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

class Create < Trailblazer::Operation
  step Rescue( RecordNotFound, KeyError, handler: :rollback! ) {
    step Model( Song, :find )
    step Contract::Build( constant: MyContract )
  }
  step Contract::Validate()
  step Contract::Persist( method: :sync )

  def rollback!(exception, options)
    options["x"] = 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.

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

  step Rescue( RecordNotFound, handler: :rollback! ) {
    step Wrap ->(*, &block) { Sequel.transaction do block.call end } {
      step Model( Song, :find )
      step ->(options) { options["model"].lock! } # lock the model.
      step Contract::Build( constant: MyContract )
      step Contract::Validate( )
      step Contract::Persist( method: :sync )
    }
  }
  failure :error! # handle all kinds of errors.

  def rollback!(exception, options)
    # ...
  end

  def error!(options)
    # ...
  end
end