DEV Community

Cover image for Your API Is a Promise, Not a Set of Endpoints
MANGESH MANDLIK
MANGESH MANDLIK

Posted on

Your API Is a Promise, Not a Set of Endpoints

Here's a change that looks harmless in code review. The name field in the user response becomes firstName, because the team is adding lastName and wants the naming to be consistent. Tests pass. The web app ships in the same release and is updated to match. Everything is green.

Twenty minutes later, support starts getting messages. Everyone on an older build of the mobile app is crashing on launch. That build does something like user.name.toUpperCase(), and name no longer exists. You can't hotfix an app that's already installed on a few hundred thousand phones. The only real option is to roll the API back and start apologising.

Nothing in that story is a bug in the usual sense. The code did exactly what it was written to do. The mistake was in how the team thought about what they were changing. They thought it was their code. It was actually a promise that other people had built on.

That gap is what API design is really about, and it's the reason I built an animated walkthrough of it: API Design on SeeItFlow covers resource modeling, errors and versioning scene by scene. What follows is the written version.

An endpoint is code. A contract is a promise.

A junior engineer looking at GET /users/42 sees an endpoint: a function that takes an ID and returns some JSON. A senior engineer sees something else. They see a contract that might have hundreds of clients depending on it, some for years.

That changes what the hard part is. Making version 1 work is easy. The hard part is evolving version 10 without breaking anyone who is still on version 3.

And here's the uncomfortable bit. Every field you add to a response becomes load-bearing the moment a client reads it. Every field you remove becomes a production incident for whoever was reading it. You don't get to know who those clients are, either. It could be your own web app, a partner's integration, or a script somebody wrote in 2021 that still runs from a cron job.

So the working assumption I'd suggest is to design as if you can never coordinate a deploy with your callers. Often you genuinely can't. Once you take that seriously, most of the standard API advice stops looking like style rules and starts looking like survival tactics.

Name things after what they are

Look at these two ways to expose the same functionality:

# Reads like remote function calls
POST /createOrder
GET  /getOrderById?id=42
POST /updateOrder
POST /deleteOrder

# Reads like a resource
POST   /orders
GET    /orders/42
PATCH  /orders/42
DELETE /orders/42
Enter fullscreen mode Exit fullscreen mode

The first set encodes the action in the URL, so every new operation invents a new endpoint with a new name that every client has to learn. The second set has one noun, orders, and the HTTP method carries the verb. Once a client developer has seen how one resource behaves, they can correctly guess how the next one works. That predictability is the entire point.

Nesting follows the same logic. /users/7/orders reads naturally as "the orders belonging to user 7." It's useful, but I'd stop at two levels. Beyond that you're welding your route structure to your internal data model, and the day you reorganise the model, the URLs, which are part of the contract, have to break with it.

Pagination is another small decision that quietly becomes permanent. Offset-based pages (?page=3) look simple, but they misbehave when rows are inserted or deleted between requests, so clients see duplicates or skip items, and deep offsets get slow. Cursor-based pagination, where the server hands back an opaque token pointing at "where you left off," holds up much better. It's far easier to start with cursors than to migrate to them after clients depend on page numbers.

Status codes are part of the contract

This one surprises people, because status codes feel like a formality. They aren't. Proxies, CDNs, retry libraries, SDKs and monitoring dashboards all branch on them without ever reading your response body.

Consider the pattern where a service returns HTTP 200 with { "success": false } in the body. It feels tidy to the person who wrote it. Then reality shows up. The CDN sees a 200 and is happy to cache the failure. Your monitoring counts it as a successful request, so the error-rate graph stays flat while users have a bad time. The client library sees a 200 and carries on as if the call worked.

You only need a handful of codes to cover most of what a real API does:

  • 200 OK when the request succeeded.
  • 201 Created when a resource was created, ideally with a Location header pointing to it.
  • 400 Bad Request when the client sent invalid input.
  • 404 Not Found when the resource doesn't exist.
  • 409 Conflict for duplicates, lock failures and other state conflicts.
  • 429 Too Many Requests when a rate limit was hit, with a Retry-After header so the client knows when to come back.
  • 500 Internal Server Error when something unexpected broke on your side.

Use the real code, and let the tooling around you do its job.

Retries happen, so design for them

Networks time out. Mobile clients lose signal halfway through a request. Load balancers retry on their own. Sooner or later, someone will send the same request twice, and the only question is what your API does about it.

For a read, nothing bad happens. For POST /payments, the answer can be that a customer gets charged twice because their connection dropped after the server processed the charge but before the response arrived. The client, reasonably, tried again.

