Operation

  • Last updated 14 Dec 22

Overview

Operations have been a central element since Trailblazer 1.0. An operation models a top-level function of your application, such as creating a user or archiving a blog post. It’s the outer-most object that is directly invoked by the controller, embraces all business-specific logic and hides it from the latter.

Operations are often confused as god objects that do “everything”. However, operations are nothing but orchestrators. Where to implement the actual code and when to call it is up to the developer.

Since Trailblazer 2.1, operations are reduced to being a very thin, public API around an Activity with some pre-defined configuration such as the FastTrack-railway.

Deeply nested business logic is implemented using activities. For background-compatibility, you may still use an operation for the upper-most logic, but internally, it boils down to being an Activity::FastTrack.

  • Two public Operation.call signatures: “public call” and circuit interface. *

Invocation

An operation has two invocation styles. This is the only difference to an Activity.

Until TRB 2.1, the public call was the only way to invoke an operation. Its signature is simpler, but less powerful.

result = Memo::Create.(params: {text: "Enjoy an IPA"})

puts result.success?    #=> true

model = result[:model]
puts model.text         #=> "Enjoy an IPA"

The public call will return a result object that exposes the binary state (success? or failure?). All variables written to the context are accessable via the #[] reader.

Since operations are just normal activities under the hood, they also expose the [circuit interface]. This allows using all advanced features such as [taskWrap], [tracing] or nesting operations with the generic activity mechanics.

ctx = {params: {text: "Enjoy an IPA"}}
signal, (ctx, _) = Memo::Create.([ctx, {}], {})

puts signal #=> #<Trailblazer::Activity::Railway::End::Success semantic=:success>

Wiring

An operation is simply an Activity::FastTrack subclass and all [DSL implications are identical].

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

  # ...
end

An operation always allows you the fast-track outputs and wiring.

For DSL options, refer to Fast Track.

Result

An operation invoked with public call will return an Operation::Result object for your convenience. It’s nothing but a container exposing the binary state (or outcome) plus the ctx object that was passed around in the circuit.

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

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

The result exposes state and the context you wrote to.

result.success? #=> true
result[:model]  #=> #<Memo ..>

The operation ending on a “failure” end (End.failure, End.fail_fast) will result in result.failure? being true. All other outcomes will be interpreted as success.

Please note that the result object is not compatible with the circuit interface and only here for backward-compatibility, when invoking operations manually.

In compositions or workflows, operations will always be called using the circuit interface.

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.

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.

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.

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.

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.

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.

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.

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

Options

Class Dependencies

If you want to configure values or dependencies on the operation class level, use the ClassDependencies module.

The usage of this feature is not recommended. Use a dry-container instead.

You may use the self[]= setter to add directives to the operation class.

These variables will be available within every step of the operation and after.

class Create < Trailblazer::Operation
  extend ClassDependencies

  # Configure some dependency on class level
  self[:validator] = AlwaysTrue

  step :validate
  step Subprocess(Insert)

  def validate(ctx, validator:, params:, **)
    validator.(params)
  end
end

Starting with the invocation of the Create operation, the validator variable is injected into the ctx and passed on.

def validate(ctx, validator:, params:, **)
  validator.(params)
end

The variable is readable from ctx even when the operation finishes - so be careful in nested setups.

signal, (ctx, _) = Create.([ctx, {}])

puts ctx[:validator] #=> AlwaysTrue

Note that variables from within a nested operation are readable in the outer operation after the nested one has been invoked.

Signals

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

You can bypass this by returning a signal directly.

{{ “signal-validate” tsnippet }}

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

{{ “signalhelper-validate” tsnippet }}

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

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

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