API Design

API Design

The basics of designing APIs.

Introduction

Imagine the Twitter API. You can use it to post, get, and even delete tweets. The Twitter API is an example of an external API. It's provided by a third-party service, Twitter, that allows us to interact with their service programmatically.

We need a way to interact with the Twitter API. How we do that is where API design comes in.

In this post, we'll focus on the basics of API design.

CRUD

When designing an API, we often talk about CRUD operations. CRUD stands for Create, Read, Update, and Delete. These are the basic operations that we can perform on a resource.

Often, it'll be helpful to think about the operations as functions, e.g., createTweet, getTweet, updateTweet, deleteTweet.

However, in REST APIs, the HTTP methods dictate the operation. POST is used to create a resource, GET is used to read a resource, PUT is used to update a resource, and DELETE is used to delete a resource.

Resource

A resource is an object or representation of something with data associated with it. For example, a user is a resource, and a user's name, email, and address are data associated with that resource.

Referring back to the Twitter example, a Tweet is a resource. A Tweet has data associated with it, like the tweet's content, the user who posted the tweet, and the timestamp when the tweet was posted.

Why API Design Matters

When designing an API, we need to consider how it will be used and evolve over time. We can't simply change it whenever we want. Once an API is released and used, we need to be careful about making changes. We need to maintain backward compatibility so existing clients can continue using the API without any issues.

Example of introducing a new feature

Let's say we have createTweet(userId, content) API for creating a tweet.

In REST, it'd look something like https://api.twitter.com/tweets with a POST request.

We want to add a feature where users can reply to a tweet. We can't simply add another field like parentId to the createTweet API. It wouldn't be backward compatible. We could make the field optional to maintain backward compatibility, but that's not a good design. It's not a good design because it's not clear that the parentId field is for replying to a tweet.

But, you can imagine a different scenario where introducing an optional field is a good design not to break existing clients.

Instead, we could create a new API like createReply(userId, content, parentId).

In REST, it'd look something like https://api.twitter.com/replies with a POST request.

Versioning

If we have to introduce breaking changes, we can version our API. We can have different versions of our API running simultaneously. This way, existing clients can continue to use the old version, and new clients can use the new version.

For example, the Twitter API might look like https://api.twitter.com/v1/tweets for version 1 and https://api.twitter.com/v2/tweets for version 2. Existing clients can continue to use v1, and new clients can use v2.

What to pass in the request

Let's take a look at what a Tweet may look like:

{
  id: string
  userId: string
  content: string
  createdAt: Date
  likes: int
}

If the user wants to create a tweet, they'd send a POST request to https://api.twitter.com/tweets with the following payload:

{
  userId: "123",
  content: "Hello, world!"
}

They would only need to pass the userId and content fields. The id, createdAt, and likes fields would be generated by the server.

It's important to understand that not everything in a resource needs to be passed in the request. Some fields should be generated by the server.

Get a specific resource

What if we wanted to get a specific tweet?

We could make a GET request to https://api.twitter.com/tweets/:id, where :id is the ID of the tweet. It would look something like https://api.twitter.com/tweets/123.

To the same endpoint, we could make a DELETE request to delete the tweet.

Get a list of resources by a related resource

What if we wanted a specific user's list of tweets?

We could make a GET request to https://api.twitter.com/users/:userId/tweets, where :userId is the ID of the user. It would look something like https://api.twitter.com/users/123/tweets.

Pagination

What happens if the user has thousands of tweets?

It would be inefficient to return all the tweets at once. Instead, we can use pagination. We can limit the number of tweets returned per page and provide a way to get to the next page of tweets.

This is where query parameters like limit and offset come in. We could make a GET request to https://api.twitter.com/users/123/tweets?limit=10&offset=0 to get the first 10 tweets, and https://api.twitter.com/users/123/tweets?limit=10&offset=10 to get the next 10 tweets.

limit specifies how many tweets to return, and offset specifies where to start fetching the tweets.

Internally, we may order the tweets by createdAt in descending order to get the latest tweets first.

Idempotence

GET and PUT requests are supposed to be idempotent, meaning they should not have any side effects.

POST and DELETE requests are not idempotent. They can have side effects. For example, making the same POST request multiple times will create multiple resources.

Conclusion

The most important points to remember when designing an API are:

  • Keep your API backward compatible.

  • Make sure your API is idempotent where it should be.

  • Endpoints should be intuitive and easy to understand.

  • Use query parameters for filtering, sorting, and pagination.

  • Use nouns for resources and HTTP methods for operations.

  • If you have to introduce breaking changes, version your API.