Confidently manage business logic with ActiveInteraction
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.
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:
- It blows up if
params[:user]isn't a hash.
- It lacks contextual information about why it does what it does.
- It has too many responsibilities.
- Controller specific setup is required in order to test the logic.
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.
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: /^.+@.+$/ 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.
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
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.
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.