The standard fix is an idempotency key. The client generates a unique key per logical operation and sends it in a header. The server remembers the key and, if it sees it again, replays the original result instead of doing the work a second time:

app.post("/payments", async (req, res) => {
  const key = req.get("Idempotency-Key");
  if (!key) {
    return res.status(400).json({
      error: { code: "idempotency_key_required", message: "Send an Idempotency-Key header." },
    });
  }

  const saved = await store.get(key);
  if (saved) return res.status(saved.status).json(saved.body);

  const payment = await chargeCustomer(req.body);
  const body = { id: payment.id, status: payment.status };

  await store.set(key, { status: 201, body });
  return res.status(201).location(`/payments/${payment.id}`).json(body);
});
Enter fullscreen mode Exit fullscreen mode

That version shows the idea, but it has a hole you should know about. If two retries arrive at nearly the same moment, both can miss the lookup and both charge the customer. In a real system you'd claim the key atomically before doing the work, for example with a unique constraint or a SET NX, and have the second request either wait or get a "still processing" response.

Either way, the mindset matters more than the mechanism: for anything that moves money or creates something you can't cheaply undo, assume the request will arrive more than once.

Versioning: keep the old door open

Back to the rename from the opening. The fix is not "be more careful." Careful people rename fields too. The fix is a rule: a breaking change never ships in place.

If name really has to become firstName, that goes out as /v2/orders, or /v2/users, with the new shape. Version 1 stays alive and untouched until the clients using it have actually migrated. That last part is the expensive bit, because it means running two versions for a while. But it's cheaper than a single day where every old mobile build crashes.

A few habits make this less painful. Adding an optional field is usually safe, and removing or renaming one never is. Before you retire a version, measure who is still calling it, because "I think nobody uses v1" is a guess, and the logs can turn it into a fact. And plan for versioning before launch, not after. Retrofitting it onto an API that already has clients is much harder than shipping /v1/ on day one.

Errors should be as useful as successes

We spend a lot of time designing the happy path and treat errors as an afterthought, but the developer integrating with your API spends a big chunk of their time looking at error responses. A consistent envelope makes that time much shorter:

{
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "details": [
      { "field": "email", "issue": "must be a valid email address" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The same shape on every endpoint means clients can write one error handler instead of twelve. A stable code is something a program can switch on, while the message is for humans and can be reworded without breaking anybody.

There's a flip side that matters for security. If a 500 response includes a SQL error and a stack trace, you've just handed an attacker your table names and query structure. Log the full trace internally and return only a code and a short message to the caller. Adding a request ID to that response is a nice touch: the client can quote it in a support ticket, and you can find the exact trace in your logs.

REST, GraphQL or gRPC?

Everything above assumes REST, and REST is a fine default, but it isn't the only choice.

REST is the operationally safest option when your API is external or partner-facing. It works with curl, browser devtools, CDNs and every language under the sun, and its simplicity and cacheability are hard to beat.

GraphQL earns its place when many different clients need different shapes of the same data and you can't predict them in advance. It trades server-side complexity for client freedom, so one query can fetch exactly what one screen needs.

gRPC fits best when you control both ends of a service-to-service call and throughput and latency are what you care about most. Protobuf gives you strict schemas, but it isn't browser-native without a proxy, so I'd keep it for internal traffic.

The important thing is that none of these lets you off the hook on the contract idea. Remove a GraphQL field and every query that selected it breaks. Reuse or renumber a Protobuf field and old clients can misread your data. The tooling changes, but the promise stays the same.

A quick test before you merge

When a pull request touches anything that a client can see, a few questions catch most of the damage. Am I removing, renaming or retyping a field that something might be reading? Does every failure return a real status code with the standard error shape? If this request is sent twice, is anything charged, created or sent twice? And if this had to change again next quarter, could it, without breaking anyone?

That last question is the one I'd keep. The question for an API was never "does it work today?" It's "can I change it tomorrow?" An endpoint that works but can't evolve is a liability with a delay on it, and one that can evolve safely is a piece of infrastructure people can trust.

Explore It Visually

If you'd rather see these ideas move than read about them, I turned the whole thing into an animated walkthrough. It goes through resource modeling, valid requests, error handling, breaking changes and versioning, so you can watch a bad design fail and a good one hold up: API Design on SeeItFlow.

Now I'm curious: what's the worst API change you've seen ship, or the one you had to clean up? A rename, a removed field, a double charge? Tell me in the comments.

Top comments (0)