cult3

Using Inheritance for Trailblazer Operations

Jun 01, 2016

Table of contents:

  1. What is Operation Inheritance
  2. The problem we’re going to be solving
  3. Setting up the project
  4. Defining the Contract
  5. Building the Operation
  6. Writing the tests
  7. Conclusion

Over the last couple of week’s we’ve been looking at some of the basic concepts that go into building a Ruby application using Trailblazer.

A technique that is promoted heavily in the Trailblazer book is inheritance. Inheritance is of course one of the basic building blocks of Ruby (Understanding Inheritance in Ruby), and so you are probably already familiar with how it can be used (and abused!).

Inheritance is actually used in a number of Trailblazer components, but in today’s tutorial we are going to be looking at Inheritance in the context of Operations.

What is Operation Inheritance

Before we get into this article, lets first make it clear what we mean by Operation Inheritance.

As with normal inheritance in Ruby, when you inherit from an existing Operation class, the child class will have access to the methods and properties of it’s parent.

You can then modify anything in the child class that is different, or specialised from the parent.

For example, you might have an Article::Create Operation that defines certain business rules and methods.

You might also have an Article::Update Operation that has mostly the same rules and methods, but with a couple of slight adjustments.

In this case, instead of repeating that logic, you could inherit from the Article::Create and make the adjustments in the Article::Update.

This would ensure that any changes to the business rules in the future would not leave discrepancies between the two actions.

Of course, inheritance is often a really bad design decision, and so the choice to use inheritance is not always this clear. You should be confident that inheritance is the correct design decision, and not just a way of reducing duplication.

The problem we’re going to be solving

I typically don’t use inheritance that often in my day-to-day programming as I find it often causes more hassle than it’s worth (Understanding Inheritance in Ruby).

However, under certain circumstances, it is most definitely the correct decision.

In today’s tutorial I’m going to using Operation inheritance to deal with the different ways a user can be added to the application.

Firstly I’m going to need to import users from an existing system where I only know the user’s email address. These users will not have a username or password, and they will need to be confirmed by the user.

Secondly I’m going to need a way to create confirmed users so they don’t have to go through the process of confirming their email address. This will allow me to seed the database with users initially, and it will be useful as a factory in my tests.

And thirdly, I need to write the Operation that will be used to create new users once the application is live. This Operation will allow the user to add their username, email address, and password, and then trigger an email confirmation so they can confirm their email address.

So we need 3 different variations of the User::Create Operation. The rules are largely the same, but there are small variations between the versions. As each implementation is essentially a specialised version, this is a perfect opportunity to use inheritance.

Setting up the project

In this tutorial I’m going to be using Rails and Minitest. Trailblazer is decoupled from Rails, and you can use whatever testing framework you want, it doesn’t really matter.

So the first thing I’m going to do is to create a new Rails project. I’ve covered how to do this a number of times, but if you’re unsure, take a look at Getting started with Trailblazer and Ruby on Rails.

Next we need to create the User model, migration, and test file:

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

As you can see this is a fairly typical user database table with the email, username, and password digest columns.

I’m also including the confirmed_at timestamps to show confirmed users, and the imported_at timestamp for imported users.

Defining the Contract

The first thing I’m going to do is to define the Contract for the Operations. As we saw in What are Trailblazer Contracts?, in this example I’m going to create the Contract as a standalone class as I feel it makes understanding the validation rules of these Operations easier.

Create a new file called contract.rb under your user concept directory. I’m splitting my Operation classes into a further create namespace, but you don’t have to if you don’t want to:

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

module User::Create::Contract
  class Base < Reform::Form
    model User
    property :email
    property :username
    property :password, virtual: true

    validates :email, presence: true, unique: true, email: true
    validates :username, username: true, unique: true, allow_blank: true
    validates :password, presence: true, length: { minimum: 8 }
  end

  class Confirmed < Base
    validates :username, presence: true
  end

  class Imported < Base
  end

  class Default < Base
    validates :username, presence: true
  end
end

First I define a Base class that holds the majority of the properties and validation rules.

I then create three child classes for Confirmed, Imported, and Default and then I add any additional validation rules that are required for those specialised instances.

