Using the Result pattern with Rails services

Chandu Tennety
Beam Benefits
Published in
6 min readAug 15, 2022

You should be writing services

If you’ve worked on a non-trivial Rails application, you probably already understand that it’s good practice to separate business logic from both the controller and the model layers, and use services instead. The separation of concerns allows for better unit testing, increased maintainability, and reduced coupling.

Services are typically plain Ruby classes (or modules) that accomplish a business task with the help of some collaborators and injected dependencies. As an example, a shopping cart application that processes payment information, updates inventory, and generates an order for shipping could make use of several services working together. Services can make HTTP requests to other applications, call off to model classes for database updates, and enqueue jobs for workers (e.g. to send a confirmation email).

Photo credit: Nappy.co (public domain)

When services fail

Services typically involve procedural code that can fail for different reasons. A dependent service may be down, a database transaction may fail, or validation errors may occur. In such cases, the service should be able to handle the failure and try to ensure that the application is in a healthy state.

Ignorance is bliss?

The naïve approach would be to return nil or false whenever a failure happens, and a truthy value otherwise. The hidden assumption here is that the calling code only cares about the return value and can only offer a generic “Something went wrong” in the failure case. This assumption may work for a prototype application, but not production code.

Error handling

A better way is to raise custom exceptions. Ruby allows the creation of custom exceptions that describe the kind of failure that occurred, with a meaningful error message. In lots of cases, this is the correct approach and uses built-in language niceties. At a business logic level, however, specific error classes and messages hold less value than knowing whether the service failed or succeeded. A potential downside is that implementation-level knowledge of how a service may fail leaks over to the calling code, adding boilerplate, or worse, breaking encapsulation. Testing this code could involve a lot of mocking, leading to more brittle tests that are hard to maintain.

Errors as data

One approach to solving this issue is the Result pattern, which we will explore in this article. With roots in functional programming and strongly-typed languages, this pattern encapsulates the idea of an operation that could fail, so that the calling code needs to handle only two cases. This is especially effective with procedural code, where the next step may depend on the success of the previous step. Also known as railway-oriented programming, this approach can return a composable, context-aware data structure to the calling code that not only communicates whether the service failed or succeeded, but also at which point and why. Error handling is encapsulated inside the service, so that the consumers can get the context they need without having to be modified every time a new failure vector is discovered.

Example service

As an example, here’s how a ShoppingCartService could look. Later, we’ll derive the API of the ServiceResult class based on usage.

ShoppingCartService class, with one public `process` method that calls a series of private methods each in an if_success block.
Example ShoppingCartService class

Assuming the UI has allowed the member to add items to the cart and enter their payment information on checkout, we can imagine that the controller calls off to this service.

The first thing to note is how the process method reads. A few things become evident:

  • As the only public method in the class, it captures at a glance the steps needed to process the shopping cart.
  • If a new step needs to be added (e.g. calculate_sales_tax), there is exactly one obvious place to add it.
  • The process method isn’t affected by the implementation of the individual steps. They could be completely internal, call to other services, make HTTP requests. Their implementation could change or get refactored. What matters is whether they succeeded.
  • No more trying to rescue any number of Exceptions and handling logic flow based on that (which could be a code smell).

Now let’s take a look at what one of those steps could look like. We’ll pick the very first step, check_inventory. Here’s a basic implementation:

The check_inventory method calls InventoryManagementService to ensure all ordered items are available, and fails the result otherwise.
`check_inventory` method v1

We don’t want the processing to go any further if there isn’t enough product available for any of the lines. There should be no code branch that allows say charging the client if we can’t fulfill the order. With conditional logic and exception handling, this could result in a combinatorial number of checks across multiple steps. With the Result pattern, however, as soon as the result fails, the if_success will short-circuit and not evaluate the passed-in block.

We could change the implementation of the check_inventory method to be more helpful:

The improved version calls the InventoryManagementService to see which lines are unavailable, and if there are any fails the result with data on the unavailable lines.
`check_inventory` method v2

The contract of the data returned by the result is orthogonal to its state (i.e. success or failure).

If the InventoryManagementService also returned a result (it should!) we could fail the result in this method and pass along any data from the InventoryManagementService result. Let’s see what that could look like.

This version handles the fact that each InventoryManagementService `available?` call also returns a result, and fails the ShoppingCartService’s result if any of those failed. If they succeeded, it checks which ones returned a `false` value indicating that the line wasn’t available.
`check_inventory` method v3

The fail method can be composable, allowing other results to pass along their messages. If the InventoryManagementService was calling other services (say an HTTP request to a Warehouse Management System), and those were down, the result would reflect that, and we can handle that failure as we choose.

Another advantage to this approach is that subsequent steps can confidently work with the assumption that they have the state they need available to them. For example, the generate_order method can be implemented only for the case that inventory exists, knowing that it would never get called if the inventory check failed in the previous step. And since it is a private method, there is no chance of it getting called from outside the service without the needed state in place. This simplifies the implementation quite a bit, avoiding an increasing number of nil checks along the way.

What does success look like?

Now that we’ve seen how a result could fail, let’s look at what success looks like. The service won’t succeed until the order has been processed:

The `process_order` method calls an OrdersProcessingService, which returns a result. In the failure case, the ShoppingCartService result is failed, otherwise, it is completed with the order as its value.
Example `process_order` method

Once again, if the processing fails, we fail the result. Then we call complete! to say that we’re finished processing, and return the result. This sets the state of the result to success unless it has previously failed. In other words, if the result stays successful through all the steps, the return value of the public process_order method is the successful result, whose value is the processed order. Otherwise it is a failed result, with data on exactly which step failed, and the guarantee that no further steps were executed.

Wrap up

As promised, here’s the important parts of the API we covered for the ServiceResult class. The actual implementation is left as an exercise for the reader (we highly recommend TDD as a way to build it)!

class ServiceResult
# A way to return its current state
def success?; end
def failure?; end
# A method to continue processing that
# takes a block, and only executes it
# if the current state is success
def if_success(&block); end
# A method to fail the result
def fail(result_or_data); end
# A method to finish processing
def complete!(value); end
# A way to return its value
def value; end
end

Hopefully this discussion helped to illustrate an approach that can help reduce cyclomatic complexity and boilerplate, while also increasing maintainability and the robustness of procedural business logic. If writing code like this is your jam, come find us at https://www.beambenefits.com/careers, we’re hiring!

Disclosure

For informational purposes only and not intended to be relied on as complete information, or to be construed as tax, legal, investment or medical advice. This is not a sale of or an offer to purchase a benefits plan from Beam.

--

--