API Design Story

When designing an API, it's hard to know in advance what constitutes a "good" or "bad" design. For writing code, we usually refer to design patterns or other best practices, but API design often remains vague. Here we'll try to explore those best practices through a quick and simple story.

Task one: Naming things is hard

Imagine you are being onboarded to a new project and are asked to develop a small feature. The application you're working on includes a dashboard that lists the application users, your task is to add a simple filter that lets admins search that list by name. The page looks something like this:

Full NameEmailPhone
John Doejohn.doe@example.com555-555-551
Jane Smithjane.smith@example.com555-555-552

First, you open the page and check the browser dev tools to find witch API is being called to fetch those users. A quick scan of the Network tab shows no GET requests, yet users are clearly rendering after the page loads, something must be there. Looking more closely, you notice one of the calls is POST /get-users/. That's the one. You open the response body and see the following API response:

{
  "data": [
    {
      "id": 1,
      "full_name": "John Doe",
      "email": "john.doe@example.com",
      "phone": "555-555-551"
    },
    {
      "id": 2,
      "full_name": "Jane Smith",
      "email": "jane.smith@example.com",
      "phone": "555-555-552"
    }
  ]
}

Next, you try to find some documentation to see if the API already supports filtering. That would be ideal, as it would mean your only task is to update the UI and pass the filter parameter to the request. However, a search through the codebase gives no results. You're left to read the source code yourself (or, more likely these days, ask an AI to read it for you).

Okay, you just need to add a simple filter to the endpoint - how hard can it be? But, being the good engineer you are, you remember that RFC 9110 Section 9.3.3 does not mention data retrieval as a primary use of the POST HTTP method. So, you are going to refactor the code to use a GET method instead.

After changing the code, your endpoint now looks like GET /get-users/. It seems a bit redundant, so without missing a beat, you remove the 'get' from the name. Now it looks perfect: GET /users/. Wait, what were you doing in the first place? Oh, the filtering, need to get that done.

Now you need to add a parameter to filter by name. You see the full_name property in the data model, so let's keep it simple: GET /users/?full_name=John. That should do the job.

Now, wait a minute — you remember reading the OWASP Top 10 and seeing 'Injection' on the vulnerability list. You definitely need to ensure you validate the input and add proper sanitization for this new parameter. Looking into the database, you see that the field should have no more than 255 characters, so you start adding the validation.

What should happen when the validation fails? You should definitely return an error to the end user. Looking at your data model, you see there's a boolean success flag. Why complicate things? Let's switch it to false and return an error message, something like this:

{
  "success": false,
  "error": "Full name is too long, max 255 chars."
}

Okay, nice. You update the UI code (or, more likely these days, ask an AI to update it) to handle this case. You click around - things are working. Great!

Task two: Don't forget pagination

Your first feature has been shipped to production! Everything is working, and you're feeling pretty good about it. But a couple of days later, your boss comes to you and says that the users' dashboard is slow and takes a while to load. He asks you to figure out what's happening and fix it.

Oh, the user base has grown significantly in the last couple of days! Now, thousands of users are being returned from the API, and it takes about three seconds to get a response from the server.

Of course, the first thing that comes to mind is to paginate the response. You start reading about it online and see that there are different types of pagination (cursor, limit/offset, and page-based). You decide to keep it simple: just add limit and offset parameters to the query. You also add proper validation for the limit so that users can't provide values less than zero or request more than 100 results at once. And for the offset, you make sure it's at least 0.

Now your API request looks like GET /users/?limit=100&offset=0&full_name=John. You apply the changes to the backend and the UI, and page loads are much faster. Job done!

Task 2.5: Cache is always hit or miss

But something is bothering you: there's no need to add additional stress to the database, especially for those initial loads and repeated searches. It looks like caching would be useful here. But how should you cache? There are so many ways to do it. A quick search shows that your team already uses a caching tool, all you need to do is specify a new rule for your endpoint.

