Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
The Ins and Outs of a Trailblazer Operation Contract

The Ins and Outs of a Trailblazer Operation Contract

These notes record my attempts to understand how and when to populate a Trailblazer contract. They may be incomplete, inaccurate or just plain wrong. They may also be right; I hope they are! Comments are welcome.

It all began with the requirement to seed a presenting contract from the inbound request. Having looked for answers in the Trailblazer book and on Gitter (there was a similar conversation on the 25th September), taking some time to understand some of the Trailblazer code provided the answers and spawned this writing.

TL;DR

  1. The contract is normally populated by a processing operation (run) when its process method calls validate.
  2. Presenting operations (form, present, and collection) don't populate the form
  3. unless prepopulate! is executed (which only form does)
  4. or it is executed explicitly.
  5. Prepopulators cannot access params
  6. but validate or deserialize can be used by presenting operations to populate the form from params.
  7. The setup_model! hook is a good place to do this.

Present and Process

When looking at the workflow from a request's perspective, where everything begins with a Rails controller action, the Trailblazer way is for the controller to delegate to an Operation that prepares a form for the controller action's view to render, and the view uses a Cell to render the form.

The typical controller action either renders output or processes input, so it follows that the two basic usage patterns for an operation are preparation of a form for rendering and processing a submitted form. Trailblazer calls these patterns presenting and processing.

The objective here is to understand the operation's inputs and outputs when used for presenting and processing. Input can come from two places: the request's params hash and the application's models. Its outputs are form and model objects, the latter of which can optionally be saved to the persistence layer (the database).

An operation is an ephemeral object that is instantiated to perform one of these tasks and is discarded afterwards, so the first step is to instantiate an operation object.

Instantiation

Trailblazer operation classes have class methods that begin an operation. These methods take two arugments: a params hash and an optional options hash. The methods are present, form, collection, run and reject.

The params argument would usually receive the controller's params hash. The options affect instantiation but are not passed into the instantiated operation object. As such, they aren't discussed here.

The first three methods, present, form, and collection are for presenting, whereas run and reject are for processing. They all set instance variables in the controller action's namespace that the controller may use to render its response once the operation completes:

  • @operation - the operation object
  • @model - the underlying model object from the persistence layer
  • @collection - the same as @model
  • @form - the operation's form.

Note also the @form is the same as @operation.contract and that @model is the same as @operation.model.

Contract and form mean the same thing; contract is a broader term used within an operation because it defines its properties and their validity; the operation does not know if a controller will present its contract as a visual form.

The form, present and collection controller methods initiate an operation in the same way; they all run the same present class method which just calls another class method called build_operation to instantiate a new instance of the operation class, passing it the params hash.

The final step in build_operation calls setup_operation_instance_variables which assigns the controller instance variables, @operation, @model and @form. The first two are simple assignments but the assignment of @form is what causes the contract to be instantiated.

The form, present and collection controller methods all return the instantiated operation. They differ only in what happens after the operation (and possibly its contract) have been instantiated:

  • present - nothing else happens but @form is not set and this also means that the contract is not instantiated.
  • collection - a @collection instance variable is assigned with the same value as @model; @form is set.
  • form - @form is set and any form prepopulators are run.

Note that the contract is instantiated when first accessed; If present is used to instantiate the operation then @operation.contract will instantiate the contract.

Note also that instantiating the contract does not populate it. That is performed during validation or prepopulation; these are described below.

The run operation is different. In addition to the abovementioned arguments, it also accepts an optional block. It runs a run class method which calls the same build_operation class method to instantiate the operation in the same way as described above, includng instantiation of the contract. It then calls its run instance method and yields to the block (if given) if the run was successful. It propagates the return value of run.

The reject operation is exactly the same as run except that it yields to the block if run was unsuccessful.

The operation instance's run method calls another instance method called process, passing params to it. This method needs to be implemented by the operation.

The typical pattern for process is to call a validate class method, passing the params hash as an argument. The first thing that this does is ensure that the the contract has been instantiated. It then calls validate on the contract and yields to an optional block if the validate was successful.

Any value returned by process is ignored; the return value of run is an array [result, operation] where result is the validity of the operation and operation is the operation instance itself.

An operation's validity is returned by valid? and can be invalidated by invalid!, as happens when a validated contract contains errors (its errors are accessible as the contract.errors - class Reform::Contract::Errors which is a subclass of Enumerable). The contract is only invalid if it contains errors (contract.errors.empty? == false).

The primary input to an operation is the controller's params hash, and Trailblazer provides the controller action with DSL methods that imply this:

[run|present|form|collection] Operation::Class [do block]

Params

When an operation is instantiated, it receives a hash of parameters that is accessible when presenting or processing. Instantiation also calls an, empty by default, setup_params! method that an operation may implement to modify the given hash:

def setup_params!(params)
  params.merge!(foo: 'bar')
end

