Better Rails Service Objects With dry-rb
The idea of using service objects came into my mind when I first read about the DCI architecture in Clean Ruby.
The paradigm separates the domain model (data) from use cases (context) and Roles that objects play (interaction). DCI is complementary to model–view–controller (MVC). MVC as a pattern language is still used to separate the data and its processing from presentation.
As the book and the quote from Wikipedia suggest, using this architecture in our code makes our models only contain data (we will even migrate data validations to service objects) and controller actions just call one of these interactions and render the result.
There are many cool libraries that I’ve used as the interaction layer like ActiveInteraction, Interactor, or Trailblazer Operations. But each of them misses something that I like which is present in another library. So I decided to pick and combine every piece that I like from these solutions and create mine.
To better understand the upcoming concepts, let’s consider a very simple blog app where it has a Post
model with title
and body
.
Basic API
The first thing we need to do is to define a basic API that all our service objects implement. The one that I prefer is a .call
class. All services must have a call method which is the interface for interacting with it. This method receives a hash as an argument, does all logic needed by the operation and returns the result.
Let’s go back to our simple example. Suppose that we need a service to create a blog post. We will define a class named Posts::Create
and will add a call class method to it that receives a params hash.
Adding validations
At the next step, I want to add the code to validate the input and the super simple business logic to create the post.
Ok. I agree that in comparison to the previous code snippet, I added a lot of things to this one. But believe me, they’re all super simple. Let’s check what we’ve added. For now, let’s skip the line number 3 and start with line number 5. Here we’ve added the validation schema for our service object. For this purpose, I’ve used dry-schema
gem. It’s easy to understand and way more flexible than ActiveSupport validations. Check out all the available schema validation rules at dry-schema documentation. As you see, here we’ve added two rules:
- Title is required and must be a string
- Body is optional, but if it’s present, it should be also a string.
Then in the .call
class method, we instantiate an instance of the service and call it’s execute
method and will pass all the parameters to it. In the execute, method, we’ll call all the steps needed to fulfill all the requirements of this operation. As you see, every step is simply a method and we call them one after another.
We’ll use Success
and Failure
functions to specify if the result of each step was successful or a failure. These functions are provided in dry-monads
gem which you can read more about in its documentation website. To have access to these functions in our service objects, we need to include the result monad using
include Dry::Monads[:result]
specified in line 3 of the previous code snippet. They’ll wrap the result of each step in a Success or a Failure object and then we can unwrap them using the yield
keyword that does two things in this case:
- Unwraps the value of a
Success
or aFailure
object. - Halts the execution of the method if there a failure in any of the steps.
To be able to use this monad, we need to add the do monad
to our include statement:
include Dry::Monads[:result, :do]
Now if for example, the validation step returns a failure object, then the execution of the operation halts, and a Failure
object containing the errors will be returned.
Handling output
One of the things I liked about Traiblazer Operations
was how you can handle the output of the service by passing a block to it. This is what we will be able to do after adding the next changes:
Beautiful, right? Let’s add it to our service object. We only need to use ResultMatcher
from the dry-matchers
gem for that. In the .call
method, if a block is given, then we need to wrap the response in the ResultMatcher
object:
Making it reusable
Ok, all done. Now let’s make our code reusable. We’ll create a module named ApplicationService
and will move all the shared logic to it. Here I moved the code for the .call
method to this base module. I also moved the validation schema execution here. So if you’ve defined a ValidationSchema
constant in your service object, then it’ll execute it and then will pass the params to the execute
method of the parent class.
After separating the base logic from the main class, your operation classes will be as simple as this:
You only need to inlcude the ApplicationService
and define an execute
method in your service object and if you add the ValidationSchema
constant, then it’ll automatically pick and call it.
We also can go further and add dry-validation
to make our validations more powerful. But I need to leave the computer and make a cup of tea for myself, so I’ll give it over to you.
Happy hacking :)