LIKE US FOR UPDATES + GET A FREE STICKER PACK!

Operation

Operation Contract

Last updated 29 August 2017 trailblazer v2.0 v1.1

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.( 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
  extend Contract::DSL

  contract do
    property :title
    property :length

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

  step Model( Song, :new )
  step Contract::Build()
  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.()
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.

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.({}) # 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.({ 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.

Key

Per default, Contract::Validate will use options["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.({ "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.({ 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.

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.( 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.({ title: "A" })
result["contract.form"].errors.messages #=> {:title=>["is too short (minimum is 2 ch...

Use this if your operation has multiple contracts.

Dry-Schema

It is possible to use a Dry::Schema directly as a contract. This is great for stateless, formal validations, e.g. to make sure the params have the right format.

require "dry/validation"
class Create < Trailblazer::Operation
  extend Contract::DSL

  # contract to verify params formally.
  contract "params", (Dry::Validation.Schema do
    required(:id).filled
  end)
  # ...

  step Contract::Validate( name: "params" )
  # ...
end

Schema validations don’t need a model and hence you don’t have to instantiate them.

Dry: Guard Schema

Dry’s schemas can even be executed before the operation gets instantiated, if you want that. This is called a guard schema and great for a quick formal check. If that fails, the operation won’t be instantiated which will save time massively.

require "dry/validation"
class Delete < Trailblazer::Operation
  extend Contract::DSL

  contract "params", (Dry::Validation.Schema do
    required(:id).filled
  end)

  step Contract::Validate( name: "params" ), before: "operation.new"
  # ...
end

Use schemas for formal, linear validations. Use Reform forms when there’s a more complex deserialization with nesting and object state happening.

Dry: Explicit Schema

As always, you can also use an explicit schema.

# app/concepts/comment/contract/params.rb
require "dry/validation"
MySchema = Dry::Validation.Schema do
  required(:id).filled
end

Just reference the schema constant in the contract method.

# app/concepts/comment/delete.rb
class Delete < Trailblazer::Operation
  extend Contract::DSL
  contract "params", MySchema

  step Contract::Validate( name: "params" ), before: "operation.new"
end

Manual Extraction

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

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

  contract do
    property :title
  end

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

  step Model( Song, :new )
  step Contract::Build()
  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.

Dependency Injection

In fact, the operation doesn’t need any reference to a contract class at all.

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

The contract can be injected when calling the operation.

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

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

When calling, you now have to provide the default contract class as a dependency.

Create.(
  { title: "Anthony's Song" },
  "contract.default.class" => MyContract
)

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

Manual Build

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

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

  contract do
    property :title
    property :current_user, virtual: true
    validates :current_user, presence: true
  end

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

  def default_contract!(options, constant:, model:, **)
    constant.new(model, current_user: self["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.

step Contract::Build( builder: ->(options, constant:, model:, **) {
  constant.new(model, current_user: options["current_user"])
})

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