The Rails defaults work really well. You start with really nice, clean models and controllers. You can view them all as one page of code and hold an idea of what they do in your head. Problem is that they keep getting bigger as you add features and eventually they balloon out of control. Giant files that are hard to understand suck.
So refactor them into service objects. Split up the big pieces and make them into objects that are easier to test and maintain.
A service object only does only thing. It preforms a single function and encapsulates all the logic needed to perform that function away from your models or controllers. Service objects are generally POROs and are designed to extract and replace processes that cropped up elsewhere in your code but then grew unwieldy there. Service objects are almost always the result of a refactoring by extracting code from one part of the app to another, better part.
Lets say you have a new user being created in the UsersController:
class UsersController < ApplicationController
def create
user = User.new(users_params)
if email_valid?(users_params[:email]) && user.save
user.add_to_team(params[:team_id])
user.send_welcome_email
render json: user
else
render json: user.errors
end
end
private
def email_valid?(email)
## code here
end
end
Instead you just shove all the validations and extra work into the service object:
class UsersController < ApplicationController
def create
user = UserCreator.call(params)
if user.valid?
render json: user
else
render json: user.errors
end
end
end
The UserCreator service can look something like this:
class UserCreator
def call(params)
email = get_email(params)
user = get_user(params, email)
team = get_team(params, user)
user.errors.add(:team_id, "Team id is invalid") unless team.valid?
user.errors.add(:user, "User is invalid") unless user.valid?
user.errors.add(:email, "Email is invalid") unless valid_email?(email)
return user
end
def get_email(p)
email = p[:email] || ps[:email_address]
end
def valid_email?(email)
User.where(email: email).none?
end
def get_team(p, user)
team_id = p[:team_id]
team = Team.find_or_create(team_id) ####
user.team_id = team.id
user.save
team
end
def get_user(p, email)
first_name = params[:first_name] || params[:name].split[0]
last_name = params[:last_name] || params[:name].split[-1]
User.create!(first_name: first_name, last_name: last_name, email: email)
end
end
That is lots of code that is kept out of the controller. With all this code here in the service object you can make it as long as necessary without interrupting the flow of the controller.
When you refactor out service objects you end up with Rail controllers that are slimmer and more testable. They only need to test that you called the service object with the correct params instead of the results for your controller tests. This can keep the controllers looking almost like default Rails controllers without to much extra, hard to fathom code.
Basically you need to create a service object that encapsulates all the little things that were stuck elsewhere in the code but now can have a home of their own. Give that service object a name that says what it does (we don’t want people guessing) and usually sounds something like:
____Creator
____Builder
____Sender
____Doer
____Extractor
You get the drift.
Give it a call method that does everything (you should break it down logically inside the service object) and you are good to go. An easier way to keep all you business logic in one place that is testable and reusable.
The testing part is fairly key too. This is your business logic that should be firing correctly every time. Emails not sent, objects not updated, or logic that is duplicated across the app can introduce difficult to trace bugs. With a service object you can test the business logic independently and thoroughly. Then all you have to do is pass in the correct parameters to it. Often when you refactor out a service object you find other areas in your code that were doing the same thing but in a slightly different way that leads to bugs. DRYing them up with service objects will squash them.
A final upside to Service Objects is that they are often a good first step to moving processes to microservices. If the controller or model is only calling a service object then that process can be more easily removed from your monolith into a microservice.
Leave a Reply