Designing Services with dry-rb
In a traditional Ruby on Rails application, models and controllers can become bloated as they take on additional responsibilities. It is not uncommon for controller actions to contain complex code for initializing models or for models to contain callbacks that send email or class methods that define intricate queries. Extracting services is one way to keep classes lean and separate business logic from framework code.
Some frameworks have built-in support for services. In Trailblazer, they are called “operations.” In Hanami, they are called “interactors.” In the DCI paradigm, they are similarly called “interactions.”
I prefer to treat services like method objects. I name them after the actions they represent and invoke their behavior via a call
method. Does thinking of services as methods change the way we design them?
In his book Confident Ruby, Avdi Grimm lists the four responsibilities of a method:
- Gather input
- Perform work
- Deliver results
- Handle failure
This article explores how to fulfill these responsibilities within a service while using modern Ruby libraries like dry-rb to provide a consistent, testable design across a service library.
Let’s start with a simple service that may be useful in a real-world application — a service that posts a message to Slack.
1. Gathering Input
The first responsibility of a method is to gather input. This brings up two concerns: how we pass the input into our object and how we validate the input before executing any operations.
Dependency Injection
Dependency injection (DI) simply means passing dependencies into an object or code block instead of instantiating them within it. Using DI makes code more flexible and easier to test because we can swap out the dependency for another object provided that it responds to the messages that we send it.
Dry::Initializer
provides the ability to configure the parameters for instantiating an object. If we modify our service to work with an instance, we can define message
as a required parameter and give notifier
a default value. Note that we still provide a call
method on the class to maintain the same interface.
Input Validation
Validating the input to a service can help avoid unwanted exceptions and provide meaningful feedback to the caller.
Dry::Validation
provides the ability to define schemas and use them to validate a hash of values. We can use it to return false
if the message is empty instead of posting an empty message to Slack.
More Services
Suppose we add an additional service that sends an email notification. It may look very similar to our Slack notification service.
Both services extend Dry::Initializer
and the call
class method is exactly the same in both services. We can extract a parent class or mixin that provides the behavior shared across both services. I’m using inheritance. However, another approach may make more sense in your project. Here’s how the new parent service class looks:
This allows us to cleanup our slack and email services:
2. Perform Work
There’s not much to say here beyond having on a single public call
class method and adhering to good design principles (i.e. DRY, SRP, etc.) when building out the inner workings of a more complex service.
3. Deliver Results
We’re returning false
if our arguments are invalid. Otherwise, we’re simply returning the result of the slack or email dependency. It would be nice to have a consistent interface across the results of all of our services.
Dry::Monads
provides Success
and Failure
objects that can wrap the result of our service. First, we can return a Failure
if the inputs are invalid.
We can also return aSuccess
or Failure
from each service’s call
method.
If these services were being used in a Rails application, the controller action could respond differently based on the result.
4. Handle Failure
The final responsibility of a method is to handle failure. There’s an interesting problem that we face when working with services — especially those that depend on remote APIs. In certain cases, we consider some exceptions to be permissible and want to return a failure result. In other cases, we want our application to crash or the exception to be tracked. Adding a way for each service to specify which errors are permissible will make our service objects even more powerful.
Dry::Core::ClassAttributes
provides a defines
method to explicitly list the attributes that can be defined within a class. We can use it to define the permissible errors. Then, if a permissible exception occurs, we can simply return a Failure
instead of crashing the app.
We can override the permissible error list in an individual service if there are any expected errors that should be allowed.
Wrap-up
That covers applying the four responsibilities of methods to services. I’m not advocating using this Service
class in a real world application. However, I think some of the libraries used in this post can help you create clean, flexible, testable services in your own applications.
All of the code in this article can be found at https://github.com/psparrow/designing-services-with-dry-rb.
Further Reading
Rob Race has written some great articles about service objects at his blog. The services used in this article are loosely-based on his examples.