This hook happens before the model or form are initialised.

Summary:

  • setup_params! is, if required, implemented by the operation.
  • setup_params! is the first hook in the operation lifecycle.

Model

The model is created during instantiation of the operation by its Setup! method, which calls build_model! that, in turn, calls assign_model! and then setup_model, the first of which calls a model! method that it expects to instantiate the model. These methods all receive the params hash as an argument but do nothing in a default operation.

An operation may implement these methods directly but the usual approach is to use the Model module with code similar to the below:

include Model
model Comment, :create

The Model module implements the model! method as well as a model command to tell the operation its model's name and how model! should instantiate it.

(The Model module was originally called CRUD and is referred to as such in the Trailblazer book.)

The above example configures the operation to use a create action to instantiate a Comment model. The supported actions are create, which instantiates a new (empty) model object, and update (also aliased as find) that uses params[:id] to instantiate an existing (populated) one.

The action can also be specified separately with an action command and inherited operations use this to specify another action (like action :update in an update operation).

An operation's model is usually an ActiveRecord (in a Rails application) object but can be any kind of object (e.g. ROM, PORO), or no object at all.

The setup_model! method is provided as a way to augment the instantiated model. An operation may implement this to perform its own setup tasks and a typical usage pattern does this to instantiate dependent objects (e.g. where a model has a has_many relationship with another).

Summary:

  • model! sets up @model and setup_model! can augment it. These are called sequentially during instantiation of the operation.
  • model! is usually provided by including the Model module.
  • setup_model! is, if required, implemented by the operation.
  • setup_model! is the second hook in the operation lifecycle.

Form

An operation may define a form (aka contract), a disposable twin of the model. If not defined explicitly then the contract is an instance of Reform::Form.

A contract declaration takes the form:

contract [class] block

The class, if given, refers to an externally defined Reform class, however the usual style is to use the block to define the contract. A contract contains properties that may be nested. Properties are attributes but not all attributes are properties - only those explicitly declared as such.

The form is created empty when the operation is instantiated. It can be populated (which assigns values to its properties) in two ways, prepopulation and validation, the latter of which is usually invoked when processing the operation (run).

Before validate runs, the model is loaded from the database during the instantiation of the contact using params[:id] to load a specific record. None of the other params are used at this point.

Validate is usually passed the subset of params that relates to the form:

def process(params)
  validate(params[:comment]) do...
end

Populating the contract from the params passed to validate occurs in a private deserialize method within the contract's Reform::Form ancestor.

Prepopulation occurs only if the operation is instantiated using form or if contract.prepopulate! is called explicitly. Prepopulation invokes per- property prepopualtors (a method or lambda) that can directly modify the the contract's properties:

property :some_prop, prepopulator: ->(*) { self.some_prop = 'a value' }

A prepopulator method has one argument, a normally-empty options hash, and is attached to the property it's meant to be used to alter. However, it has access to the whole contract via its self so can update any of its properties:

property :some_prop, prepopulator: ->(*) { self.some_other_prop = 'a value' }

But the params hash isn't available in a prepopulator. Two ways that a contract can be prepopulated from params involve using the operation hook setup_model! to either seed model from params or to run the contract's deserialize method. The setup_model! hook is used because it is invoked after instantiating the model but before instantiating the contract. Seeding the model from params goes like this:

model.attributes = params.slice(:property1,:property2,:propertry3).permit!
model.relation.build(params.slice(:property4, :property5).permit!)      

Mass-updating model attributes requires the delicate dance around the strong parameters of ActiveRecord. The deserialize method does not suffer this pain but it is a private method. One way to execute it is by running validate and then clearing any errors raised (since they relate to processing rather than presenting the operation):

contract.errors.clear unless contract.validate(params)

A (slightly, just a little bit, wrong?) alternative is to use send to call the private method directly:

contract.send(:deserialize, params)

It's possible, also, to invoke prepopulators from setup_model!:

contract.prepopulate!

If both deserialize and prepopulate are used then the order that they are used in is important if both update the same properties.

Any differences between the contract's properties and the current state of the model are identified as changes, as can be seen by

contract.changed

Summary:

  • The contract is usually populated from params in a processing operation by calling validate from its process method.
  • An alternative to validate is to use prepopulate! but this doesn't have access to params
  • An alternative to prepopulate that can use params is to call validate (or deserialize) from setup_model!.
  • The setup_model! hook can be used by a presenting operation to populate its contract from params by validate or deserialize.

Final Thoughts

There is no contract-level prepopulator. This does not work:

contract, prepopulator: ->(*) { self.some_attr = 'some value' }

It would be helpful if the deserialize private method could be made public.

A proper presenter hook in the operation that is only called on presenting operations would allow presenter-specific tasks to be performed without impacting the processing operation, e.g.

def present
  desearialize(params)
end

Currently, ensuring this requires use of build to instantiate a subclass operation in speciic circumstances.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.