Improving Large Rails Apps with Service Objects
Nothing has improved my Rails apps more than service objects.
A couple of weeks ago, I was asked to help out with an established Rails app.
I found a
User model that included 28 modules.
All but 8 were namespaced under
A core object in your system, used everywhere, and it’s thousands of lines long.
How many features have been bolted onto this model?
When you look at a method, which feature does it belong to?
Madness like this can be sidestepped with service objects.
Let’s start at a time before 28 includes. Let’s start at the beginning. Imagine you’ve started a SaaS business. It’s a help desk. Soon you have clients signing up. Then their users are creating accounts and receiving support.
Want to make your clients happy? Make their users happy. You’ve decided to add a welcome email for those users. It’ll provide a friendly hello and tips on how to use the help desk.
Where does your new code go?
Put it in the controller.
Code usually makes its first appearance in a controller. You add the welcome notification, the users are happy, and your clients are thrilled.
def new @user = User.new end def create @user = User.create(params[:user]) if @user.valid? Notifications.welcome(@user).deliver_later redirect_to(@user) else render 'new' end end
Looks pretty harmless. There’s a problem though. Software tends to grow.
Fast forward a year. You’ve added user groups, a customizable default group, and clients are notified of sign ups. Once small, the code no longer fits on a screen. But let’s not jump too far ahead.
Some clients don’t want an open sign up. They want to wait until an account is created in their software and auto-enroll users. They want an API.
You use the new
ActionController::API feature to make an endpoint.
Currently, all your business logic for signing up users is in the
Trying to reuse controller code is tricky.
Ask anyone who’s ever had a
Put it in the model.
Now that you need the code in two places you push it into the
You add it as a callback on creation and ding a user’s got mail.
What happens to
User as you add more features?
It begins to grow, and grow, and grow.
Maybe you’ll group features into modules to separate the code.
Then you’ll have 28 modules.
Once again I digress.
Your existing clients love the API. Your prospective clients are excited to switch from competitors. There’s just one thing. Your prospects want to bulk import their existing users. They also want to send their own welcome email explaining the transition.
Now you have to skip the welcome email callback you added. Have fun trying to figure that out.
Put it in a service object.
ActiveModel wants to represent a record. It wants to map to a table and persist data. It influences the way you think about models. It makes you think of them as nouns.
What happens when you need a verb? You add it to a noun.
Before long, each noun is a mass of business logic. Some interactions spread across models making them difficult to wrap your head around. Service objects fix this. They’re models that are designed to be verbs.
Interactions are contained in their own classes. Making them easier to test, document, change, and remove.
You could use plain old Ruby objects. I wanted my objects to feel like a part of Rails. I wanted a sibling to ActiveModel. I wanted the team to have a consistent approach. This led to the creation of ActiveInteraction.
User.sign_up you’ll have
Each interaction is called with
run and passed a
Hash of arguments.
SignUp.run( client: Client.find(1), name: 'Aaron', email: '[email protected]', password: 'supersecure' )
In a controller you might directly pass
The interaction itself looks like this:
class SignUp < ActiveInteraction::Base object :client string :name, :email, :password def execute user = User.create( client: client, name: name, email: email, password: password ) if user.valid? Notifications.welcome(user).deliver else errors.merge!(user.errors) end user end end
run kicks off three steps.
First, there is a check to see if all the necessary arguments were passed.
SignUp that means a
:client (an instance of
They need to be the right type or convertible to the right type.
If you mark a field as a
date and provide a
String it will convert to a
You can also indicate optional arguments.
Second, validations are run. There are no validations in this example but you already know how they work. They’re ActiveModel validations straight out of the heart of Rails.
Finally, if the first two steps pass,
execute is run.
run returns an “outcome”.
It’s an instance of
SignUp that quacks like an ActiveModel.
It even works with Rails forms like an ActiveModel.
If you need the return value from
execute all you have to do is call
result on your instance.
Using it in your controller will also look familiar.
def new @sign_up = SignUp.new end def create @sign_up = SignUp.run(params[:user]) if @sign_up.valid? user = @sign_up.result redirect_to(user) else render 'new' end end
Now you have a sign up process that is contained, easy to use, and easily reusable. I almost forgot, you needed the ability to skip the notification for bulk imports.
class SignUp < ActiveInteraction::Base object :client string :name, :email, :password + boolean :notify, + default: true def execute user = User.create( client: client, name: name, email: email, password: password ) if user.valid? + if notify Notifications.welcome(user).deliver + end else errors.merge!(user.errors) end user end end
What are you waiting for?
You don’t need a greenfield app to reap these benefits. I’ve used service objects to break big models back down to their basics. Piece by piece you can extract functionality and take control.
Whether you use ActiveInteraction or roll your own give it a try. You’ll have an app that’s simpler to maintain and easier to understand.