In this case I simply require the presence of the username in the Confirmed and Default instances, and leave it not required in the Imported instance.

Building the Operation

Next up we need to build out the Operation classes for each of the different ways a new user can be created in the application.

Create a new file called operation.rb in the same concept directory as before:

require 'bcrypt'

module User::Create::Operation
end

Again, I’m using an extra Create namespace, for this particular application, but that is not required so you don’t have to include it if you don’t want to.

First up I’ll add the Base class that acts as the abstract class that will be extended from. This class includes a couple of methods we will need in the child classes:

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

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

    def generate_token
      model.confirmation_token = SecureRandom.urlsafe_base64
    end

    def process(params)
      raise NotImplementedError,
            'User::Create::Operation::Base is an abstract class'
    end
  end
end

Ruby does not have the concept of abstract classes and so we can simply throw a NotImplementedError exception if this class is used as a concrete class. Using Ruby Exceptions.

As you can see, I’ve defined a couple of methods for generating digests and tokens.

Next up I will add the Confirmed implementation:

class Confirmed < Base
  contract User::Create::Contract::Confirmed

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

  def set_confirmed_timestamp
    model.confirmed_at = Time.now
  end
end

First I set the User::Create::Contract::Confirmed contract from earlier.

Next, I define the process method. In this method I validate that the incoming parameters are valid, and then I generate the password digest and I set the confirmed timestamp before saving the properties to the model.

Next up I will add the Imported implementation:

class Imported < Base
  contract User::Create::Contract::Imported

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

  def set_imported_timestamp
    model.imported_at = Time.now
  end
end

Again I will first set the Contract on this Operation.

This time, inside the process method I will validate the incoming parameters, generate the password digest, generate the confirmation token, and then I will set the imported timestamp to show that this was an imported user.

When importing users I’m going to generate a random password so all users do actually have a password in the database.

And finally I will add the Default implementation:

class Default < Base
  contract User::Create::Contract::Default

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

  def send_confirmtion_email
    UserMailer.confirm_membership(model).deliver_later
  end
end

Once again I first set the correct Contract on the Operation class.

This time inside of the process method I validate the incoming parameters, generate the password digest, generate the confirmation token, and then I save the new user to the database.

Once the new user is created I will send the confirmation email as an after save callback.

Here is this class in full:

require 'bcrypt'

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

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

    def generate_token
      model.confirmation_token = SecureRandom.urlsafe_base64
    end

    def process(params)
      raise NotImplementedError,
            'User::Create::Operation::Base is an abstract class'
    end
  end

  class Confirmed < Base
    contract User::Create::Contract::Confirmed

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

    def set_confirmed_timestamp
      model.confirmed_at = Time.now
    end
  end

  class Imported < Base
    contract User::Create::Contract::Imported

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

    def set_imported_timestamp
      model.imported_at = Time.now
    end
  end

  class Default < Base
    contract User::Create::Contract::Default

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

    def send_confirmtion_email
      # Send email
    end
  end
end

Writing the tests

With all of the Operation implementations in place I can now write some tests to make sure everything is working as it should.

I know this isn’t strictly TDD. I actually did use TDD to get to this point when I initially wrote this code, but I thought it would be easier to understand if I didn’t write this tutorial using TDD.

First up I will ensure that the Base class throws a NotImplementedError exception when it is run:

require 'test_helper'

module User::Create::OperationTest
  class BaseTest < ActiveSupport::TestCase
    test 'throw NotImplementedError exception' do
      assert_raises NotImplementedError do
        User::Create::Operation::Base.run({})
      end
    end
  end
end

Strictly speaking, you don’t really need this type of test, but I thought I would include it because it shows that “abstract” class technique of throw exceptions for methods that should be implemented in child classes.

Next up we have the Confirmed implementation tests:

