cult3

Getting started with Operations in Trailblazer

May 11, 2016

Table of contents:

  1. What is an Operation?
  2. What are the benefits of Operations?
  3. Setting up the User Model
  4. Creating the User concept and Operation
  5. Creating the Create Operation
  6. Writing the tests
  7. Implementing the Operation
  8. Conclusion

A couple of weeks ago I gave my review of Trailblazer. Trailblazer is a “framework” for managing the inevitable abstraction you will need to introduce to your Ruby application once it reaches a certain level of complexity.

One of the most important aspects of Trailblazer is that your Models and Controllers should be very thin. Controllers should be lean HTTP endpoints, and your Models should only deal with associations and querying.

Trailblazer introduces Operations, which are classes that encapsulate the business logic around a given action of your application.

In today’s tutorial we are going to start to explore the concept of Operations in Trailblazer.

Last week we looked at setting up a Trailblazer project with Ruby on Rails. If you missed that tutorial I would recommend going back and reading it before continuing with this tutorial.

What is an Operation?

Before we take a look at some code, lets first understand exactly what an Operation is.

An Operation is a class that encapsulates the business logic of a given action. For example, the action of creating a new user, or updating an existing user.

Operations hold the responsibility of mapping the incoming data, validation, creating the object graph, and saving to the database.

Although this seems like the Operation has a lot of responsibility, the Operation itself is actually just conducting these responsibilities to other classes. The Operation is simply a single interface to a collection of composed collaborators.

Typically the code that would normally be in your Model or your Controller would be moved into the Operation. Your Model will no longer be responsible for validation or callbacks, and your Controller will no longer be responsible for dealing with any business logic that spills outside of the Model.

What are the benefits of Operations?

In my opinion, one of the big benefits of Operations is that it makes it immediately obvious as to how the actions of the application are implemented.

In a Trailblazer application, if you wanted to see the process of creating a user, you would go to the user concept and look at the Operation. I find encapsulating actions in a single class makes understanding a new application much easier.

Secondly, testing a given action is also much easier.

When testing a given action of your application, you can very easily Unit Test the Operation. This makes it easy to test the various scenarios your application will find itself in, and it gives you a lot of confidence that your code is working correctly.

I find it easier to focus my tests on a given action, rather than as separate Model and Controller tests that try to cover every eventuality.

A third benefit of using Operations is that you can use them as factories in your tests. So whenever you wanted to create a new user in one of your tests, you could use an Operation, rather than something like Factory Girl.

This ensure there aren’t any weird discrepancies between your application and your test factories.

Setting up the User Model

The first Operation we will look at will be for creating a new user in the application. This will be an easy place to begin with, but we’ll look at some of the interesting aspects of Operations along the way.

So the first thing we need to do is to create the User model and migration using the Rails generator:

bin/rails g model User email:string password_digest:string confirmed_at:timestamp

This will create the model, migration, and the model unit test class.

Creating the User concept and Operation

The next thing we need to do is to create the user concept directory, and the Operation class.

As I mentioned in Getting started with Trailblazer and Ruby on Rails, in a Trailblazer application, classes are grouped by concept, rather than type.

So the first thing we need to create is a user directory that sits under the concepts directory.

Next we can create the operation.rb file that will hold the Operation class.

Creating the Create Operation

The first action we will be looking at will be the user create action:

class User < ActiveRecord::Base
  class Create < Trailblazer::Operation
    include Model
    model User, :create
  end
end

As you can see, I’ve created the Create class that extends the Trailblazer::Operation class.

You will also notice that I’m nesting the Create class inside of the User Active Record class. This does not couple the Create class to the User class or to Active Record, it simply gives us the nice syntax of User::Create. This provides the context of the action.

Next I’m including the Model module, and I’m setting the model to User, and the action to :create

This allows the Operation to know what type of model we’re working with, and the given action of this Operation. This simply means the Operation will instantiate the correct class automatically, so we don’t have to do that manually.

With the basics of the Operation class now set up, we can take a look at writing some tests.

Writing the tests

As usual I’m going to be using Minitest for my tests. You can use whatever test framework you like, it doesn’t really make a difference:

require 'test_helper'

class UserCreateTest < ActiveSupport::TestCase
end

The first test I will write will be to ensure that the Operation requires the presence of the email and the password fields:

test 'require presence of email and password' do
  res, op = User::Create.run(user: {})

  assert_not(res)
  assert_includes(op.errors[:email], "can't be blank")
  assert_includes(op.errors[:password], "can't be blank")
end

