Skip to content

Commit 285a8d3

Browse files
authored
Merge pull request #10 from gmalette/affordances-for-errors-pt3
Affordance for Errors, pt3
2 parents 057684b + a672871 commit 285a8d3

1 file changed

Lines changed: 134 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
---
2+
layout: post
3+
title: Affordance for Errors, part 3
4+
date: 2020-02-18 23:00:00 +0400
5+
description: In this third and final post, we work through a practical example of API design, to see how to remove the affordance for errors.
6+
categories:
7+
- Ruby
8+
---
9+
10+
In the [first post]({% post_url 2020-01-29-affordance-for-errors-pt1 %}) of this series, I showed how a few APIs afford errors to their users. In the [second post]({% post_url 2020-02-16-affordance-for-errors-pt2 %}), I showed a few examples of how other APIs or languages have avoided or solved the same problems. In this third and final post, we will work through a practical exercise of desigining an API.
11+
12+
13+
When I design APIs, I try to think of all the ways it could possibly be misused, and remove as many ways as possible. Sometimes, this comes at the cost of _some_ ergonomy, but how much I'm willing to sacrifice depends on a few factors: the criticality of the errors, the impact on ergonomy, the (handwavy) likelyhood that it will occur, to name a few.
14+
15+
## Practical Exercise: Reservation Manager
16+
17+
I was recently writing an API to our reservation manager (the system holds reservation for carts, and disallows selling more items than are available). From the outside, the functionality is simple. You can reserve the items of a cart. Once reserved, the reservation can be claimed (ex: after the payment is successful) or unreserved (ex: if the payment failed).
18+
19+
### Who Are The Users?
20+
21+
I think the very first step of API design is empathy. Yearn for your users to succeed in their task. Understand where they're coming from. Make sure your APIs can be composed with the other tools they use.
22+
23+
The most important question you must ask yourself: "who will the users be?". Are they interns, senior developers, or principal engineers? Will they use your API every day, once per quarter, or once per decade? Is your API the cornerstone of their feature, or are they using it as an afterthought?
24+
25+
Whenver possible, aim for the lowest common denominator. If an intern unfamiliar with the language using this API for the first time can succeed, the probability that a principal engineer using it for the 10th time will too is very high. When it's less practical, understanding your users will help you make the right tradeoffs between the different factors.
26+
27+
In my case, this API is likely to be used by junior developers, and very rarely. They will definitely plan ahead as this will be integral to what they're building.
28+
29+
### Idiomatic API
30+
31+
I started by jotting down a first draft of the API, that handled all the cases necessary, in an idiomatic Ruby fashion. It looked something like this:
32+
33+
```ruby
34+
manager = ReservationManager.new
35+
is_reserved = manager.reserve(cart)
36+
if is_reserved
37+
if process_payments(cart)
38+
manager.claim(cart)
39+
else
40+
manager.unreserve(cart)
41+
end
42+
else
43+
# handle error
44+
end
45+
```
46+
47+
However, as we've seen previously, this API affords a lot of errors.
48+
49+
### Identifying Affordance for Errors
50+
51+
Using the learnings from the first post, we can quickly identify a few problems:
52+
53+
1. It's possible to ignore the return value of `reserve` and treat all reservations like they succeeded.
54+
2. Additionally to #1, they can call `claim` and `unreserve` without having reserved in the first place.
55+
3. Is it legal to use different instances of `ReservationManager` for `reserve` and `claim` or `unreserve`?
56+
4. It's impossible to know when the reservation has exceeded its lifetime (ex, it has been garbage collected), and it's impossible to force the user to consume (`claim` or `unreserve`) the reservation.
57+
58+
For example, if the user had wrong assumptions about thei system, they could write this implementation:
59+
60+
```ruby
61+
manager.reserve(cart)
62+
if process_payments(cart)
63+
manager.claim(cart)
64+
end
65+
```
66+
67+
Knowing that the users of this system will use this API approximately once in their entire career, I strongly favour reducing the surface for errors over idiomacy or ergonomy. Let's see how we can solve this problem.
68+
69+
### Removing the Affordances
70+
71+
To partially remove the affordance #1, we need to give an incentive to the user to use the return value. In fact, we will give them no choice. We can start by make `reserve` return a `Reservation` object, which is now the owner of `claim` and `unreserve` methods. In doing so, we also solved #3; our users _cannot_ use a different instance of `ReservationManager` to `claim` or `unreserve.`
72+
73+
```ruby
74+
reservation = manager.reserve(cart)
75+
if reservation.success?
76+
if process_payments(cart)
77+
reservation.claim
78+
else
79+
reservation.unreserve
80+
end
81+
else
82+
# handle error
83+
end
84+
```
85+
86+
With this API, we only partially solved #1, and we haven't solved #2 at all, however. One can still fail to check if the reservation was successful. To solve this, we can wrap our `Reservation` in a `Result` object (aka, a kind of Either monad), forcing the user to at least check for success.
87+
88+
The usage of the `Result` object could be surprising to some Ruby developers, but in our codebase they are very common, and they wouldn't startle anyone.
89+
90+
```ruby
91+
result = manager.reserve(cart)
92+
result
93+
.on_success do |reservation|
94+
if process_payments(cart)
95+
reservation.claim
96+
else
97+
reservation.unreserve
98+
end
99+
end
100+
.on_error do |error|
101+
# handle error
102+
end
103+
```
104+
105+
Using this version, the users will have no choice but to consider whether the reservation was successful before they proceed further with it.
106+
107+
Depending on the various factors, we could decide to stop here; we're already in a much better state than the original API. However for this API, I really wanted to make sure I solved #4 and force the user to `claim` or `unreserve`. We can do so by trading the return value of `reserve` for a mandatory block that will receive the reservation result.
108+
109+
```ruby
110+
manager.reserve(cart) do |result|
111+
result
112+
.on_success do |reservation|
113+
if process_payments(cart)
114+
reservation.claim
115+
end
116+
end
117+
.on_error do |error|
118+
# handle error
119+
end
120+
end
121+
```
122+
123+
This allows us to `unreserve` the reservation if it hasn't been consumed by the end of the block. Depending on the specifics, we can also choose to `raise` if the reservation hasn't been consumed, to notify the users that their code has a problem.
124+
125+
With this API in place, we have dramatically reduced the surface for error. The objects in play will naturally guide the our users towars the correct way to use our API, and even if they don't, it won't compromise the correctness of our system.
126+
127+
Opinions may vary, but in mine, we haven't sacrificed ergonomics by the slightest to get here, either.
128+
129+
## Conclusion
130+
131+
In the first post of this series, I showed a few ways in which common APIs allow their users to make mistakes. My goal was to help you take notice of the problem, so that you can find similar problems (and more) in your own APIs. In the second post, we saw how others have solved the same problems, to help you see ways to remove the affordance you give. In this final post, I went through a practical example, and explained how I design APIs, and how I try to make it easy for my users to make no error. I sincerely hope you can take something away from this series, and that you start taking notice of, and start removing, the affordance for errors in your APIs.
132+
133+
[Comment or Like](https://github.com/gmalette/gmalette.github.io/pull/10)
134+

0 commit comments

Comments
 (0)