How great that you changed the endpoint to use a GET HTTP method (see more info in RFC9205 Section 4.5.1)! Now you have a unique key (the URL itself) to be used for caching.

Hmm, what eviction policy should you choose? You decide to keep it simple and just use a TTL (Time to Live) policy. You've read somewhere that 300 seconds should be more than enough for most endpoints. Sounds good — let's get this done.

You open a PR, it gets approved, and your changes are in production. Great — one more problem solved!

Task three: Everything is going to be OK

It's a regular day at work, and you are about to head to lunch when you see a new message in your inbox. You open the message — another bug about that users' dashboard? What's wrong this time?

The email says that some users are experiencing weird behavior: when they log into the dashboard, they get redirected to the login screen multiple times before they can finally interact with the page.

Weird behavior. You head to the page and try things out for yourself. Everything seems to be okay — so what's happening? Since it doesn't seem super critical at the moment, you head out to lunch.

After you come back you refresh the page and the app redirects you to login page, you put in your login and password app redirects you to the same dashboard, you see the spinner on the page that indicates that users are bing loaded. But, instead of showing you the users list you are back at the login page, hmmm that's strange.

You inspect the network tab to see the response from the GET /users/ endpoint, and it looks like this:

{
  "success": false,
  "error_type": "unauthorized",
  "error": "User session expired"
}

Okay, now you know why you were redirected. But how could your session expire so quickly? You suspect a bug in the authentication setup, but after some digging, the truth finally hits you: it's the cache. It's always the cache.

While you were at lunch, your session expired. When you refreshed the page, you received an unauthorized error. However, because your caching tool treated that error as a successful response, it cached it. When you logged in again and the app called GET /users/, the server served that same cached error. Your code saw the error, assumed you still weren't logged in, and kicked you back to the login screen, creating this weird loop.

Knowing what happened, it's time to fix it. It looks like that success flag in the response was causing more harm than good. You decide it's time to remove it and use proper HTTP status codes instead. You consult RFC9110 Section 15 and find that the appropriate status code is 401 Unauthorized for unauthorized requests.

But now another question arises: how should you structure the error response? You could try to make something up, but there's actually a standard for that as well RFC7807 Section 3 so you decide to follow it to keep things simple. Now, in the case of an unauthorized request, the API response looks like this:

{
  "type": "https://example.com/probs/unauthorized",
  "title": "Unauthorized",
  "detail": "User session expired, please log in again."
}

After applying those changes you decide to go back and fix the GET /users/ endpoint as well, it already returns a proper HTTP status code (200 OK), but there's no need to have that success flag in the response, now it looks like this:

{
  "data": [
    {
      "id": 1,
      "full_name": "John Doe",
      "email": "john.doe@example.com",
      "phone": "555-555-551"
    },
    {
      "id": 2,
      "full_name": "Jane Smith",
      "email": "jane.smith@example.com",
      "phone": "555-555-552"
    }
  ]
}

Also, time to fix the validation responses as well, you can use 400 Bad Request status code for that, and the response body can look like this:

{
  "type": "https://example.com/probs/validation-error",
  "title": "Validation Error",
  "detail": "The request parameters didn't pass validation.",
  "invalid_params": [
    {
      "name": "full_name",
      "reason": "Full name is too long, max 255 chars."
    }
  ]
}

You make the changes, deploy them, and check things out — the issue appears to be resolved. Success! What a day. Time to finally get some rest.

Task four: You can't always GET what you want

You're getting tired of that users' dashboard, so you look into the backlog to see if there's anything else to pick up. You find a great piece of work to embark on — until you see a Slack message. It's one of your teammates asking why you changed the GET /users/ endpoint. Another part of the application stopped working after your changes. God dammit.

You check the message and it turns out another part of the app is using that endpoint to provide autocomplete functionality for a dropdown. Your teammate asks for your help to fix it. What's the best course of action here? You could potentially roll back your changes, but that would mean the authorization issue will come back...

