Confidently Manage Business Logic with ActiveInteraction

Co-authored by Taylor Fausak.

We are proud to announce the release of ActiveInteraction version 1.0. ActiveInteraction is a gem for managing application specific business logic. Instead of living in controllers or models, business logic can find a home in interactions. They are designed to integrate seamlessly with Rails by behaving like ActiveModels. Use ActiveInteraction to shrink your controllers, slim your models, and DRY your code.

Case Study

We built ActiveInteraction to solve a particular kind of problem. It’s one we think lots of Rails developers grapple with. Let’s walk through an example to see how ActiveInteraction can help.

The Fat Controller

Back in 2007, OrgSync started on Rails one point something. It looked like most Rails projects. Had it started on Rails 4, it would probably look like this:

class UserController < ActionController::Base
  def create
    unless params[:user].key?(:password)
      params[:user][:password] = SecureRandom.hex
    end

    @user = User.new(user_params)

    if @user.save
      Notifications.welcome(@user).deliver

      redirect_to @user
    else
      render 'new'
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :password)
  end
end

This approach is littered with problems:

The Fat Model

If the business logic doesn’t belong in the controller, where does it belong? The model is a natural fit since all this logic deals with it.

class User < ActiveRecord::Base
  before_save :ensure_password
  after_create :send_welcome_email

  private

  def ensure_password
    if password.nil?
      self.password = SecureRandom.hex
    end
  end

  def send_welcome_email
    Notifications.welcome(self).deliver
  end
end

The controller slims down and stays focused on request logic.

class UserController < ActionController::Base
  def create
    @user = User.new(user_params)

    if @user.save
      redirect_to @user
    else
      render 'new'
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :password)
  end
end

Something still isn’t right. The controller has to know which parameters the model cares about. And what if you don’t want to send a welcome email? Skipping callbacks is possible, but it’s a pain.

In addition, we moved the fat from controllers to models. Although that put the logic closer to where it belongs, we managed to pollute our model with email delivery and password generation. Models should be concerned with validation, storage, and retrieval.

Enter Mutations

We couldn’t help but feel like something was missing, but we didn’t know what the next step of our journey would be. That’s when we stumbled upon Architecture the Lost Years, a presentation by Robert Martin. It introduced us to the Interactor pattern, which we loved.

We searched for a Ruby interactor library and found Mutations. It seemed to fit our needs, so we began moving our business logic into interactions.

class CreateUser < Mutations::Command
  required do
    string :email, matches: /^[email protected]+$/
    boolean :send_welcome_email, default: true
  end

  optional do
    string :password
  end

  def execute
    ensure_password

    user = User.create!(inputs.slice(:email, :password))

    if send_welcome_email
      Notifications.welcome(user).deliver
    end

    user
  end

  private

  def ensure_password
    unless password_present?
      self.password = SecureRandom.hex
    end
  end
end

It resulted in more lines of code, but they were better lines of code. We quickly saw the benefits of this approach. We were able to easily share code between the web and API controllers. And thanks to its declarative nature, generating documentation was a piece of cake.

Models no longer contained conceptually distinct but practically tangled business logic. Instead, each piece of logic got its own easily understandable file. Models slimmed way down.

class User < ActiveRecord::Base
  # Down to a size 0!
end

The controller grew by a few lines but it still only dealt with what it had to.

class UserController < ActionController::Base
  def create
    outcome = CreateUser.run(params)

    if outcome.success?
      redirect_to outcome.result
    else
      @user = User.new

      outcome.errors.message.each do |attribute, message|
        unless @user.has_attribute?(attribute)
          attribute = :base
        end

        @user.errors.add(attribute, message)
      end

      render 'new'
    end
  end
end

This direction looked promising, but had a few problems. Notice how the controller creates a model solely for attaching errors. Mutation results don’t quack like ActiveModels, so using them with forms feels like a square peg in a round hole.

Mutations purposefully separates itself from Rails, which is a perfectly reasonable design decision. It comes at a cost though. Custom validators are rendered useless and Rails specific classes, like UploadedFile and TimeWithZone, aren’t supported.

Introducing ActiveInteraction

We wanted our custom validators and times with zones. We wanted interoperability with gems like Formtastic. We wanted a library built with Rails in mind.

We took what we loved from Mutations and built the gem we wanted.

class CreateUser < ActiveInteraction::Base
  string :email
  string :password, default: nil
  boolean :send_welcome_email, default: true

  validate :ensure_password

  validates :email, email: true
  validates :password, presence: true

  def execute
    user = User.create!(inputs.slice(:email, :password))

    if send_welcome_email
      Notifications.welcome(user).deliver
    end

    user
  end

  private

  def ensure_password
    unless password?
      self.password = SecureRandom.hex
    end
  end
end

Similar interfaces made the transition from Mutations to ActiveInteraction quick and painless. And just like before, the model ends up empty.

class User < ActiveRecord::Base; end

Unlike before, the controller changes very little.

class UserController < ActionController::Base
  def create
    outcome = CreateUser.run(params[:user])

    if outcome.valid?
      redirect_to outcome.result
    else
      @user = outcome
      render 'new'
    end
  end
end

Notice how the invalid outcome is assigned straight to @user. That’s because the outcome of running an interaction quacks like an ActiveModel. It can be dropped right into a form without having to jump through any hoops.

Conclusion

We’re so thrilled with this direction that we’ve been using interactions in production since July. Our development team has provided valuable feedback and a variety of use cases. Thanks to them, we’ve settled on a solid interface and a compelling feature set. It’s been a great addition to our code base and we hope it helps yours.

Check out the full documentation and more about ActiveInteraction on GitHub.

Originally published on the OrgSync developer blog.