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.
- Define the contract class (or multiple of them) for the operation.
- Plug the contract creation into the operation’s pipe using
Contract::Build
. - Run the contract’s validation for the params using
Contract::Validate
. - If successful, write the sane data to the model(s). This will usually happen in the
Contract::Persist
macro. - 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 call
ing 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 %}