During a rare productive youtube session, I came across a talk on How to build good APIs and why it matters by Joshua Bloch (author of Effective Java). After watching it, I knew I had to take notes because the talk was too good to forget. So good in fact, that I wanted to share them with you.
Joshua managed to squeeze many topics into an hour, hitting both higher-level characteristics of a good API, the process of building one and some practical tips to building an API. So let’s jump right in.
Firstly, let’s go over real quick what an API is. This is not covered in the video, so if you already know, feel free to skip this part.
An API (Application Programming Interface) can be considered as a contract of how to communicate with the software behind the API. It defines what data you can fetch, what format it is in and what operations you can do on that data. Which means an API can be anything from a fully-fledged REST API or a set of methods you can call to operate on a list.
According to Bloch, there are certain characteristics you can aim for to design a good API.
- Easy to learn
- Easy to use, even without documentation
- Hard to misuse
- Easy to read and maintain code that uses it
- Sufficiently powerful to satisfy requirements
- Easy to evolve
- Appropriate to the audience
While these characteristics are quite abstract and hard to implement, they can be used as a guideline. How to achieve these characteristics is what the rest of the post is about.
The first step of building an API is to start with the requirements. However, beware of proposed solutions by stakeholders and try to extract use cases instead. Figure out the exact problem you are trying to solve instead of how the user wants it solved.
Once you have the requirements in place, start small. Write up a maximum one-page specification.
Anything larger than that and your ego becomes invested. Sunk cost fallacy kicks in and you won’t feel comfortable scrapping it.
It is low effort to make changes and it’s easy to rewrite it once you start getting feedback. Only when you start to better understand the problem you are trying to solve, should you flesh out the specification more.
As counter-intuitive as it sounds, you should start coding against your API immediately. Create interfaces and don’t bother with the implementation until you have everything specced out. Even then, keep coding against your API to make sure it behaves as you would expect. This allows you to clarify and prevent surprises.
These code snippets could some of the most important code you will write for your API. They can live on as examples and you should spend a lot of time on those. Once your API is in use, it’s the example code that gets copied. Having good examples means good use of your API, so they should be exemplary.
However, the most important thing when building an API is
When in doubt, leave it out.
Especially if you are building a public API, it becomes near impossible to remove functionality once users started using it.
Small heads up, many of these examples are based on Java and OOP (Object Oriented Programming). Most of it is still applicable outside of Java and OOP though.
An API should do one thing and do it well
The functionality should be easy to explain. If it’s hard to name, it’s generally a bad sign. A good API should read like prose.
Be open to splitting things up when you’re trying to do too many things in one place or putting them together when you’re doing similar things.
An API should be as small as possible but no smaller
Satisfy the requirements, leave everything else out. You can always add but never remove.
Consider the number of concepts to learn to understand the API. You should think about the conceptual weight of having to learn the API and try to keep it as low as possible.
One way to do that is to reuse interfaces where possible. By reusing interfaces, the user only has the learn that interface once.
Don’t put implementation details in the API
You should not expose implementation details to the client. It makes the API harder to change if you want to change the implementation.
An example is throwing exceptions. You might be throwing a SQL exception, but in a later version also want to implement another form of data storage. Now you have to throw an SQL exception even if you’re trying to write to a file because the users are expecting and handling the SQL exception.
Minimize accessibility of everything
Make as much private as possible. It gives you the flexibility to change names and implementations without impacting your client's implementation.
Names matter a lot
The names should be largely self-explanatory, you should consider an API like a small language. This means it should be consistent in it’s naming. The same words should mean the same thing and the same meaning should be used to describe the same thing.
// Does the same thing, but different names are used fun remove() fun delete()
The components of the API that are documented well are more likely to be reused. Document religiously, especially when dealing with state or side effects. The better the documentation, the fewer errors your users will experience.
Never warp an API for performance
Good API design coincides with good performance usually. Things like making types mutable or using implementation types instead of an interface can limit performance.
By bending your API to achieve better performance, you run the risk of breaking the API. For example, by making an immutable class, mutable to use less memory. While the underlying performance issue will be fixed, the headaches are forever.
Classes should be immutable unless there is a very good reason to do so otherwise. If mutability is necessary, keep the state space as small as possible.
Subclass only where it makes sense
Subclass only if you can say with a straight face that every instance of the subclass is an instance of the superclass. If the answer isn’t a resounding yes, use composition instead. Exposed classes should never subclass just to reuse implementation code.
Design and document for inheritance or else prohibit it
This applies to OOP. Avoid the fragile base class problem, which occurs when changes to a base class could break the implementation of a subclass.
If it cannot be avoided, document thoroughly how methods use each other. Although as much as possible, try to restrict access to instance variables and use getters and setters to control the implementation of the base class.
Don’t make the client do anything the module could do
Let the API do the things that always needs to be done. Avoid boilerplate for clients.
// DON'T val circle = CircleFactory.newInstance().newCircle() circle.radius(1) circle.draw() // DO val circle = CircleFactory.newCircle(radius = 0.5) circle.draw()
Apply principle of least astonishment
The API user should not be surprised by behavior. Either you avoid side effects or use descriptive names to describe what the side effects are.
Errors should be reported as soon as possible after they occur. Compile-time is best, so take advantage of generics/static typings.
Provide programmatic access to all data available in string form
If you only use strings, the format and content become part of the API so you can never change it. So provide access to the content of the string via an object. This way you don’t have to make any promises about the format and content of strings.
Overload with care
Only overload methods if their behavior is the same. Taking the Java TreeSet constructor as an example, TreeSet(Collection) ignores order, while TreeSet(SortedSet) respects the order.
Use appropriate parameter and return types
Favor interfaces over classes for input, but use the most specific input parameter type. Don’t use string if a better type exists. You should also not use doubles or floats for monetary values, for example.
Use consistent parameter ordering across methods
Especially when parameter types are identical because you can accidentally swap the parameters around.
fun copy(source: String, destination: String) fun partialCopy(destination: String, source: String, numberToCopy: Int)
Avoid long parameter lists
Three or fewer parameters is ideal. Long lists of identically typed parameters can be harmful and very error-prone. If necessary, break up the function or use a helper class to hold the parameters.
Avoid return types that demand exceptional processing
Users will forget to write the special-case code, which can lead to bugs. This should be avoided in cases where the non-exceptional flow is also sufficient. For example, return zero-length arrays or collections rather than nulls.
Lastly, you should expect to make mistakes, which is why so many of these points are about being able to change things easily and less about building the perfect API from the get-go.
Please check out the talk as well, he goes into much more detail than I do here.
I hope you found these useful and feel free to share your thoughts or experiences in the comments!
Thank you for reading ❤️