Using the Result pattern with Rails services
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).
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.
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:
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 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.
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:
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.