OOP vs. services for organizing business logic
Is there a third way?
December 6, 2022 (updated July 19, 2024) Ā· Felipe Vogel Ā·- The good old days
- Then along came Rails
- Two philosophical camps?
- Service object skepticism
- Second-guessing myself; more study needed
- Conclusion: to be continuedā¦
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 (as in the Active Record pattern), or should we put in the effort to hide the data model that is reflected in database tables (as in the repository pattern, for example ROM in Ruby)?
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 with 200k 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 back-end logic that I find hard to avoid when writing a React view. Letās focus only on back-end 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 model over a thousand lines long, 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 groups: advocates and opponents of service objects. In reality itās a bit more nuanced than that: advocates typically use other design patterns in addition to service objects, and opponents often agree that MVC pattern built into Rails does not scale indefinitely.
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 PORO models 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 (which is already technically a PORO unless youāre using a services library/framework) becomes 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. Some of these resources are taken from my āLearn Rubyā road map, in particular the sections āRails architectureā and āRails codebases to studyā
Books 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 plain OOP + Rails MVC. 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.
- Books on Rails architecture:
- Volmerās Rails Guide
- Layered Design for Ruby on Rails Applications
- Maintainable Rails (book), which uses gems that are part of the Hanami ecosystem.
- Learning Domain-Driven Design
- Gems:
- ActiveInteraction
- dry-transaction
- Flow
- Interactor
- Sequent ā CQRS and event sourcing
- solid-process
- Rails Event Store ā for an event-driven architecture
- Rectify
- Surrounded ā for DCI; pair with the book Clean Ruby
- Ventable ā a variation of the Observer design pattern
- Wisper ā the Publish-Subscribe design pattern
- Packwerk ā to enforce boundaries and modularize Rails applications
- gems related to Packwerk
Open-source Rails codebases
Another way to learn good Rails architecture is by inductive study of real-world code. I do that at work, of course, but I could learn more by studying open-source apps. Iāve listed a bunch in āRails codebases to studyā, part of my āLearn Rubyā list of resources.
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. If any of this helps you as well, dear reader, then all the better!