Where to put your code when Rails runs out of answers
There's nothing quite as exciting, a moment filled with as much potential, as running rails new ...
for a project. The early progress is always so spectacular - a CRUDy MVC application comes together so quickly. Soon, however, it's time for your application to grow up and do more valuable things. It needs business logic.
The next step is always to worry about the waistline of your controllers and models. For many years, the conventional wisdom in the Rails community was to aim for a “Fat Model, Skinny Controller” approach - that is, keep the logic in your controllers to a minimum in favour of placing more and more of it in your models. But an approach like this can only hold so much business logic before code dealing with concerns that span many models starts to accumulate and bulge out of unexpected locations.
Service Objects
You Google for advice: "but where do I actually put all this code?". Rails has a place for everything, how come it doesn't have a place for business logic? Google has plenty of answers:
service objects in
app/services
use cases in
app/use_cases
mutations in
app/mutations
interactors in
app/interactors
They're all essentially the same answer (and all are really versions of the service objects idea). They say to you "here's where you put the stuff that glues everything together, the stuff that controllers call to manipulate multiple models and perform some other actions". Alongside where to put it, they all tell you how you should structure it too, usually: one public method per file, so that one file contains one neat piece of business logic.
It's comforting. You stay on the rails. The framework has an answer once more. Once again, you know where to put your code and how to structure it.
However, as your app hits middle age, providing all sorts of value to lots of people through its ever accumulating mass of business logic, your service objects start to get very difficult to work with. They call each other in an increasingly complex web of interconnectedness. The lack of encapsulation within your service objects directory means that making changes gets harder and harder as you have to reason about how they interact with each other, and with the rest of the Rails application.
Forgetting Rails
It turns out that your business logic isn't so malleable that it can be carved up into simple, neat, self contained units with one public method per file. That's because your business logic is exactly as complex as your business. And while Rails is a fantastic framework for modelling a web application, it makes no claims about helping you model your business.
What if Rails didn't exist? What if you were just writing some code to capture your business logic, what would you do? My bet: you'd write a bunch of Ruby modules and classes, you'd group them in folders that kept similar concepts together. Within those modules/folders, you'd indicate which pieces were internal implementation details and which pieces were intended for consumption by other modules. Your plain old Ruby would do a great job of capturing your business's domain and you'd never worry about finding a framework to tell you how to lay it out.
If you're trying to figure out where to stuff the business logic in a Rails app, you're really approaching the problem from the wrong direction. Your business logic isn't an inconvenience that you need to keep out of the way of the framework, it's the very point of your application. So write your business logic, your application, the way it needs to be written and then use Rails, with its great ORM (ActiveRecord), its brilliant controller library (ActionController) and myriad of other features and extensions in service of that application.
In Practice
The Rails documentation says:
….here's a basic rundown on the function of each of the files and folders that Rails creates by default:
…
app/ - Contains the controllers, models, views, helpers, mailers, channels, jobs, and assets for your application.
…
lib/ - Extended modules for your application.
Tines is built using Ruby-on-Rails. We write our business logic in that top-level lib
directory and we write it as plain old Ruby. It's laid out in a way that makes sense for our domain. If we think that a module with a single public method is the right way to describe an interface (i.e. something that looks like a service object), that's what we use. And when we feel that some simple Ruby classes, instantiated into objects would help, that's what we do. Without a framework level expectation about how the files should look, we're free to solve our problems using the right Ruby tools.
That code in the lib
directory makes use of the "controllers, models, views, helpers, mailers, channels, jobs, and assets" from the Rails app
folder as it needs to. Our controllers and models are all "skinny" - they're focused on doing the job of interfacing with the outside world and the database. The weight of "business logic" is all carried in the lib
directory:
.
├── app # ... everything "framework" related
│ ├── assets
│ ├── channels
│ ├── controllers
│ ├── graphql
│ ├── helpers
│ ├── jobs
│ ├── mailers
│ ├── models
│ └── views
├── bin
├─ ─ config
├── db
├── lib # ... all "business logic" in plain old Ruby
├── log
├── public
├── spec
├── tmp
└── vendor
(If you're eagle-eyed, you might notice the app/graphql
folder, which isn't part of a standard Rails project. We use graphql-ruby and have classified the mutations and types etc. that we write for it as "framework" code, rather than "business logic".)
We deliberately chose to use the top-level lib
directory as a peer of the app
directory (rather than, say, app/lib
or app/services
) in order to draw the distinction more sharply: our business logic makes use of the Rails framework code, it isn't a sub-component of it.
The more you think in terms of "using Rails from your code" instead of "where does my code fit in a Rails app", the better job your code will do at modelling your application's domain. So if you find yourself struggling to figure out where to fit your business logic and your code in the Rails framework, try simply creating a new Ruby file in the top-level lib
directory and, well… go off the rails for a while!