How to Design "Practical" APIs for Your Application?
When designing an API, my primary focus is on simplicity, ease of use, and consistency.
"What is practical API design?"
When I design an API, I focus on making it simple, easy to use, and consistent.
Keep in mind, there isn't a single solution that works for everyone.
I've worked with many teams on various projects, what works best for one project might not be right for another. And that's fine. It's the variety of methods and perspectives that keeps our work fresh and exciting.
“What is REST API?”
You can think of a REST API as a set of resources.
Each resource is like an object—it has a type, its own data, links to other resources, and several methods you can use to interact with it.
I should mention, I don't always follow the strict rules of RESTful APIs, like using all those specific status codes—201, 202, 422, or methods like PATCH. Sometimes I choose a slightly different solution.
Let's go over some basics of API design before we get into more details.
Here are some examples of operations you can perform at the collection level on /orders:
GET /orders // Retrieves a list of all orders or a subset based on applied filters
POST /orders // Creates a new order
PUT /orders // Replaces an entire list of orders with another list, which isn't a common practice
PATCH /orders // Applies partial updates to multiple orders based on filters, not commonly used
DELETE /orders // Deletes all orders or those matching certain filters, use with caution
For item-level operations on an individual order specified by :id
:
GET /orders/:id // Retrieves the details of a specific order
POST /orders/:id // Create a new order but with a specific ID
PUT /orders/:id // Replaces the specific order with the provided details
PATCH /orders/:id // Applies partial updates to specific fields of the order.
DELETE /orders/:id // Deletes the specific order.
Resource Name: Singular or Plural?
When it comes to naming resources in APIs, there's a bit of a discussion. Do we use singular or plural names? Let's look into this.
Here are 3 key points to consider:
A resource name is its identifier and it needs to be unique.
A collection is a type of resource that holds a list of sub-resources, all of the same type.
A resource or collection ID uniquely identifies that resource, but only within the context of its parent.
Let's use a familiar example, like a folder with files. Each file has its own name, right? In API terms, you might see something like this:
folders/<folder-id>/files/<file-id>
I tend to use plural names, it seems more standard and logical to me. But we've had a lot of discussions in this area, especially with words that don't follow regular pluralization rules, like 'people-person', 'child-children', or words that have only one form.
“But how about this: api/users/{id}/profile/? Why is ‘profile’ singular?”
There are situations where the singular form is more appropriate.
Consider these examples:
GET /api/forms/login
GET /api/users/{id}/profile
In the first example, we have multiple forms, but 'login' is just one specific form. That's why it's singular.
In the second example, if a user has only one profile, it's a singular resource. We stick with singular because having multiple profiles under one user ID wouldn't make much sense, right?
And here's something interesting: you can still explore deeper into a singular resource, like this:
GET api/users/{id}/profile/addresses/{address-id}
Each user has one profile, but that profile might have multiple addresses, so 'addresses' is plural.
Custom actions
Let's talk about those unique situations where standard actions like create, read, update, and delete (CRUD) aren't enough. I'm referring to actions such as 'undelete', 'publish', or 'sell'.
You've likely encountered this, where the usual CRUD approach doesn't quite fit. So, what can we do?
We have a couple of main strategies to handle these custom actions:
Slash after the resource name
This approach is simple, for example, if you need to restore a file, you could use an endpoint like:
POST /files/{id}/restore
This makes sense, doesn't it? It feels like you're performing an action directly on the resource.
The colon method
This method is used by big player Google APIs.
It looks like this: POST /files/{id}:restore and this has become a popular convention, especially evident in how Google structures its API design:
POST /v1/{resource}:getIamPolicy
POST /v1/{resource}:setIamPolicy
“Why not just stick with slashes all the way?”
Well, using a slash, like:
POST /v1/{resource}/getIamPolicy
This can become confusing.
It might suggest that getIamPolicy is a sub-resource of {resource}, which it isn't. That can lead to misunderstanding.
Even so, many developers still prefer the slash method, it's familiar, it aligns with standard URL conventions, and honestly, sometimes it's just easier to stick with what you know.
Versioning
We all understand that APIs evolve, and with that evolution, sometimes things break.
You might add new features, adjust existing ones, or remove what isn't working anymore. In this constantly changing environment, you definitely don't want to leave your users struggling with outdated or broken functions.
Let's discuss "breaking changes" and here are a few typical examples:
Renaming a field: This is a common issue. If you change the name of a field, suddenly, all the existing integrations might start having problems. Your users who relied on the older version could really have a tough time.
Changing a field’s type or format: Changing a field from a string to an integer, or altering the date format. It might not cause a major crash, but it can definitely lead to some odd, unexpected behaviors.
Tweaking field validation rules: Perhaps you decide to change the maximum length of a string or tweak regex patterns.
Flipping a field from optional to required: This change can be tricky. A field that was optional before is now required, and any requests that omit this field will fail
So, how do we manage these issues? One approach: Versioning.
URL versioning
This method is easy to understand; you just include the version number directly in the URL, like /v1/orders. It's straightforward, but it's also inflexible. Why? Because every new version requires a new URL.
If you're looking for a more nuanced solution, you might consider channel-based versioning. Here's how it looks:
/v1/stable/orders
# == /v1/orders
/v1/beta/orders
# == /v1beta/orders
Let's break down what these versions typically represent:
v1alpha (Alpha Version): This is an early release, mostly incomplete, and primarily for internal testing. Don't expect stability in this stage; it's all about experimentation and gathering initial feedback.
v1beta (Beta Version): This version is more refined than the alpha. It's available for broader testing, though it's still under development. Beta versions are closer to the final product but may still undergo adjustments based on user feedback.
v1 (Stable Version): This is where the API reaches maturity. After making it through the alpha and beta phases, the API is ready for widespread use as a stable release. It's fully tested and equipped for general deployment.
The alpha and beta stages are crucial for resolving any issues and ensuring that the API truly meets user needs.
Header versioning
Let's talk about header versioning and in this method, the version information is “tucked” away in the HTTP request headers.
It's not immediately visible, which makes it super flexible and you can switch versions without changing your URLs.
However, it can be a bit tricky for your clients to keep track of, and anything that's not directly visible might lead to bugs.
Query parameter versioning
This method is somewhat related to URL versioning, instead of embedding the version in the URL path, it's included in a query parameter. You've probably seen URLs like /orders?version=1.0, right? This approach is straightforward to implement and provides flexibility.
“So, should we even use versioning?”
That's the big question, isn't it? Well, the answer often is "it depends."
If you're developing an internal API just for your own use, or if you can update all your clients whenever there's an API change, then you might not need versioning. Otherwise, you could find yourself permanently stuck with v1, since upgrading everyone to a new version could be impractical.
Idempotancy
Idempotency isn't just a big word to impress your colleagues, it's actually essential for building a reliable API.
Think of idempotency as ensuring predictability.
In simpler terms, it means if you perform the same action multiple times, you always get the same result, especially in terms of the side effects of your operations.
Take this example:
You send a request to create a new user.
Bam , you've got a new user.
If you send that same request again (maybe by accident, or because your internet connection is glitchy)...
You won't end up with a second user; instead, you'll see the same outcome as the first time.
That's idempotency for you.
So, when does idempotency really matter? Let's dive into some HTTP methods, assuming there aren't any concurrent requests to complicate things:
GET (idempotent): This method is naturally idempotent, you're just pulling data, not changing anything. Whether you do it once or a hundred times, the outcome remains unchanged.
POST (non-idempotent): This is where it gets a bit complex. POST is essentially like saying, 'Hey, create something new.' Do it twice, and you end up with two of those things and it's clearly not idempotent.
PUT (idempotent): PUT is generally idempotent because it updates a resource or creates it if it doesn't exist yet. Repeat the action multiple times with the exact same data, and the final result doesn't change.
DELETE (idempotent): Deleting something is also idempotent. Once something is deleted, it's gone. Trying to delete it again won't change that fact, it can't become 'more gone.'
PATCH (potentially idempotent): PATCH can be a bit of a wildcard. If it consistently updates a resource in the same way, it's idempotent. However, if each PATCH operation changes something, like adding a new entry or incrementing a number, then it's not idempotent.
Now, how do we ensure idempotency, especially for those naturally non-idempotent POST requests?
A smart approach is to use a unique 'transaction ID' or 'request ID'.
With this method, if the same POST request is sent multiple times, your system can recognize it and effectively say, 'Wait a minute, we've already taken care of this. No need for another action.'
Take Stripe for example, They use request IDs to manage payment attempts, and this clever strategy helps avoid the problem of charging someone twice for the same transaction."
Pagination
So, you've got a massive amount of data, like a really long list of orders.
Sending all that data at once would be overwhelming, and that's why pagination is so useful. It breaks down the data into smaller, more manageable chunks, or "pages."
Page-based pagination
This method is classic and very straightforward.
You work with two main parameters: the page number and the size, which is the number of items you want per page.
The API delivers the data for that specific page and here's what it looks like:
# page 1
GET /orders?page=1&size=10
# page 2
GET /orders?page=2&size=10
Now, here's a twist.
Some APIs use "offset" and "limit" instead of "page" and "size". This terminology is more common in database talk. It means "start from this point (offset) and give me this many items (limit)."
# page 1
GET /orders?offset=0&limit=10
# page 2
GET /orders?offset=10&limit=10
Cursor-based pagination
Let's think about cursor-based pagination like using a bookmark.
You're basically telling the API, "Hey, start from this point 'sdkfj934jf983' right where this specific record is." It's especially useful for data that's constantly updating, like a live feed.
“Why is it cool?”
It helps you avoid annoying issues such as skipping over or seeing the same record twice. These problems can occur if the data changes while you're still browsing through pages.
Setting it up isn't too complicated, either. The server sends you a batch of records along with a cursor, which acts like a pointer indicating where the next batch starts. When you need more data, you just send that cursor back to the server.
Here's a quick look at how it works:
GET /orders?cursor=123
# response
{
"orders": [
{
"id": 124,
"name": "Order 124"
},
{
"id": 125,
"name": "Order 125"
},
{
"id": 126,
"name": "Order 126"
}
],
"cursor": "126"
}
“This is cool, but how do we know when we've seen everything?”
That's a great question! The server can give you some helpful indicators, like "hasNext", "hasPrevious", "totalRecords", and even a "previousCursor", to help you navigate through different scenarios.
“So, what’s the catch with cursor-based pagination?”
Well, it’s not perfect.
Unlike page-based pagination, where you can jump to any specific page, cursor-based pagination doesn't allow you to skip directly to a particular dataset.
But it's excellent for scenarios like infinite scrolling or live feeds.
One thing to keep in mind: it usually relies on having a stable order in your data, such as timestamps or IDs. If the order of your data changes frequently, cursor-based pagination can become tricky. It might even result in showing you duplicate or missed records.
Filtering + Sorting
These are two fundamental techniques in API design: filtering and sorting.
Filtering
Filtering is all about focusing on what you really need.
It allows you to narrow down your dataset based on specific criteria. You do this using query parameters, and the cool part is, you can combine several filters at once. Here's how you might do it:
GET /products?price=20&brand=Nike
Sorting
Sorting is about organizing your data, you can line it up either ascending or descending based on different fields.
# Default is usually descending
GET /articles?sort=publish_date
# But you can specify the direction
GET /articles?sort=publish_date,asc
# And even sort by multiple fields
GET /articles?sort=publish_date,asc;title,desc
Rate Limit
Let's shift to rate limiting, which is a technique to control the number of requests a user can make within a certain timeframe.
This is pretty important for managing server load, preventing abuse, and ensuring everyone has a fair chance to use your API.
I've actually written a detailed post about rate limiting strategies on my blog, which I highly recommend checking out for more comprehensive insights.
When someone exceeds the rate limit, the standard response is to send back a 429 HTTP status code, which means 'Too Many Requests'. It's also good practice to include a friendly message like, "Rate limit exceeded. Try again in X minutes" to set clear expectations.
Another pro tip: It's helpful to keep your clients informed with HTTP headers.
You can include headers like X-RateLimit-Limit: 1000, X-RateLimit-Remaining: 500, or X-RateLimit-Reset: 1588377600.
Using Retry-After: 120 is another practical way to manage this.