In the first post of this series, I showed how a few APIs afford errors to their users. In the second post, 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 an example of designing an API.
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) likelihood that it will occur, to name a few.
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).
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.
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?
Whenever 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.
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.
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:
However, as we’ve seen previously, this API affords a lot of errors.
Using the learnings from the first post, we can quickly identify a few problems:
- It’s possible to ignore the return value of
reserveand treat all reservations like they succeeded.
- Additionally to #1, they can call
unreservewithout having reserved in the first place.
- Is it legal to use different instances of
- 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 (
unreserve) the reservation.
For example, if the user had wrong assumptions about their system, they could write this implementation:
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.
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 making
reserve return a
Reservation object, which is now the owner of
unreserve methods. In doing so, we also solved #3; our users cannot use a different instance of
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.
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.
Using this version, the users will have no choice but to consider whether the reservation was successful before they proceed further with it.
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
unreserve. We can do so by trading the return value of
reserve for a mandatory block that will receive the reservation result.
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.
With this API in place, we have dramatically reduced the surface for error. The objects in play will naturally guide the our users towards the correct way to use our API, and even if they don’t, it won’t compromise the correctness of our system.
Opinions may vary, but in mine, we haven’t sacrificed ergonomics by the slightest to get here, either.
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 an 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.