Disclaimer: In this blog post I raise many questions and give few answers. At the bottom I list resources which I'm exploring in search of an answer, so skip down if that's all you care about.
Business logic. Everyone has it, and no one seems to agree on where to put it in a Rails app. Some people stuff it all in Active Record models, others throw it out into service objects, and still others put it in POROs. (But then where do you put the POROs?)
In all these debates, there's probably an element of different answers coming from different needs: people who work with small apps don't stray far from The Rails Way of MVC (models, views, and controllers), whereas those who work with larger apps might feel the need for a more sophisticated architecture.
That being said, I sense that these disagreements also reflect a more fundamental question: How should the app interact with the database? Or in other words, should database tables be near the surface, or should we put in the effort to hide the data model that is reflected in database tables?
I may have lost you already, so before I wade too deep into philosophy, let tell the story of why I'm struggling with these questions.
The good old days
Before I learned Rails, I knew Ruby. I loved it. It made sense. Propelled by Sandi Metz's talks and books, I could write a plain Ruby app in the most beautiful and satisfying OOP style. Life was good.
But I knew I couldn't linger in those enchanted woods forever.
Then along came Rails
I learned Rails and got my first programming job working on a Rails app of over two hundred thousand lines of Ruby code, plus React views. Suddenly things didn't make so much sense anymore. I often didn't (and still don't) know where a piece of code belongs. Let's even set aside React views and the duplication of backend logic that I find hard to resist when writing a React view. Let's focus only on backend Ruby code: even there I find myself indecisive when trying to decide where to put a new piece of code.
The most convenient place for that new bit of code is an existing Active Record model, but when I'm crawling through a huge model I'm reminded that maybe I should think hard about where to put this code. So I turn to alternative places, but then I'm faced with a jungle of service objects and variously-located POROs 😵💫
I usually find a tolerable solution, but in the end I always wonder: where does business logic really belong? 🤔
Two philosophical camps?
As I looked through discussions of this question in the Ruby community, I noticed that most answers came from one of two "sides": advocates and opponents of service objects. In reality it's a bit more nuanced than that: advocates might propose a pattern that is a more sophisticated version of service objects, and many opponents admit that careful OOP design is important to augment Rails' MVC structure.
But the reason I lump them into two camps is that each has a different approach to the fundamental question I posed earlier: How should the app interact with the database? In the context of Rails, this question can be rephrased like this: What should an Active Record model represent?
Advocates of service objects often think of Active Record models as models of database tables, and therefore not an appropriate place to put business logic. The other camp sees Active Record models as models of domain objects that just happen to be backed by a database table, and therefore a perfectly suitable place for business logic.
Service object skepticism
For several months I thought the anti-service-object camp was right, end of discussion. It seemed clear to me that Active Record models are intended to be domain models:
1. It's spelled out in the Rails Guides.
From the section "What is Active Record?"(emphasis mine):
"Active Record is the M in MVC - the model - which is the layer of the system responsible for representing business data and logic. Active Record facilitates the creation and use of business objects whose data requires persistent storage to a database."
And, shortly afterward:
"In Active Record, objects carry both persistent data and behavior which operates on that data."
2. Martin Fowler, who first described the Active Record pattern, agrees.
To quote his article on the Active Record pattern:
"An object carries both data and behavior. Much of this data is persistent and needs to be stored in a database. Active Record uses the most obvious approach, putting data access logic in the domain object."
So an Active Record object is intended to be fundamentally a domain object, with database access added for convenience, not the other way around. Probably that's why it seems against the grain of Rails when service objects are the place where business logic goes.
Fowler directly criticizes service objects in his article on anemic domain models. In reference to putting domain logic in services, he says:
"The fundamental horror of this anti-pattern is that it's so contrary to the basic idea of object-oriented design; which is to combine data and process together. The anemic domain model is really just a procedural style design."
And:
"In general, the more behavior you find in the services, the more likely you are to be robbing yourself of the benefits of a domain model. If all your logic is in services, you've robbed yourself blind."
3. Conversely, domain models don't have to be Active Record models; they can be PORO models.
Taking advantage of this can alleviate many of the "fat model" problems that service objects seek to solve.
Martin Fowler proposes refactoring a service object into a PORO, and he's not alone: some in the Ruby community have written the same (1, 2, 3).
There are lots of patterns that can be used in POROs around Active Record models. For example, if a record is created from complex form input, you could use a form object instead of a service object.
Also, some versions of service objects are somewhat object-oriented when they reject the notion that service objects should have only a #call
method and when they share code within the same class. In these cases, a service object is a bit more like a purpose-built PORO.
So why not just take the next step and put these services in the app/models
folder, and refactor them from procedures into actual domain models? To take an example from the last link above: SalesTeamNotifier.send_daily_notifications
could be changed to Internal::Notification.new(receiver: 'sales').send
.
So yeah, I was a convinced service object skeptic, firm in dismissing even the need for anything but classic OOP. When I tried to be fair and play devil's advocate, I only got as far as conceding that OOP is harder to get right than procedures, and OOP done wrong can result in a lot of moving parts and less clarity about what actually happens when. I could even appreciate the simplicity of services, in the sense that making one is as easy as copy-pasting a long model method.
Second-guessing myself; more study needed
Fast forward a few months. I still don't like service objects, and I still like OOP. But now I'm less certain that the cure-all for badly organized business logic is "just do more OOP, end of story."
After all, if so many people feel the need for service objects, and if OOP is evidently so hard to get right, aren't these signs that something is missing? Maybe that missing something is just better OOP, but in that case good OOP is hard to come by and we at least need a more accessible way to do it.
So I've set out to explore the problem of organizing business logic from more angles than before, using the resources listed below. These lists are excerpted from my "Learning Ruby" road map which I often update, so you may want to find these lists there if this post is old at the time of your reading it. The sections corresponding to the lists below are, at the time of writing, "Rails architecture" and "Rails codebases".
Deductive study: books, talks, and gems
Here are some resources that I hope will shed light on the question of organizing business logic better, both in terms of solutions and in terms of when (under what conditions) these alternative approaches are beneficial as opposed to simple OOP with Rails defaults. This list is not exhaustive; in particular I've omitted gems that are just a service object implementation. Some of these resources are closely related to service objects, but that's intentional–I'm compensating for my bias against them.
- Domain-Driven Design, which aims to augment OOP to prevent problems such as fat models. It's intended for large, complex domains. Resources: "Getting modules right with Domain-driven Design" (talk), Learning Domain-Driven Design (book).
-
Other approaches that are more lightweight and have some of the same goals:
- Data Oriented Web Development with Ruby (upcoming book) by Peter Solnica, who is on the Hanami core team. Learning Hanami wouldn't be a bad idea either.
- Maintainable Rails (book), which uses gems that are part of the Hanami ecosystem.
- "Organizing business logic in Rails with contexts" (blog post).
- Learn more about the repository pattern: article, talk.
-
Relevant gems that seem worth learning from:
- dry-transaction
- Interactor
- Sequent – CQRS and event sourcing
- Rails Event Store – for an event-driven architecture
- Ventable – a variation of the Observer design pattern
- Wisper – the Publish-Subscribe design pattern
- Packwerk – to enforce boundaries and modularize Rails applications
Inductive study: open-source Rails codebases
I rarely read a lot of code outside of work, but I plan to change that. Below are Rails projects that I've seen mentioned more than once as good examples to learn from, or they are sufficiently active and well-known as to be good candidates for study.
-
Small codebases: Less than 50k lines of Ruby code.
- github.com/codetriage/codetriage (6k lines): Issue tracker for open-source projects.
- github.com/joemasilotti/railsdevs.com (12k lines): The reverse job board for Ruby on Rails developers.
- github.com/lobsters/lobsters (13k lines): Hacker News clone.
- github.com/thoughtbot/upcase (14k lines): Learning platform for developers.
- github.com/houndci/hound (14k lines): Automated code review for GitHub PRs.
- github.com/rubygems/rubygems.org (26k lines): Where Ruby gems are hosted.
-
Larger codebases: More than 50k lines of Ruby code.
- github.com/solidusio/solidus (72k lines): E-commerce platform.
- github.com/mastodon/mastodon (75k lines): Like Twitter but self-hosted and federated.
- github.com/forem/forem (103k lines): Powers the blogging site dev.to.
- github.com/alphagov/whitehall (117k lines): Publishes government content on gov.uk.
- github.com/discourse/discourse (322k lines): Discussion forum platform.
- github.com/instructure/canvas-lms (745k lines): A popular LMS (learning management system).
- gitlab.com/gitlab-org/gitlab (1.8 million lines): Like GitHub but with CI/CD and DevOps features built in. Has great docs on architecture.
Conclusion: to be continued…
In a year or two I may be able to give something more like an answer to the questions I've raised here. For now, I've made a start by processing my thoughts and mapping out some promising resources.
What about you? Have you had any moments of insight into how to organize business logic? Do you know of a great resource not mentioned here? I'd love to hear about it!
Top comments (2)
That was an interesting read... Thanks for that.
I used to be in a service objects camp. When they became popular in Rails community it was so much refreshing, comparing to all the shady ideas of calling external APIs in
before_validate
of the model, which were quite widespread back then. Service objects brought some tidyness to this. You could suddenly follow the code execution, line after line, without the intimate knowledge of the framework and which callbacks gets called when.Yes, they did bring the procedurality (?). And it was good.
Nowadays I wouldn't label myself as a strong service objects proponent. They had their prime time, but I think we know a bit better now. However, I'm definitely not in the anti-service-objects camp. I still find them useful in certain situations.
I have huge problem with quotes like the one from Fowler about anemic domain objects. Sure, in principle it's true, but it seems to miss an important context: the web is procedural at its core. There is a request, something is being done with the data in the request, a response is build and then it's sent back. Step by step. Procedurally.
How does the object-oriented design, which is basically state-based, fits there? Barely ;) At some point of a stateless web request we rebuild the state in isolation from the database, start to behave like it's a proper state, then serialize it back to the database and come back to the procedural part.
Now, it's not like I'm rejecting the OOP completely, just because it's a web concept. On the contrary. It tends to be very useful to model some complex processes happening. It's probably a great idea to use something like DDD to model the very core of the business logic. A checkout process in e-commerce. A ticket purchase in a airline system. But there will be a lot around that will not be a good fit for DDD. There's noting particularly non-procedural about promoting a user to admin or changing a product description.
So, coming slowly to the end of this chaotic comment, I think both procedural service-objects-like code and rich OOP domain modeling have their place in a sufficiently large modern web application. The trick is to find the boundaries and enforce them, so domain code is not handled procedurally, but on the other hand we don't try to model simple procedural things with DDD. And this is really hard, actually. But "taking the side" and tightly keeping oneself to "one camp" certainly does not help.
Thanks for the very insightful comment! Lots to think about there. Maybe some of the difficulty that I'm struggling with comes from my subconscious protest at the reality that the web is procedural. I camped out in Ruby and OOP before moving on to Rails, and I think that actually made it a more painful transition because I'd established a comfort zone that did not include requests or a database.
I agree that taking a side is not helpful. I may have focused too much on "the two sides" and oversimplified things, but it was my way of making sense of a complicated question. And I must admit, I tend to be attracted to cohesive, monolithic approaches, so "let's do both" is hard for me to get excited about even when (as you point out) it is often the answer that makes most sense. But I'm trying my best, and I hope after diving into these resources I'll come out the other side with more appreciation for nuance and balance.
Let me know if you'd recommend any other resources in this area. I know some things will only come with years of experience, but I'd like to actively think through this as much as I can.