Skip to main content
← All posts

Three API Design Decisions I Got Wrong (And What I'd Do Differently)

Specific mistakes I made designing backend APIs early on — not abstract principles, but the actual calls that caused problems and what I understand now that I didn't then.

I’ve redesigned the same kinds of API endpoints enough times now to have formed opinions about what I got wrong the first time. These aren’t abstract principles I read in a book — they’re specific decisions I made, the problems they caused, and what I’d do instead.

1. Returning the full document on every mutation

Early on, whenever a POST or PUT request succeeded, I’d return the entire updated document in the response body. It seemed sensible: the client made a change, so give it the new state.

The problem showed up when documents got complex. A user profile update would return the full profile — including fields the client had no interest in and hadn’t touched. An order status change would return the full order including all line items. The payload sizes were fine at first, then became a problem as data grew.

Worse: it created an implicit contract. The client started depending on getting the full document back from mutations, which meant I couldn’t change the response shape without breaking the frontend.

What I do now: Mutations return only what changed, plus a confirmation field. If the client needs the full updated state, it fetches it separately. This decouples write operations from read operations, which is correct — they’re different concerns with different caching and payload requirements.

2. Using GET for operations with side effects

At some point I had a GET /api/notifications/mark-read endpoint. The logic was: the client calls it when the notifications panel opens, and it marks everything as read. It was convenient to implement as a GET because I could call it from a useEffect without a fetch config object.

This broke in two ways. First, analytics and logging tools that crawl or pre-fetch URLs started accidentally marking notifications as read. Second, browser prefetching did the same thing. GET requests are supposed to be safe and idempotent — no side effects, repeatable with the same result. Mine violated both.

What I do now: Anything that changes state is a POST, PUT, or PATCH, full stop. The HTTP method isn’t just a convention — it carries semantic meaning that the infrastructure (caches, proxies, crawlers) acts on. Violating it causes subtle, hard-to-debug problems.

3. Designing error responses without a consistent structure

My first APIs returned errors in whatever format was most convenient at the time. Some returned a string: "User not found". Some returned an object: { error: "not found" }. Some returned nothing and just used the status code.

When I went to build consistent error handling on the frontend, I couldn’t write a generic error parser because the shape was different for every endpoint. I ended up with branching logic trying to handle all the formats, which was fragile and never completely covered all cases.

What I do now: Every error response follows the same shape:

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "No user exists with that ID.",
    "field": "userId"
  }
}

code is a machine-readable constant the frontend can switch on. message is human-readable for logging. field is optional — only present for validation errors tied to a specific input. Every endpoint, every error, same shape. The frontend error handler is one function, not a maze of conditionals.

The common thread

All three mistakes came from optimising for what was convenient to implement rather than what made sense for the system as a whole. Each one saved maybe thirty minutes during development and cost several hours across debugging sessions, refactors, and frontend workarounds.

API design decisions have an unusually high compounding cost because everything that depends on the API inherits the constraints of its design. A bad choice made early — especially around response shapes and semantics — tends to persist, because changing it requires coordinating changes on both sides of the interface.

Getting it right the first time isn’t always possible. But asking “what are the implications of this design for everything that depends on it?” before committing to a shape is a habit that consistently catches problems earlier.