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 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 interupting 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 everytime. 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 thouroughly. 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 doign the same thing but in a slighty 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.