Operation

Operation Contract

Last updated 03 December 2016 operation 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 module 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/comment/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

Using contracts consists of four steps.

  • Defining the contract class(es) used in the operation.
  • Plugging creation and validation into the operation’s pipetree.
  • Run the validation, and if successful, write the sane data to the model(s). This will usually be done in the Persist step.
  • After the operation has been run, interpret the result. For instance, a controller calling an operation will render a erroring form for invalid input.

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

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

Contract Definition

Defining the contract can be done via the contract block as in the example above. This is called inline contract since it happens straight in the operation class.

An alternative approach is to reference an external Reform class from a separate file. This is called an explicit contract.

class MyContract < Reform::Form
  property :title
  property :length
  
  validates :title,  presence: true
  validates :length, numericality: true
end

This class can now be referenced in the operation.

# app/concepts/comment/create.rb
class Create < Trailblazer::Operation
  extend Contract::DSL
  
  contract MyContract
  
  step Model( Song, :new )
  step Contract::Build()
  step Contract::Validate()
  step Contract::Persist( method: :sync )
end

The explicit file/class convention is the preferred Trailblazer style as it keeps classes small and maximizes reusability. Please make sure you’re following Trailblazer’s naming convention to avoid friction. # TODO: link.

Pipetree

After defining, you have to create and invoke the Reform object in your operation. The easiest way is to use Contract’s macros for that.

# app/concepts/comment/create.rb
class Create < Trailblazer::Operation
  # ...
  step Model( Song, :new )
  step Contract::Build()
  step Contract::Validate()
  step Contract::Persist( method: :sync )
end

For a better understanding, here’s the compiled pipetree.

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

With a Reform contract the relevant steps are as follows.

  1. Since every Reform object needs a model, use the Model macro to instantiate or find it.
  2. Let the Contract macro instantiate the Reform object for you. The object will per default be pushed to self["contract.default"].
  3. Let Contract::Validate extract the correct hash from the params. If this fails because the params are not a hash or the specified key can’t be found, it deviates to left track.
  4. Instruct Contract::Validate to validate the params against this contract. When validation turns out to be successful, it will remain on the right track. Otherwise, when invalid, deviate to the left track.
  5. Use the Persist macro to call sync or save on the contract in case of a successful validation.

Validate

The Contract::Validate step in the pipe is responsible to validate the incoming params against its contract. This boils down to the following simple code.

contract.validate(params)

Given the contract is a Reform object, the step invokes the validate method for you and passes in the params from the operation call.

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.

After the validation, the sane data sits in the contract. No model is touched for validation, you still need to push the validated data from the contract to the model(s).

This typically happens via the Persist step which usually sits right after the validation in the pipe. Since Validate will deviate to the left track in case of an unsuccessful validation, this step is only called for valid data.

Persist

To push data from the contract to the model(s), use Persist. Again, this simply calls Reform’s persisting for you and can be reduced to the following snippet.

contract.save

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

self.| Persist[method: :sync]

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

Read more about how Reform handles validations and persisting.

Default Contract

You don’t have to assign a name for a contract when using only one per operation.

# app/concepts/comment/create.rb
class Create < Trailblazer::Operation
  extend Contract::DSL
  
  contract do
    property :title
    # ...
  end
  # ...
end

The name will be default. The contract class will be available via self["contract.default.class"].

Create["contract.default.class"] #=> Reform::Form subclass

After running the operation, the contract instance is available via self["contract.default"].

result = Create.(..)
result["contract.default"] #=> <Reform::Form ...>

You can pass a name to contract.

# app/concepts/comment/update.rb
class Update < Trailblazer::Operation
  extend Contract::DSL
  
  contract :params do
    property :id
    validates :id,  presence: true
  end
  # ...
end

Multiple Contracts

Since contract can be called multiple times, this allows you to maintain as many contracts as you need per operation.

Naming also works when referencing constants.

# app/concepts/comment/update.rb
class Update < Trailblazer::Operation
  extend Contract::DSL
  contract :user, MyContract
end

When using named contracts, you can still use the Contract macros, but now you need to say what contract you’re referring to using the name: option.

# app/concepts/comment/update.rb
class Update < Trailblazer::Operation
  # ...
  step Model( Song, :new )
  step Contract::Build( name: "params" )
  step Contract::Validate( name: "params" )
end

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

Extracting Params

Per default, Contract::Validate will use self["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 Create < Trailblazer::Operation
  extend Contract::DSL
  
  contract do
    property :title
  end
  
  step Model( Song, :new )
  step Contract::Build()
  step Contract::Validate( key: "song" )
  step Contract::Persist( method: :sync )
end

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

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.

Naming Interface

You don’t have to use the contract interface to register contract classes in your operation. Use the constant: option to point the Contract builder directly to a class.

class Create < Trailblazer::Operation
  class MyContract < Reform::Form
    property :title
    validates :title, length: 2..33
  end
  
  
  
  step Model( Song, :new )
  step Contract::Build( constant: MyContract )
  step Contract::Validate()
  step Contract::Persist( method: :sync )
end

No DSL is used here.

Instead, the Contract step will build the contract instance and register it under self["contract.default"], which will then be used in the Validate step.

Explicit Naming

Explicit naming for the contract is possible, too.

class Create < Trailblazer::Operation
  class MyContract < Reform::Form
    property :title
    validates :title, length: 2..33
  end
  
  step Model( Song, :new )
  step Contract::Build( constant: MyContract, name: "form" )
  step Contract::Validate( name: "form" )
  step Contract::Persist( method: :sync, name: "form" )
end

Here, you have to use the name: option to tell each step what dependency to use.

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!(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: ->(operation, constant:, model:) {
    constant.new(model, current_user: operation["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).

Cheatsheet

—- do it yourself — -procedural