Fat models and fat controllers, fat models and skinny controllers, skinny models and fat controllers, skinny models and skinny controllers. When reading blogs about how you should structure your Rails apps, these are the options you are provided with. There are many other articles about which to pick so I'll open here by saying that my instincts pushed me towards the last option, skinny models and skinny controllers.
The problem with this choice is that you still have business logic and nowhere to put it. When looking around, I had a lot of trouble finding anything that convincingly answered the question:
Where does business logic belong?
That led me and my team at User Interviews to a lot of trial and error. After about 6 years I'm finally happy with where we've landed. This is a story of that journey.
👉We're hiring! Join our kickass, fully remote team.
Helpers directory
The first stop on this journey is the helpers directory. It was already there, one was created for every controller I made, this must be where the Rails core team wanted this logic to go!
Helpers worked well for a while. It kept logic out of the controllers and models and provided a single place to look for business logic. We had the ProjectsHelper and ParticipantsHelper to hold all business logic about projects and participants respectively.
This started to break down when the helpers were being included in models and jobs. Previously, when helpers were used in views and controllers, they had access to the request object and URL helpers. This was not true in models and jobs and led to a lot of bugs where we'd try and use a method we didn't have access to (e.g. we tried to send a message and include a URL). The solution was obvious, we needed to split up those methods which needed access to controller methods, and those that didn't.
TaskHelpers and ControllerHelpers
The name helpers still made sense, so we built on it. We added task_helpers and controller_helpers directories.
Task helpers only had class methods and were available to be called anywhere. There was no expectation that they would have access to URL helpers or other information about a request.
Controller helpers were to be module methods which were only available in a controller (view helpers were left in the helpers directory, where they belonged). They would call task helpers as needed but could do things like redirect, render, or reference URL helper methods. For example:
# app/controller_helpers/participants_controller_helper.rb
module ParticipantsControllerHelper
def participant_params
# Access to `params` is assumed because this is a ControllerHelper
pararms.require(:participant).permit(:email, :name)
end
end
# app/controllers/participants_controller.rb
class ParticipantsController < ApplicationController
include ParticipantsControllerHelper
def update
participant = Participant.find(params[:id])
if participant.update(participant_params)
render_success
else
render_failure
end
end
end
# app/task_helpers/participants_task_helper.rb
class ParticipantsTaskHelper
def self.import_participants(csv_file_name)
...
end
end
# app/jobs/participant_import_job.rb
class ParticipantImportJob < ApplicationJob
def perform(csv_file_name)
ParticipantsTaskHelper.import_participants(csv_file_name)
end
end
This split worked for a while, but eventually broke down for two reasons. The first was that the naming was verbose and unclear. We still had logic in the helpers directory and the distinction between controller_helpers and task_helpers and where certain logic should exist wasn't obvious. The second was more of a problem of organization. Similar to the logic we had in helpers, we ended up with very generic classes such as ProjectsControllerHelper or ParticipantsTaskHelper. This wasn't necessarily built into the system, but it didn't provide a great system around it either.
Concerns and services
The next big change was largely one of naming.
The first thing we realized was that ControllerHelpers were really just controller concerns by a different name. In an effort to conform to Rails standards (and be more like DHH), we started moving that logic into concerns. Also, we realized that having business logic here was delving into the "fat controller" mentality which we wanted to avoid, so we tried to take any actual business logic out of these files.
Once controller helpers were gone, the name task helpers didn't make sense any more (not to say it made a ton previously). There was a brief foray into creating a managers folder and putting business logic in there, but we settled on creating a services directory and making a new slate of services (e.g. ParticipantService and ProjectService)
Initially, these services were required to be initialized and would be passed any services that they depended on when initialized. The hope here was that this would allow us to better track dependencies and enable us to use dependency injection to allow for more dynamic logic and simpler testing.
Those who are paying attention may have spotted where this fell apart. Due to poor factoring and a continuation of naming our services based on very generic themes (e.g. projects and participants, our two biggest models) we ended up with enormous files with semi-related logic. Additionally, because these services were so generic, they had a lot of dependencies. Handling this spider web of services was so complex we created a file just for initializing them, passing in whatever other services were required. Adding new services was a pain and adding a method to a service which pointed to another service required updating this spider web and hoping that you didn't end up creating a circular dependency.
Services with class methods
The obvious solution to the issue of a web of dependencies is to remove them. We did this by making it so services were no longer initialized. All methods would be class methods (a la task helpers) so you could call another service without the rest of the service depending on it.
This was almost perfect, except we continued the trend where we would create extremely generic services (most had started as helpers, were refactored into task helpers, then renamed to services). We briefly tried to remedy this by making our services smaller and more specific in their naming (e.g. Projects::CharacteristicsService, or Participants::RecruitService). This worked OK, but it wasn't always clear what methods a service had or how big a service could get before being broken up.
Where we landed: command pattern
The easiest and most consistent answer to both of those questions were to have one method per service. This sounded suspiciously like a command pattern that one of our earlier engineers had proposed years before (and I foolishly ignored). Luckily, as we were coming to this realization, we hired an engineer who had worked extensively with ActiveInteraction. We decided to give it a shot and haven't looked back.
The initial issue of overly generic services was immediately removed because a single interaction can only do one thing, execute. We have had to be intentional about our usage of namespaces and folders in Rails to make it easier and obvious where logic should live, but that's been a lot easier when you're able to call an interaction easily from anywhere in the code base and every class only has one method to call. For example:
# app/services/recruiting/
module Recruiting
module Matching
class FindParticipantsForProject < ActiveInteraction::Base
record :project
def execute
...
end
end
end
end
Additionally, because interactions are easy to mock you can keep specs really clean and simple. This has simplified the issue we were trying to solve with initialized services and didn't require us to be bogged down by all the dependency building we had previously had.
Final thoughts
Any of the iterations we went through could have been the right one, but we had to find the right one for us.
In the end, my primary goal when deciding how to organize a code base is to make it obvious where code lives (or principle of least surprise). This makes it faster and easier to know where to put the code you're writing or find the code you need to change. The use of namespacing and simplicity of single function commands worked well with our team so that's what finally stuck. Also, the extra goodies around how ActiveInteraction plays with the rest of our ecosystem made it very easy to adopt (but that's another blog post).
I'm writing this not to say that everyone should follow our patterns, but to showcase that code bases can and should evolve as more understanding is gained. Try out new tools, new patterns, and try to follow best practices, and if something is not working, we should do our best to improve it.
We're hiring — join us!
This is just the first part of the story. Our code base and our product continue to evolve—if you'd like be part of that adventure, join our kickass, fully remote engineering team!
Read the User Interviews origin story and browse our open positions.