7 API Design Mistakes I Would Never Repeat
Bad API design does not usually fail on day one. It fails when real clients, real users, and real edge cases start depending on your weak…
7 API Design Mistakes I Would Never Repeat
Bad API design does not usually fail on day one. It fails when real clients, real users, and real edge cases start depending on your weak decisions.

7 API Design Mistakes I Would Never Repeat
The endpoint worked.
The frontend shipped.
The mobile team integrated it.
Then one small change broke three clients, created five Slack threads, and turned a simple feature into a political negotiation.
That is the hidden cost of bad API design. It rarely looks bad in the first pull request. It looks “fast,” “simple,” and “good enough.” The damage appears later, when every client has built workarounds around your unclear contract.
An API is not just a way to move JSON.
It is a promise.
And weak promises become expensive.
1. I Treated API Responses Like Internal Objects
One of the first API design mistakes I would never repeat is exposing data the same way it exists inside the application.
It feels efficient at the start. You already have a User model. You already have an Order entity. You already have database fields. So the API response becomes whatever the backend currently stores.
{
"id": 42,
"user_name": "umar",
"is_active": true,
"created_at": "2026-06-09T10:20:00Z",
"deleted_at": null
}
This looks harmless until the internal model changes.
Maybe user_name becomes displayName. Maybe soft delete logic changes. Maybe is_active becomes a derived status. Maybe the database column exists for backend behavior but should never have been part of the public contract.
The prield name.
The problem is ownership.
Internal models belong to the backend. API contracts belong to clients. When you mix them, every database refactor becomes a client risk.
A better approach is to design response objects intentionally:
{
"id": "42",
"name": "Umar",
"status": "active",
"joinedAt": "2026-06-09T10:20:00Z"
}
This response is not a database dump. It is a client-facing contract.
That difference matters.
A database model answers, “How do we store this?”
An API response answers, “What does the client need to know?”
Those are not the same question.
Good API design creates a translation layer between internal reality and external promise. It may feel like extra work, but it prevents the worst kind of coupling: invisible coupling.
The frontend should not break because someone renamed a column.
2. I Let Every Endpoint Invent Its Own Shape
Inconsistent response shapes are one of the fastest ways to make an API feel immature.
One endpoint returns this:
{
"data": {
"id": 1,
"name": "Product"
}
}
Another returns this:
{
"product": {
"id": 1,
"name": "Product"
}
}
Another returns an array directly:
[
{
"id": 1,
"name": "Product"
}
]
And errors look different depending on which developer wrote the endpoint.
At first, nobody cares. The frontend handles it. The mobile app handles it. Someone writes a tiny adapter. Then another adapter. Then another. Six months later, every client has defensive code everywhere because the API has no consistent language.
This is where developer time quietly disappears.
A consistent shape does not need to be complicated. It just needs to be predictable.
{
"data": {
"id": "1",
"name": "Product"
},
"meta": {},
"error": null
}
For lists:
{
"data": [
{
"id": "1",
"name": "Product"
}
],
"meta": {
"page": 1,
"limit": 20,
"total": 120
},
"error": null
}
For errors:
{
"data": null,
"meta": {},
"error": {
"code": "PRODUCT_NOT_FOUND",
"message": "Product was not found."
}
}
The exact shape matters less than consistency.
That is the part many teams miss. You do not need to copy this exact structure. You need one structure your team actually follows.
Consistency reduces surprise. Surprise increases bugs.
When every endpoint behaves differently, the client stops trusting the API. And when clients stop trusting the API, they start guessing.
Guessing is where bugs enter.
3. I Used HTTP Status Codes Like Decoration
Bad API design often hides behind 200 OK.
The request failed, but the response says 200.
The user is unauthorized, but the response says 200.
Validation failed, but the response says 200.
Then the body contains something like this:
{
"success": false,
"message": "Invalid input"
}
This looks simple until real clients start integrating with it.
HTTP status codes are not decoration. They are part of the contract. Browsers, proxies, monitoring tools, SDKs, retry logic, error tracking, and client libraries all use them to understand what happened.
When everything is 200, the system loses signal.
A validation failure should not look like a successful request.
400 Bad Request
An unauthenticated request should be clear.
401 Unauthorized
A forbidden action should not be confused with missing login.
403 Forbidden
A missing resource should be obvious.
404 Not Found
A server failure should not pretend to be normal.
500 Internal Server Error
This does not mean obsessing over every possible status code. Most APIs can be very healthy with a small, consistent set.
The mistake is not using fewer status codes.
The mistake is using the wrong ones.
A clean error response with the right status code gives clients better control:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required.",
"fields": {
"email": "Email is required."
}
}
Now the frontend can show field-level errors. Monitoring can track real failure rates. Backend logs can separate client mistakes from server bugs.
A good API does not just return data.
It tells the truth about what happened.
4. I Made Pagination and Filtering Convenient Before Making Them Predictable
Pagination looks easy until it becomes permanent.
That is the trap.
A developer adds:
GET /products?page=1
Then the frontend needs page size:
GET /products?page=1&limit=20
Then filtering arrives:
GET /products?category=books
Then sorting:
GET /products?sort=price
Then search:
GET /products?q=keyboard
Soon, the endpoint accepts everything, documents nothing, and behaves differently depending on hidden backend defaults.
The client asks for page 3 and gets duplicate records. A product appears on two pages because sorting is unstable. Search changes the result count but pagination metadata does not match. The frontend adds loading hacks because nobody knows what the API guarantees.
Pagination is not just a query parameter.
It is a consistency problem.
At minimum, list endpoints should make these things clear:
ConcernBad API behaviorBetter API behaviorPage sizeHidden defaultReturn limit in metadataTotal countMissingReturn total when possibleSortingUnstableDefine default sortFiltersUndocumentedDocument allowed filtersEmpty resultAmbiguousReturn empty array with metadata
A better response gives the client enough context:
{
"data": [],
"meta": {
"page": 3,
"limit": 20,
"total": 45,
"totalPages": 3,
"sort": "createdAt:desc"
}
}
This is not about adding ceremony. It is about removing guesswork.
Filtering needs the same discipline. If status supports only active, paused, and archived, say so. If sorting is allowed only on createdAt and price, enforce that. If unknown filters are ignored, clients may think the filter worked when it did nothing.
Silent ignoring is dangerous.
A failed filter should often fail loudly:
{
"error": {
"code": "INVALID_FILTER",
"message": "Filter 'role' is not supported."
}
}
Predictability beats convenience.
A convenient API that behaves vaguely becomes expensive once real clients depend on it.
5. I Ignored Versioning Until Clients Were Already Depending on the Mistake
Every team says they will handle API versioning later.
Later usually means after the first painful breaking change.
At the beginning, versioning feels unnecessary. There is only one frontend. The backend and frontend are deployed together. Everyone sits in the same Slack channel. If something breaks, someone fixes it quickly.
Then the product grows.
Now there is a mobile app. Maybe customers use an integration. Maybe old frontend builds are still cached. Maybe third-party clients are calling your API. Suddenly, changing a response field is not just a refactor. It is a breaking change.
The mistake is thinking versioning is only for huge companies.
Versioning is not about company size. It is about client independence.
If clients can exist outside your deployment control, you need a versioning strategy.
That does not always mean putting /v1 in every route. It can be route versioning, header versioning, feature-based contracts, or careful backward-compatible evolution.
A simple route version is often enough:
GET /api/v1/orders
But versioning alone does not save you if you break contracts carelessly.
A better rule is this:
Add fields freely, remove fields carefully.
Most clients can ignore new fields. Removing or renaming fields is dangerous. Changing meaning is worse.
For example, changing this:
{
"status": "active"
}
to this:
{
"status": true
}
is not a small change. It breaks the contract because the meaning and type changed.
The safer path is to add a new field first:
{
"status": "active",
"isActive": true
}
Then migrate clients. Then deprecate the old field with a real plan.
Breaking changes are sometimes necessary. But they should be intentional, communicated, and measured.
The worst API changes are the ones that look harmless to the backend developer and catastrophic to every client.
6. I Leaked Database Thinking Into the API Contract
A lot of weak API design comes from designing endpoints around tables instead of user actions.
You see routes like:
POST /user_roles
PATCH /order_items/123
GET /payment_transactions
Sometimes these are fine. But often they expose backend structure instead of product meaning.
The client does not always want to manipulate tables. It wants to perform actions.
There is a difference between this:
PATCH /orders/123
and this:
POST /orders/123/cancel
The first says, “Update some fields.”
The second says, “Perform a business action.”
That distinction matters because cancellation may involve rules: refund checks, inventory updates, audit logs, notification events, permission checks, and state transitions.
If you expose everything as generic CRUD, clients may start sending partial updates that bypass business intent.
{
"status": "cancelled"
}
That looks simple. It is also risky.
A better API makes business operations explicit when the action has rules:
POST /orders/123/cancel
{
"reason": "Customer requested cancellation"
}
Now the backend owns the workflow. The client expresses intent without pretending to understand the entire state machine.
CRUD is not bad. It is useful for many resources. The mistake is forcing every business operation into CRUD because it feels cleaner.
Clean URLs do not matter if the system behavior becomes unclear.
Good API design asks:
“What is the client trying to do?”
Not just:
“What table are we updating?”
When APIs are shaped around business intent, they become easier to understand, safer to evolve, and harder to misuse.
7. I Designed for the Happy Path and Debugged the Rest Later
The happy path is seductive.
A user signs up. The request succeeds. The response returns a token. The UI moves forward.
Everything looks fine.
But real APIs live in the unhappy path: invalid input, expired tokens, duplicate requests, slow services, missing permissions, partial failures, third-party downtime, stale clients, and retries.
If the API design does not define failure clearly, every client invents its own behavior.
That is where chaos begins.
A strong API contract should answer basic failure questions:
- What error code should the client expect?
- Is the error safe to show to users?
- Can the request be retried?
- Is the failure caused by validation, authentication, permission, conflict, or server state?
- Is there a request ID for debugging?
- Are field-level errors available?
- Will duplicate submissions create duplicate records?
The last one is especially important.
Imagine a payment or order creation endpoint:
POST /orders
The user clicks twice. The network retries. The frontend times out but the backend succeeds. Without idempotency, the system may create duplicate orders.
A better design includes an idempotency key:
POST /orders
Idempotency-Key: 8f3c2a9
Now the backend can recognize repeated attempts and return the same result instead of performing the action twice.
This is not overengineering when money, inventory, bookings, or user trust are involved.
It is basic damage control.
Debuggability matters too. An error response without a request ID turns every production issue into detective work.
{
"error": {
"code": "ORDER_CREATE_FAILED",
"message": "Could not create order.",
"requestId": "req_92k10"
}
}
Now support, frontend, backend, and logs can point to the same event.
That one field can save hours.
The happy path proves the feature can work.
The failure path proves the API can survive reality.
Conclusion:
API Design Is Mostly About Reducing Future Surprise
The API design mistakes I would never repeat are not rare or advanced.
They are ordinary mistakes made under delivery pressure.
Returning internal models. Inventing response shapes endpoint by endpoint. Misusing status codes. Treating pagination as an afterthought. Ignoring versioning. Designing around tables instead of intent. Leaving failure behavior vague.
Each one feels small when you are moving fast.
Each one becomes expensive when clients depend on it.
A good API is not just a backend feature. It is a contract that protects teams from surprise. It helps clients trust the system. It makes errors understandable. It gives future developers fewer traps to inherit.
The real lesson is simple:
Bad API design saves time today by borrowing pain from tomorrow.
What API design mistake have you seen create the most damage in a real project?
메타데이터
- post_id
- 0f7796bf8d2b
- slug
- 7-api-design-mistakes-i-would-never-repeat-0f7796bf8d2b
- url
- https://medium.com/skillstuff/7-api-design-mistakes-i-would-never-repeat-0f7796bf8d2b
- canonical_url
- https://medium.com/skillstuff/7-api-design-mistakes-i-would-never-repeat-0f7796bf8d2b
- author_url
- https://medium.com/@codetune
- status
- ok
- fetched_at
- 2026-06-11 05:11:55