Photo by Bench Accounting on Unsplash

How to design clean API interfaces

Aashish Peepra
7 min readMar 3, 2024

--

This is going to be a tough read. This contains too much code to read and no pictures. It’s not for people with faint hearts. So read if you are ready to get bored and learn something new.

When you’re designing an API you want it to be consumable. Think of each consumer as a customer and your API as a product. You don’t want to make yourself happy, you want your customers to be happy. Design it in a way that is easy to consume. Performance is a factor but don’t overlook ease of use.

I can’t even count how many APIs I’ve written so far, but I surely have written a lot of bad ones. Based on those This blog post will mention some pointers to remember while developing a good and clean API.

Let’s understand modification Vs extension

That sounds like a very cliche term. Let’s break it down further. Let’s say you have an endpoint that gives you the items in the user’s cart. The response type of the API will look something like the following

{
"cart": [
{
"itemId": "babbf56f-3eea-4fe6-9601-159f8c78f056",
"item": {
"name": "Macbook Pro 14inch",
"listedPrice": 34000000
}
}
]
}

Now if the manager tells you that products do not have “name” but “title” instead, if you change the name key to title, you are modifying the response type. Hence, you are altering the interface. Hence, you are changing the contract between the backend and frontend. Hence, you have to change the frontend and every other place the API was consumed. This makes the process more error-prone. If it’s a necessity then you will have to do the modification. If not, make sure you minimize the number of modifications.

Instead, APIs should be open to extension. That means, now if you also need to provide the specifications for the product, you should be able to do that by adding a new column in your response object.

{
"cart": [
{
"itemId": "babbf56f-3eea-4fe6-9601-159f8c78f056",
"item": {
"title": "Macbook Pro 14inch",
"listedPrice": 34000000,
"specifications": {
"processor": "Apple M1 Pro",
"memory": "16GB",
"storage": "1TB SSD",
"graphics": "Integrated 16-core GPU",
"display": "14-inch Liquid Retina XDR display"
}
}
}
]
}

Also, imagine if you also needed to return some other information along with the cart details, the response object is easy to update. It is easy to extend.

Principle: APIs should be hard to modify and easy to extend.

Do not return arrays or primitive data types as the response

This is an extension of the previous principle. Imagine you created a company’s settings page. The customers are finally allowed to add multiple users to their accounts. You created a POST endpoint to support this feature.

The interface looks like the following

@UseGuards(RoleGuard[(requestingUserTypes.MAIN_APP_USER, requestingUserTypes.MAIN_APP_ADMIN)])
@Post("/")
async createUser(@Body() data: Body) {
// some missing validations. Don't worry about them

await this.prisma.user.create({
data: {
company: {
connect: {
id: data.companyId,
},
},
name: data.name,
email: data.email,
},
});

return true; // READ THIS
}

Can you smell what’s wrong with this code?

Actually, nothing is truly wrong with this piece of code. However, the API response is neither extensible nor consumable in a safe manner. Most of the inter-web communication happens over JSON, so people by default assume that a REST API will need to be parsed as JSON. “true” can’t be parsed as JSON. So the frontend developer will have a hard time handling this case.

If your defense is a POST endpoint that is not used to GET data it’s fine to send non-JSON responses. Then I’ll tell you one more scenario.

A recent feature that I worked on at Commenda was a document classification and extraction service. There’s a single multi-file uploader, the user can upload a bunch of documents and as you guessed correctly, they will get classified and extracted. The simple outline looks like this

Simple outline of a long running job
Simple outline for a long-running job

In this case, once the frontend uploads the files on the backend. The backend gives back a job ID that could be used for polling on the frontend. If I had simply returned a true, this wouldn’t be possible.

Also returning true from an API is a straight dumb move. We already have status codes to define whether an operation was successful or not. The frontend can just read the status code as 200 and get going on its way.

The problem with returning arrays directly is that your object no longer stays extensible and also it sometimes works and sometimes doesn’t. Simple arrays aren't valid JSON.

Principle: Don’t return primitive data types as API responses. Keep your responses open to extension.

Keep the interface constant for all users

This sounds like a very straightforward book move. But it is as easy to fuck up as any other thing.

Here’s an old piece of code

 @Get(":id")