The res variable is a boolean that shows if the Operation ran successfully or not. The op variable is the Operation itself.

In this example I’m asserting that the res is false and the errors array on the Operation has been filled with the errors that I’m looking for.

Next I’m going to test to ensure that the email is valid:

test 'require valid email' do
  res, op = User::Create.run(user: { email: 'invalid email' })

  assert_not(res)
  assert_includes(op.errors[:email], 'is invalid')
end

Next I will test to ensure that the email address must be unique:

test 'require unique email' do
  User::Create.(user: { email: 'name@domain.com', password: 'password' })

  res, op =
    User::Create.run(user: { email: 'name@domain.com', password: 'password' })

  assert_not(res)
  assert_includes(op.errors[:email], 'has already been taken')
end

In this test I’m using the Operation as a factory to seed the database with a user and then I’m attempting to add a new user with an existing email address.

Although this is a simple example, it shows how you can use Operation classes instead of factories.

And finally I will assert that the Operation can create a new user successfully:

test 'create user' do
  res, op =
    User::Create.run(user: { email: 'name@domain.com', password: 'password' })

  assert(res)
  assert_instance_of(User, op.model)
end

Here I’m asserting that the response was true and that the model property of the Operation is an instance of User. I should probably check to make sure that the User has been persisted with the correct data, but you get the idea.

With these tests in place, we can now take a look at adding the functionality to the Operation to make them pass!

Implementing the Operation

The first thing we need to do is to define the Operation contract. The contract defines the properties of the Operation and any validation rules.

require 'reform/form/validation/unique_validator.rb'

class User < ActiveRecord::Base
  class Create < Trailblazer::Operation
    include Model
    model User, :create

    contract do
      property :email
      property :password, virtual: true

      validates :email, presence: true, email: true, unique: true
      validates :password, presence: true
    end
  end
end

First I define the two properties for email and password. The password property is virtual because the property of the User model is password_digest. The Operation will automatically sync the properties to the model, and so by defining the password property as virtual, this property will not be added to the model.

Next I define the validation rules. If you’re familiar with Rails validation, this should look pretty familiar (Working with Validation in Ruby on Rails) I’m also using the valid_email gem to check for valid email addresses.

When the Operation is run, the process method is called. So the next thing we need to do is to implement that method:

require 'reform/form/validation/unique_validator.rb'

class User < ActiveRecord::Base
  class Create < Trailblazer::Operation
    include Model
    model User, :create

    contract do
      property :email
      property :password, virtual: true

      validates :email, presence: true, email: true, unique: true
      validates :password, presence: true
    end

    def process(params)
      validate(params[:user]) { |f| f.save }
    end
  end
end

First we validate that the params that were passed in are valid. If the validation rules are satisfied we can take an action inside of the block. The block in this case will only be run if the validation is satisfied.

Inside of the block we can simply call the save method that will sync the properties of the contract to the model and then save it to the database.

Notice how we didn’t have to do anything to sync the properties and we didn’t instantiate a new instance of User, as the operation automatically knows how to do this.

The final thing that we have to do is to generate the digest for the user’s password:

require 'bcrypt'
require 'reform/form/validation/unique_validator.rb'

class User < ActiveRecord::Base
  class Create < Trailblazer::Operation
    include Model
    model User, :create

    contract do
      property :email
      property :password, virtual: true

      validates :email, presence: true, email: true, unique: true
      validates :password, presence: true
    end

    def process(params)
      validate(params[:user]) do |f|
        generate_digest
        f.save
      end
    end

    def generate_digest
      model.password_digest = BCrypt::Password.create(contract.password)
    end
  end
end

Here I’ve defined a generate_digest method that will create a new digest from the given password and set it on the model. This method is called inside of the block of the process method, but before the model is saved.

You can think of this as a before_create callback that you would typically see on an Active Record model.

But instead of lumping this functionality into the model, we have simply defined it in the Operation so there’s no chance of this being inadvertently executed.

If you run those tests from earlier you should see them all pass.

Conclusion

In today’s tutorial we have looked at a fairly simple example of creating a Trailblazer Operation, but we have touched upon some very important concepts.

An Operation encapsulates the business logic of a given action. This makes it really easy to understand a given action because all of the code is in one file, rather than distributed between Models, Controllers, and Service Objects.

At first glance, the Operation seems to have a lot of responsibility. But on closer inspection, we can see that the Operation is simply providing an interface to the underlying collaborators.

As we continue to explore Trailblazer we will look at a lot more of the interesting benefits of encapsulating your business logic in Operations.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.