Ruby on Rails Simple Service Objects and Testing in Isolation
This is an AMP version of this page click here to see the oryginal version.
Service Objects are not a silver bullet but they can take you a long way in modeling your Rails app’s domain logic. In this blog post, I will describe how I usually work with service object pattern in a structured way. I will also cover testing in isolation with mocked services layer.
I first read about service objects in a great blog post about 7 Patterns to Refactor Fat ActiveRecord Models. Since then a new article about them pops up every now and then. I decided to add my two cents.
Reasoning behind Service Objects in Rails apps
A service object is a way to encapsulate an app’s logic to prevent fat models and cluttered controllers. It is recommended that they should have only one public method. Sticking to this rule enforces you to follow a single responsibility principle and helps to avoid the trap of overcomplicating your services code.
I usually follow the convention where a service object has only one public class method
call. This method instantiates a new service instance and executes it. Let’s look at some code:
Because I am lazy some time ago I created a simple gem. Smart Init saves me some typing when creating service objects. This is how a previous example looks written with the help of my gem:
It’s not only about avoiding boilerplate code but more about having a default scaffold for building services and saving yourself some thinking. Alternatively, you could use
Struct but it does not check for a number of parameters provided in the initializer, exposes getters and instantiates unnecessary class instances.
Mock Service Objects in controller specs
Naming one public method
call is an optimal solution. Not only because
UserAuthenticator#authenticate is unnecessarily redundant, but it allows you to mock your services using a proc object. It comes super handy when you want to test other parts of your application in isolation from services layer.
Let’s say that you want to test how your controller behaves depending on whether a payment operation was successful or not. In theory, your specs could execute the whole checkout process e.g. by using a VCR gem. Unfortunately for any non-trivial flow, it could be difficult to simulate all possible edge cases in an integration test.
Instead, you can use a simple proc object to simulate any outcome of running your services. Let’s take a look at an example controller and spec:
As you can see not only can you mock returned values but also simulate any kind of side effect because of proc objects being callable chunks of code. Here I used it to raise a runtime exception, but it could be any code. It gives you a real flexibility in simulating edge cases without doing a complex data setup with fixtures/factories.
Beyond true and false; “Enums” for control flow
In the previous example, I used a single if/else statement to detect if an operation was successful and exception handling for critical edge cases. In practice, operation result is not always as simple as success or failure and you might need to handle more than 2 possible outcomes.
I work mainly in Swift nowadays and one of the features I miss the most when I come back to Ruby are enums. I am not talking about database Rails enums here. A real enum is a variable which can have only predefined values and it is validated during a compile time.
Obviously, there is no such thing as compile-time validation in Ruby, and the closest thing to enums I managed to come up with is an array of constants. You could use this technique to provide at least a minimal protection from typos if you would like your services to return different “status codes” as a result of their execution. Let’s take a look at an example:
In the controller you can switch on a result of service execution:
As you can see because of how Ruby handles constant namespaces, you can access them directly and not necessarily through
RESULTS array. It is a bit verbose but still better than tracking a typo bug for hours.
I hope that some of those tips would prove useful in how you work with services in your apps. I am open to suggestions on what could be improved in what I describe.