class ConfirmedTest < ActiveSupport::TestCase
  def setup
    @user =
      User::Create::Operation::Confirmed.(user: attributes_for(:user)).model
  end

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

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

  test 'email should be a valid email' do
    res, op =
      User::Create::Operation::Confirmed.run(user: { email: 'invalid email' })

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

  test 'email should be unique' do
    res, op =
      User::Create::Operation::Confirmed.run(user: { email: @user.email })

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

  test 'username should be a valid username' do
    res, op =
      User::Create::Operation::Confirmed.run(
        user: {
          username: 'invalid username'
        }
      )

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

  test 'username should be unique' do
    res, op =
      User::Create::Operation::Confirmed.run(user: { username: @user.username })

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

  test 'password should be greater than 8 characters' do
    res, op = User::Create::Operation::Confirmed.run(user: { password: 'abc' })

    assert_not(res)
    assert_includes(
      op.errors[:password],
      'is too short (minimum is 8 characters)'
    )
  end

  test 'create confirmed user' do
    res, op =
      User::Create::Operation::Confirmed.run(user: attributes_for(:user))

    assert(res)
    assert(op.model.confirmed?)
  end
end

I’ll not go through each test as we’ve covered this kind of testing a number of times now and so it should be fairly easy to see what’s going on in each test.

A couple of things I will note.

First I’m seeding a user in the database in the setup method using the Operation itself.

I’m using attributes_for(:user) from factory girl Replacing Fixtures with Factory Girl in Ruby on Rails just as a way of generating hashes of correct data when creating users. That’s the only bit of factory girl I’m using, I’m not actually using the factories.

And I’ve added a confirmed? method to the User model for checking whether a user is confirmed or not.

Next up we have the Imported implementation tests:

class ImportedTest < ActiveSupport::TestCase
  def setup
    @user =
      User::Create::Operation::Imported.(user: attributes_for(:imported_user))
        .model
  end

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

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

  test 'email should be a valid email' do
    res, op =
      User::Create::Operation::Imported.run(user: { email: 'invalid email' })

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

  test 'email should be unique' do
    res, op =
      User::Create::Operation::Imported.run(user: { email: @user.email })

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

  test 'password should be greater than 8 characters' do
    res, op = User::Create::Operation::Imported.run(user: { password: 'abc' })

    assert_not(res)
    assert_includes(
      op.errors[:password],
      'is too short (minimum is 8 characters)'
    )
  end

  test 'create imported user' do
    res, op =
      User::Create::Operation::Imported.run(
        user: attributes_for(:imported_user)
      )

    assert(res)
    assert_not(op.model.confirmed?)
    assert(op.model.imported?)
  end
end

And finally we have the Default implementation tests:

class DefaultTest < ActiveSupport::TestCase
  def setup
    @user =
      User::Create::Operation::Confirmed.(user: attributes_for(:user)).model
  end

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

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

  test 'email should be a valid email' do
    res, op =
      User::Create::Operation::Default.run(user: { email: 'invalid email' })

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

  test 'email should be unique' do
    res, op = User::Create::Operation::Default.run(user: { email: @user.email })

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

  test 'username should be a valid username' do
    res, op =
      User::Create::Operation::Default.run(
        user: {
          username: 'invalid username'
        }
      )

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

  test 'username should be unique' do
    res, op =
      User::Create::Operation::Default.run(user: { username: @user.username })

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

  test 'password should be greater than 8 characters' do
    res, op = User::Create::Operation::Default.run(user: { password: 'abc' })

    assert_not(res)
    assert_includes(
      op.errors[:password],
      'is too short (minimum is 8 characters)'
    )
  end

  test 'create default user' do
    res, op = User::Create::Operation::Default.run(user: attributes_for(:user))

    assert(res)
    assert_not(op.model.confirmed?)
    assert_not(op.model.imported?)
  end
end

If you run all of those tests you should see them pass.

Conclusion

So hopefully this was a good illustration of how to use Trailblazer Operations and inheritance.

In today’s tutorial I required the functionality to create users in three different ways. This is the perfect opportunity to use inheritance because we can say that each method is a specialisation of the create user process.

In this example we benefit from being able to reuse the methods to create the password digest and create a confirmation token. But probably more important is the semantic importance of understanding the relationship between the three classes.

As I mentioned in the introduction to this post, I usually tend to avoid inheritance as I find there is often a much better solution.

But inheritance does have a time and a place, and hopefully as you can see from today’s tutorial, it makes our code a lot better when used correctly.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.