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
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.
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.
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
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
- 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.
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
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
ApplicationServiceand 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 :)