Ah, a simple but effective solution: let's have the best of both worlds. You can bring back the old version of the endpoint while keeping the new one as well. The only thing you need to do is add versioning to your latest changes. This way, you'll have the original GET /users/ endpoint that still uses the legacy success flag, and a new GET /v1/users/ endpoint (refer to Semantic Versioning for more complex use cases) that will contain your latest changes that your UI can use.

You quickly create a PR that should solve the issue, and after it gets approved, you deploy the changes. You check the app again, and everything seems to be working fine. Great!

Task five: Offboarding

The time has come: you're moving to a new greenfield project where you can build everything from scratch. But as always, there's a catch. Your boss asks you to organize a knowledge transfer for the engineer taking over your role. You guessed it — documentation!

You start to think about what to document, and of course, the first thing that comes to mind is that we don't have any API documentation. It would probably have been much easier if we had some from the beginning. Of course, you remember that the OpenAPI specification exists; you quickly write things down and get the following result (feel free to copy the spec to Swagger Editor):

openapi: 3.1.0
info:
  title: My API
  version: 1.0.0
  description: My API description
paths:
  /v1/users:
    get:
      summary: Get a list of users
      description: Returns a list of users
      parameters:
        - in: query
          name: name
          schema:
            type: string
          description: Filter users by name
      responses:
        "200":
          description: A list of users
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/user"
        "400":
          description: Bad Request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/problem"
components:
  schemas:
    user:
      type: object
      properties:
        id:
          type: integer
          examples:
            - 191
        name:
          type: string
          examples:
            - John Doe
      required:
        - id
        - name
    problem:
      type: object
      properties:
        type:
          type: string
          format: uri
          examples:
            - https://example.com/probs/example-error
        title:
          type: string
          examples:
            - Example error
        detail:
          type: string
          examples:
            - Example error description
        invalid_params:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
                examples:
                  - name
              reason:
                type: string

That's amazing! Not only do you now have a UI that allows you to play around with the API, you can also easily import it into Postman or even generate an API client using the OpenAPI Generator.

Task six: Off limits

Okay, now you're done with the documentation. You are ready to move on; you have documented everything and fixed all the issues. You've done your job. It's heading toward the end of your day, and you are already thinking about dinner. But just as you are about to head out of the office, Slack starts to explode. Alerts start popping up: the API is down...

Without hesitation, you start digging. Quickly looking into logs and dashboards, you see a huge volume of requests. God damn it — we need to scale the service; we can't sustain this kind of load. But wait a minute: we don't have that many users. The numbers don't seem right. There should be something else happening here...

You take a closer look at the logs: multiple requests from the same IP address. Why? The pattern continues, and you see the same set of IP addresses over and over again. Is there something wrong with the frontend? You quickly take a look. Oh no! Not again — someone used the useEffect hook without a dependency array. Ah, someone says that the patch is on the way. Great, problem solved!

Summary

To wrap up, here are the key principles of API design covered in our story, organized for clarity:

  • Use HTTP methods: Align actions with methods (e.g., GET for retrieval) to leverage web infrastructure like caching.
  • HTTP status codes: Use specific codes (e.g., 200, 400, 401) to communicate the exact state of the response.
  • Consistent resource naming: Use nouns instead of verbs to describe resources (e.g., /users instead of /get-users).
  • Standardize error responses: Follow protocols like RFC 7807 to provide consistent and machine-readable error details.
  • Validate incoming data: Sanitize incoming data to prevent vulnerabilities like injection attacks.
  • Version your API: Introduce new versions (e.g., /v1/users) to safeguard backward compatibility when making breaking changes.
  • Paginate results: Use limits and offsets or cursor based pagination to manage large datasets efficiently.
  • Implement caching: Reduce server load and latency by caching responses, ensuring you have a strategy for cache invalidation.
  • Add rate limiting: Ensure your API has rate limiting in place to protect against DoS attacks.
  • Document everything: Use standards like OpenAPI to generate interactive documentation and client libraries, simplifying onboarding and usage.

Resources