async findOne(@Param("id", new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE })) id: number, @User() guardUser: GuardedRequest){
const authorizedPartnerAccountantCompany = await this.authorizationService.isAuthorizedPartnerAccountantForCompanyFuture(guardUser.user, id);

if (guardUser.user.isCommendaAdmin || authorizedPartnerAccountantCompany) {
company = (await this.companyServices.findOne({ id }, { ...includeCompany, members: true })) as FindOneResponseType & { members: PrismaUser[] };
} else {
const companyWithUser = await this.prismaService.user.findUnique({
where: {
email: guardUser.user.email,
},
include: {
...
},
},
});
...Other piece of code
company = companyWithUser.company;
}
}

Can you figure out what’s wrong with this?

If you think the await on the first line is the problem then you are right. but that’s not what I wanted to point out. Can you see how the reads are from different tables for different types of users?

If the requester is an admin we are reading from the company table and when the user hits its endpoint we read from the user table instead. The controller is not explicitly typed and hence it thinks that returning different types of responses is fine, when it isn’t.

That’s the problem with dynamically typed languages. They are good until you are playing around, but once you decide to do things more seriously, you realize how useless they are. Hence, if you are coding your backend in javascript, there are two options either you die or start coding in typescript. Best of luck!

Principle: Keep the interface constant for all the callers and explicitly type the response types for every endpoint

Use query parameters more extensively

One early-stage mistake I made that set a domino effect and everyone at the company followed it was not using query parameters enough.

Let’s say I want to design an API that gives all the corporations. I can have a GET /corporation. Now to get one corporation at a time, I can have GET /corporation/:id. But if I want to get all the corporations in a country, doing /corporation/342 where 342 is the companyID feels illegal because, in the corporation controller, the IDs should be corporation IDs and not company IDs. Hence I thought GET /corporations/byCompany/:companyId is brilliant. But it’s dumb.

What is a better way is to use query parameters as a filtering layer. So you still have your GET /corporation endpoint but you use query parameters and filter the response. It goes like GET /corporation?companyId=125. In your code, you will parse and check if the companyID is passed and if yes, add the filter to your database query.

An interesting thing when following this pattern is defining what information should be passed to different types of callers. I.E how to handle when the admin calls this endpoint, when the user calls the endpoint, and when an accountant or third party calls this endpoint.

Why do you need to handle these cases? Think of what will you return within the base case /. A simple / with no query means returning all the corporations that the user has access to, right?

So if the admin calls /, they see all the corporations. When users hit / they see all the corporations they have access to. When accountants hit /, they see all the corporations they are assigned to. Similarly, if you add the query parameter ?companyId=125. You will now have to validate whether the user has access to this company or not. This authorization check will be different for each type of caller.

But don’t forget, ultimately the response type should remain the same no matter who is calling the API.

Here’s an endpoint I wrote that gives you different invoices. Now the caller can simply get all the invoices by a company, by a corporation, by a country, etc, through one simple endpoint by using query parameters. This is a very recent piece of code and I like how beautiful it looks.

 @Get("/search")
async getAllFilteredInvoices(@Query() body: GetAllFilteredInvoicesDTO, @User() guardUser: GuardedRequest): Promise<GetAllFilteredInvoices> {
await this.authorizationService.isUserAuthorizedToAccessCompany(guardUser.user.email, body.companyId);

const resultFutures = body.corporationIds.map(id => {
return this.InvoiceServiceImp.getAllFilteredInvoices({
companyId: body.companyId,
corporationId: parseInt(id, 10),
countries: body.countries,
states: body.states,
effectiveStartDate: body.effectiveStartDate ? new Date(body.effectiveStartDate) : null,
effectiveEndDate: body.effectiveEndDate ? new Date(body.effectiveEndDate) : null,
});
});

const allResults = await Promise.all(resultFutures);

return allResults.reduce(
(prev, res) => {
return {
invoices: [...prev.invoices, ...res.invoices],
};
},
{ invoices: [] },
);
}
}

Principle: REST endpoints were written with clever features like query parameters, status codes, etc. built in. Use them.

It’s 12:50 AM and I’m writing this from my office. I think I’ll simply break this blog into multiple parts. Hope you had fun reading a bunch of highly opinionated writing standards. Have a great day.

--

--

Aashish Peepra

Software Engineer @commenda-eng | 100K+ people used my websites | Mentored 28K+ Developers globally | Google Solution challenge global finalist