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.

Take a five-year-old internal API built by a good team. The original decisions were not stupid. They were fast, which is different. The API had three consuming systems when it was built and has eleven now. What cost half a day in year one costs three weeks in year four, because every bad pattern has been copied across all eleven consumers.

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

Numeric IDs in URLs: two problems in one decision

/api/invoices/1247 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. The database work is the easy part. Tracing every consumer of the old ID format is what takes the days.

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.
11consumers of an API that started with three. Every shortcut taken in year one has been copied into each of them.

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. An enterprise API without structured error codes ends up with 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 were made knowing what they committed future engineers to. Numeric IDs, missing versions, verb endpoints and inconsistent envelopes were not wrong on the day someone chose them. They became wrong when the API grew and nobody had designed for growth.

Share

Related reading