Recently I sat down with Riaz Virani to discuss his recent talk at RailsConf 2021 on why services are the missing element in Rails. Riaz has been the CTO of LoadUp, a freelance software engineer, and is a Ruby specialist. It was a great conversation, his RailsConf talk really resonated with me so I was excited to discuss this important concept with him. The concept of services are often overlooked in Rails application development, even though it can be used to effectively organize, implement, and test your application logic.
Where does business logic belong in a Rails application?
Riaz noted that after working as a Rails engineer for several years, it was amazing how many times he encountered this issue. Not only that, but there were many other teams dealing with the same question. Rails provides an amazing baseline to get started with web development, but Riaz observed that many engineers felt “it ended at a certain point”.
For example, many systems have the concept of logic to “Create an Order”. In a hypothetical codebase, this logic may get thrown into a random controller or model class. Create Order is a concept specific to the business, but it doesn’t fit nicely into the out-of-the-box Rails structure. Most people denote the practice of isolating this logic into classes as creating a service object layer. This is similar to the use of the Command Pattern, Railway architecture, or State Monads.
“I was seeing (the concept of services done in a number of different ways, but no one was really talking about it.”
If you have this problem with business logic spread out all over your codebase, you can use a service to organize and encapsulate these capabilities. In terms of directory structure, creating an app/services folder is the most common choice, but the more important bit is to decide on a pattern and location, and be consistent.
There may be a sequence of things you need to do. For example, creating an order may involve procurement, billing, and fulfillment operations. Each of these may be its own service that gets (re)used in different use cases. These services typically reside in a separate directory. They are not located in a controller or a model class, but they are often invoked from those places.
Are services implemented as PORO or something else?
Everything in Ruby is an object, so you have to start there. Service objects can be implemented as POROs (Plain Old Ruby Objects), and this is a valid choice. However, it's often not not just a Ruby object because they aren’t inherently functional. Frameworks are typically used to help define the functional pattern, enable it, and enforce it.
Anything that leads you to the Command pattern is a great start, and consistency is key. Most of the gems in this space are fairly modest in size. They provide orchestration capabilities, error handling, and other mechanisms associated with the Railway architectural pattern. One could argue though that the primary value they provide is enforcing the functional patterns of services inside of an object-oriented world.
The LightService Gem
There are a number of gems in this space, but none seem to have captured the majority of the mindshare. Riaz covers the use of the LightService gem in this talk. However, there are others as well such as Trailblazer. Either of these are decent choices, and you can find other related gems as well.
No one gem or framework has yet captured the majority of the community’s mindshare. One possible reason for this is that most teams don’t use a functional programming language. Quite the opposite, they use Ruby and Rails the same as we do because of their admiration for the language and its focus on developer happiness.
You can implement functional patterns in objects, and decompose logic into POROs without using a library. Services tend to appear less frequently in startup code, and more so within established companies that have large codebases. Once companies get to a sufficient level of scale, they run into this same problem and look for a solution.
You may not necessarily start out with a service construct, but it's a great option once you realize your development team needs the structural decomposition and service implementation capabilities.
Riaz notes that a great thing about developing with these gems is they are extremely stable. You can find the one you like and stick with it, with very few distractions. They tend to have very few gem updates, mainly updates for new Ruby versions. The frameworks are not overbearing and the patterns are stable. Not many reasons exist for it to break.
Did We Mention Testing Services is Really Easy?
It is much easier to test functional, or procedural, service objects in most cases. Because they take inputs, perform a single function, and return an output, the test code is very simple and aligns with the functionality to be tested. By contrast, many object-oriented systems require complicated test setup code that can become very brittle. There are numerous objects that must be mocked, a result of efforts to keep classes within a reasonable size and also due to object modeling techniques. You can quickly end up with 90% mocks and 10% actual test code. At this point, it becomes almost counter productive.
Services, similar to an HTTP endpoint, have everything you need is passed in as arguments and you can verify the result. The explicitness of a service makes all of this clearer and easier to test in many cases.
On the flip side here, one could argue we are just moving code from the controller to the service. Oftentimes, there is a straightforward one-to-one mapping. So should we just test using the controller? Riaz points out that controllers often involve other adjacent logic, such as sending email at the completion of a request being processed. If you are just testing the service, you can factor that out of your unit or component test, and verify your software’s accuracy in a more direct way.
If all you have is a hammer, everything looks like a nail.
At the end of the day, Riaz calls out that we should keep in mind always use the best tool for the job.
“If you are a purist on any given thing, you are probably wrong about that.”
If a mechanism works to model the problem at hand, use that. Despite all the benefits of services, it would be extremely challenging to model an ActiveRecord as a function or procedure. It is inherently a stateful concept, specifically to represent a row in the database and conceptually an entity in our business model. In any design, you will still need to query the database and hold that data somewhere in order to process it. For the rest of the application to use that data, it is simply better suited to a model class.
Another example that Riaz gives is the Ruby File class. It is nicely modeled as an object. The contents of the file and the operations on it are packaged together. It is very natural and fits most engineer’s mental models.
However, keep the logic in your model classes to only that which is necessary. Typically this include minimal decorators like the money gem or validations that will universally be true, wherever and however this model class is used. Anything that is “do this” or which has side-effects, you should err on putting that logic in the service objects.
ActiveRecord should do what it does, but sometimes the temptation becomes adding other logic into the models that is really better suited elsewhere. You also have the challenge of determining which model class logic should go into? Oftentimes, it is not very clear if you are just looking at the model class hierarchy, and choosing between the User object, Order, etc. Business functions often span the use of multiple model types.
One last bonus point: Onboarding made simpler
One last point, and its an important one, is readability. You want to onboard new team members quickly. They need to understand the codebase. Services provide a great entry point. It is easy to figure out where to start looking. Simply find the appropriately named service and follow the code from there.