OOP vs. services for organizing business logic
Is there a third way?
December 6, 202 Ā· 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, 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. If any of this helps you as well, dear reader, then all the better!