00:00:11.360
Hello, RubyConf! I'm going to be talking about using monads for elegant error handling.
00:00:14.799
My name is John Gallagher, and I'm a lead developer at BiggerPockets and an automation consultant for my own business.
00:00:20.640
Before we start, I want to give you a little warning: many of these slides will contain extremely cheesy stock imagery for comedic purposes. You'll know because, ping, you'll get a little bit of cheese in the top right-hand corner. So let's get going!
00:00:27.519
This is the first photo that is pretty cheesy. This person here is obviously thinking, 'Who stole my finger puppet?' and this person is thinking, 'Did I leave the shower on this morning?' So imagine you're in a meeting like this where you're deciding what the next feature you want to build is. You decide, 'Okay, it's this.' You do some refinement of the card, and then you start coding like a good developer.
00:00:53.920
You do TDD, right? Because everybody loves TDD here; we're all Rubyists. And so off you go with your coding. You create the happy path, you handle an edge case, and at some point, if you're anything like me, you stop and think: 'Hang on, have I caught all the failure modes for this piece of code? Have I handled all exceptions? And more importantly, have I caught the right exceptions? What would the customer see if this piece of code went wrong? What should they see?'
00:01:36.560
Let me take a little example here of editing an address. Imagine you have customer's contact details stored in an external API, like Office 365, for example. Now, let's say you're at a stage in the process where you want to show a list of all addresses to select for their postcode. If you're in the U.S., you'd refer to it as a zip code. This drop-down might contain "1 Main St," "5 Main St," "3 Main St," and a whole bunch of other addresses.
00:02:04.719
So, the first thing you want to do is make an API call to get the postcode you've previously stored. However, every API call can succeed or fail, so what happens if it fails? You decide that you would just want to render an error to the customer, essentially giving up because there's nothing more you can do.
00:02:39.920
If that API call is successful, it will return a postcode, and from that postcode, you can then look up what addresses are at that postcode or, again, zip code if you're in the U.S. Now, of course, that is a second API call, and it can fail as well. If that succeeds, you just want to render a select on the screen with all the addresses for the customer.
00:03:01.760
If it fails, we want to render a manual address entry screen, so the customer can enter the state, zip code, and street. This way, they aren't prevented from moving on to the next step. How would we do that in a standard Ruby way using exceptions? Here's what it might look like.
00:03:39.760
You would have a class, maybe called FindContact, and it would have a standard method called call that passes in the ID it wants to look up. You would use something like Rails.configuration.api to call out to Faraday, where we make a GET request. Since this is a JSON API, it will return a body with some JSON. You fetch the contact key out and return that. If there's any error, we rescue Faraday error or maybe we rescue JSON parsing error.
00:04:35.679
My colleagues have seen situations where an API returns a 200 success, but the JSON is malformed because someone made a mistake somewhere. We want to catch both exceptions because they indicate something has gone wrong. Incidentally, Faraday error also includes things like timeouts.
00:05:14.760
Now, what happens if there's an error? We want to return nil. But why nil? Well, nil in this case is a special value that indicates to the client that something went wrong and the operation could not be completed.
00:05:58.919
The lookup for postcode is very similar: it still makes an API call, passes in JSON, and fetches the addresses key. If there's an error here, we decide to pass back an empty array of addresses. What would the controller code look like?
00:06:34.160
Before, it might look a little like this if you're using Rails. We would find the contact and return it. If that contact is nil, we'd render the error screen. Then we exit early, as the contact is anything. We continue to look at the postcode. If there are any addresses in that array of addresses that come back, we render the address select screen.
00:07:23.040
Otherwise, we render the manual address entry screen. However, there are a few problems with this approach. First, we're returning nil. I have a big issue with Ruby developers inserting nil into their programs. Occasionally, I've done the same myself, but there are reasons to avoid it.
00:07:44.160
The client also knows too much. The client, which in this case is the controller, knows what nil means. We have a situation where the client is tightly coupled with our logic. This leads us to our next concern: there's no consistency in what to return. Should it be nil, an empty string, or an empty array? This lack of convention increases cognitive load on developers.
00:08:44.400
The end result is that sometimes customers get software that feels broken because we haven't fully thought through these exception cases. If we do return nil, calling any method on it could lead to unexpected errors. This uncertainty creates a sense of doubt and decision fatigue.
00:09:32.000
So, what's the solution? Until recently, I didn't have a great answer, but I've discovered monads, specifically a result monad. Monads can be used for many purposes, but let's zero in on result monads.
00:10:01.920
The result monad is pretty straightforward: it's a class that represents success or failure with a consistent interface. You'll see that this means we can compose lots of steps together in a clean way. In case of success, this object wraps the value; in case of failure, it wraps the error.
00:10:41.679
Let's refactor the code we had before to use monads and see what happens. First, we need to understand how to create a success or a failure.
00:12:09.440
Using the dry monads gem, we can create a success by using the success class, unsurprisingly. You can do success.new if you prefer that, but dry monads offers a nice syntactic sugar that makes your code cleaner.
00:12:38.880
What about failure? Failure provides a similar interface. In this case, failure wraps the error, and you can pass in various objects, such as an exception, symbol, or open struct. However, calling value bang on a failure won't mean anything, and instead, it will throw an error.
00:13:24.160
The takeaway here is that if you're using success and failure and you want to unwrap the value, you should first check if it's a success. Once verified, you can then call the value bang to get the actual value.
00:14:23.360
Let's take the previous error-handling code and convert it to use success and failure. First, we include dry monads. We wrap what was coming back, converting it from a plain hash into a success that wraps that hash. Then we rescue any exceptional case and wrap it in a failure.
00:15:08.320
The same approach applies to looking up the postcode. We include dry monads, wrap the successful response, and similarly wrap any exceptions in a failure to pass back.
00:16:15.840
What would the controller look like? Initially, we used nil checks, now converted to failure checks. This ensures an early exit if there's a failure. If the operation succeeds, we can use value bang to extract the postcode as usual and render the address select screen.
00:17:09.360
So that’s it! That’s all I have. I've been John Gallagher, and thank you for your time.
00:18:03.120
But wait! You may now be thinking what the real benefit of this approach is. You’ve probably noticed that now there are many dot value bangs everywhere alongside failure checks. Does that really improve the code?
00:18:51.440
The answer is yes, and let me elaborate. We can now build a pipeline. This is where the true power of monads comes in. Using a pipeline allows for clearer handling of success and failure without losing readability. We want to take all the success values and pass them along in a clean and straightforward way, while any failures pass through without triggering all the error handling.
00:19:59.920
Fmap allows us to process those successes and pass the values through cleanly, while failures will be handled specifically without complicating the initial structure.
00:21:24.160
Now, let's compare the two bits of code side by side: on the left is the old style with exceptions, and on the right is the new approach with monads. While using the traditional flow may seem familiar, the pipeline structure brings increased readability, ease of use, and extends flexibility.
00:23:09.600
With this, you've unlocked a continuous approach to your logic that helps avoid confusion around error handling.
00:23:29.680
You've also got versatile options in terms of additional error handling down the line, which is much easier with this setup. So, there you go! While it might seem like another learning curve, this method offers a robust alternative.
00:24:38.880
Finally, a couple of practice questions: if we pass in a postcode that is not nil or blank, we get a success; if it's blank, it results in failure.
00:25:10.640
Thank you for your time, and if you're interested, I have an exercise available on GitHub where you can submit a PR, and I'll take a look.
00:26:00.000
Once again, I'm John Gallagher at BiggerPockets. You can find me at synaptic mishap on Twitter and LinkedIn or John Gallagher on GitHub. Hopefully, I'll see you on the other side, and thanks once again!
00:27:00.240
Enjoy the rest of the conference!