Tech Reads
Engineering Practice8 min read

API Design for Enterprise: The Decisions That Age Badly

Three years after an enterprise API goes live, the decisions that hurt most are not the ones the team debated for a week. They are the ones made in an afternoon by a developer who did not realize they were making a decision at all. Numeric IDs leaking in URLs. No versioning strategy. Verb-based endpoints. Each one costs roughly two weeks to fix across a live system with multiple consumers.

We inherited a five-year-old internal API last year. The team that built it was good. The original decisions were not stupid — they were fast, which is different. The API had three consuming systems when it was built and eleven when we touched it. What cost half a day at year one cost three weeks at year four, because every bad pattern had been replicated across all eleven consumers.

These are the specific patterns we found. Each one is avoidable if you know to look for it.

Numeric IDs in URLs: two problems in one decision

/api/invoices/1247 — this is a numeric ID in a URL. The team chose it because the database uses an auto-increment integer primary key and the mapping was trivial. This is how it always happens.

The first problem: enumeration attacks. Sequential numeric IDs tell any authenticated user how many invoices exist, let them guess neighboring IDs, and let them observe your data volume over time. In enterprise systems with row-level security, the API may reject the unauthorized request — but the attacker now knows record 1246 exists and is accessible to someone. UUIDs give away nothing.

The second problem: migration pain. When you move to a new database, merge two systems, or change your primary key strategy, every numeric ID in every URL is a hard dependency. Every consumer has stored and used those URLs. Every integration has hardcoded the pattern. We spent nine days on ID migration alone on one project — not because the database work was hard, but because tracing every consumer of the old ID format took most of that time.

Watch out

Use UUIDs in API URLs from day one. Keep integer primary keys in the database for performance if you want — just never expose them. The mapping layer costs almost nothing to build and the migration pain it prevents is real.
9 daysspent on ID migration across 11 API consumers — not database work, but tracing everywhere the numeric ID pattern had been hardcoded

No versioning strategy: the decision that looks free

The most common reason teams skip API versioning is that they plan to "add it later." This is wrong in a specific way: there is no "later" for versioning. Once consumers depend on an unversioned API, adding a version prefix requires updating every consumer simultaneously or running parallel unversioned and versioned endpoints indefinitely.

The version prefix debate — URL versioning (/v1/invoices) vs header versioning (Accept: application/vnd.api+json;version=1) — is real but secondary to the more important question: have you versioned at all? We use URL versioning for enterprise APIs. It is visible in logs, in browser history, in documentation, in curl commands. Header versioning is elegant in theory and invisible in debugging at 2am.

The practical rule: ship /v1/ from day one even if you never plan to version. You will plan to version by year two.

The verb trap: RPC thinking in REST clothing

GET /api/getInvoice. POST /api/deleteUser. POST /api/approveAndNotifyAndLog. These are RPC endpoints using HTTP as a transport, not REST resources. The distinction matters because verb-based APIs proliferate — once the pattern exists, every new capability becomes a new endpoint, and the API becomes an undiscoverable collection of function calls.

The resource model forces discipline: what is the thing? /v1/invoices/{uuid}. What do you do to it? GET, PUT, DELETE, PATCH. State transitions — approve, reject, cancel — become sub-resources or state updates via PATCH with explicit status fields. The compound verb endpoint /approveAndNotifyAndLog is a side-effect problem dressed as an API problem — the notifications and logs should be consequences of the state change, not bundled into the endpoint.

Response envelopes and error schemas

Two inconsistencies that seem minor and are not: response envelope format and error response structure.

Response envelopes: some endpoints return {"data": [...], "meta": {...}}, some return bare arrays, some return {"items": [...], "total": N}. Each consumer has to handle three different response shapes, and adding a new consumer requires reading source code to discover which shape each endpoint returns. Pick one envelope pattern and use it everywhere. We use {"data": ..., "meta": {...}} for all responses, with meta carrying pagination and request IDs.

Error responses: clients need to parse errors programmatically. {"error": "Invoice not found"} is a human-readable string that a client cannot reliably act on. {"error": {"code": "INVOICE_NOT_FOUND", "message": "...", "field": "invoice_id"}} is a machine-parseable structure a client can route on. Every enterprise API we have inherited that does not use structured error codes has a client-side switch statement full of string matching. That is the technical debt your error format creates.

Pagination: the pattern that breaks when the dataset grows

Offset-based pagination (?page=3&per_page=50) works fine at 10,000 rows. It becomes a performance problem at 1 million rows because OFFSET 50000 in SQL requires the database to scan and discard 50,000 rows before returning results. At 5 million rows, your deep pagination queries start timing out.

Cursor-based pagination uses the last seen record's ID or timestamp as the page pointer: ?after=uuid&limit=50. The database query becomes WHERE id > :cursor LIMIT 50, which is O(log n) regardless of how deep in the dataset you are. The trade-off: you cannot jump to page 47. For most enterprise use cases — "give me the next batch of invoices" — that is fine.

The API decisions that age well share a single property: they were made with explicit awareness of what they committed future engineers to. Numeric IDs, missing versions, verb endpoints, inconsistent envelopes — none of these were wrong decisions at the moment someone made them. They became wrong when the API grew and nobody had designed for growth. Make the decision consciously or pay for it later.

Share

Related reading