- Introduction
- 1. Reversible vs Irreversible — the Screening Criteria
- 2. Resource Boundaries and Identifiers
- 3. Method Semantics: safe and idempotent
- 4. Status Codes Are a Contract
- 5. Error Response Format (RFC 9457)
- 6. Pagination: offset vs cursor
- 7. Representing Time, Money, and Enums
- 8. Change and Versioning — Expand, Migrate, Contract
- 9. How to Verify the Contract
- Quiz: Check Your Understanding
- Conclusion
- References
- Further reading
Introduction
The genuinely dangerous decisions in API design are not the hard ones but the irreversible ones. Pick the wrong caching strategy and you fix it next sprint. Pick the wrong identifier format and you still cannot fix it three years later. Both live under the single word "design," so most teams give them the same amount of time.
This blog already has The Complete Guide to API Design — REST, OpenAPI, Versioning, Pagination, Idempotency, Webhooks, plus gateway-layer posts such as Choosing a Rate Limiting Algorithm. Those are broad catalogues. This post rearranges the same material along one axis: cost of reversal. Depth instead of breadth. What has to be settled today, what can wait, and what the specs actually say when you settle it.
The sources are RFC 9110 (HTTP Semantics), RFC 6585 (additional status codes), RFC 9457 (Problem Details), and Martin Fowler's Parallel Change.
1. Reversible vs Irreversible — the Screening Criteria
1-1. Four Screening Questions
Whether a decision is reversible is decided not by technical difficulty but by the relationship the client forms with that value.
- Does the client store this value in its own storage?
- Does the client branch on this value in code?
- Does this value become a key in another system, a log field, or the basis for settlement?
- Does changing it require the client to modify and redeploy code?
One yes is enough to treat it as irreversible.
1-2. The Actual List
| Decision | Cost of reversal | Why |
|---|---|---|
| Resource boundaries and URL structure | Very high | Embedded in client code. Redirects never fully erase it |
| Identifier format and meaning | Very high | External systems store and index it |
| Method semantics | Very high | Retry, cache, and proxy behavior all hang off it |
| Status codes and error identifiers | High | The client's branch conditions |
| Pagination contract | High | Cursor format and response envelope harden together |
| Representation of time, money, enums | High | Baked into parsing code and storage schemas |
| Authentication scheme | High | Requires every client to deploy |
| Rate limit numbers | Medium | Lowering is breaking, raising is safe |
| Adding response fields | Low | Safe if unknown fields are ignored |
| Internals, storage, performance | Low | Free as long as the contract holds |
1-3. Things That Become Contracts Without Being Documented
Behavior you never wrote down still becomes a contract once clients depend on it. If you never specified sort order and a client built a UI around "roughly newest first," changing the order gets reported as an outage. The only defense is to explicitly state what you are not specifying.
- "Sort order is not guaranteed. Use the
sortparameter if you need one." - "Unknown fields must be ignored. Fields may be added without notice."
- "New values may be added to this enum. Follow the rule below for unknown values."
Putting those three sentences in the v1 documentation costs ten minutes. Leaving them out costs you a v2.
1-4. So, the Order to Decide In
What must be settled before the first release is the top five rows of the table above. The rest can wait until you have your first users. Plenty of teams work in the opposite order: two hours arguing about response field names, fifteen minutes on the identifier format.
2. Resource Boundaries and Identifiers
2-1. Boundaries Follow the Client's Nouns, Not the Org Chart
Cut resources along team boundaries and the API wobbles every time the org is reshuffled. The criterion is the noun the client perceives. If something the client treats as a single "order" is split into three because of internal server structure, the client writes code to reassemble them and that reassembly rule becomes the de facto contract. Three verification questions: is there a reason to fetch it alone, does its lifetime differ from its parent, does it have a separate authorization boundary. If all three are no, it is a field on the parent resource.
2-2. Where the URL Structure Hardens
Example — path templates always live inside a code block.
GET /v1/orders/{orderId}
GET /v1/orders/{orderId}/items
POST /v1/orders/{orderId}/cancellations
GET /v1/customers/{customerId}/orders?status=paid&limit=50
Two levels of nesting is the practical limit. In /v1/customers/:customerId/orders/:orderId/items/:itemId, if :itemId is globally unique then the prefix is decoration, and decoration produces typos and 404s.
How you express state transitions is decided here too. A cancellation can be a subresource creation like POST /v1/orders/:orderId/cancellations, or a partial update of a status field. The former leaves history as a resource and makes idempotency keys easy; the latter keeps the endpoint count low. Just avoid mixing both inside one API.
2-3. Three Identifier Options
| Scheme | Enumerable | Leaks scale | Sortable | Index locality |
|---|---|---|---|---|
| Sequential integer | High | Yes | Yes | Good |
| Random UUID | Low | No | No | Poor |
| Time-sorted ID | Low | Partially | Yes | Good |
Expose sequential integers and a competitor can call twice a day apart to estimate your daily order volume, while an attacker can walk the identifier space probing your authorization checks.
2-4. Do Not Encode Meaning Into Identifiers
The problem with an identifier like ORD-2026-KR-000123 is that clients parse it. The day a country code becomes three characters, every parser breaks. If you insist on a prefix, restrict it to a fixed one that indicates type only, such as ord_. Separating internal from external identifiers is another option: the cost is one mapping table, the benefit is that a storage swap never leaks into the API.
2-5. A Contested Point — Where REST Ends and RPC or GraphQL Begins
The industry genuinely disagrees here. Instead of a winner, look at the axes.
- Client diversity: with a single first-party web app, having the server shape responses to the screen is efficient; with many uncontrolled clients, a general resource-oriented contract wins.
- Caching needs: using HTTP cache infrastructure requires resources and method semantics. POSTing queries to a single endpoint gives up that layer.
- Organizational boundaries: when the consumer is an outside organization, the self-describing and easily documented option has the advantage.
- Query volatility and operational complexity: when the needed field combinations keep changing, a query language pays off — but you must newly design cost ceilings, depth limits, and a caching strategy.
What matters is writing the boundary down.
3. Method Semantics: safe and idempotent
This is where misunderstanding concentrates, so go to the source. RFC 9110 §9.2.1 defines safe methods as "essentially read-only; they do not alter server state" and classifies GET, HEAD, OPTIONS, and TRACE as safe. §9.2.2 defines idempotent as "the intended effect on the server of multiple identical requests is the same as for a single request" and classifies GET, HEAD, PUT, DELETE, OPTIONS, and TRACE as idempotent. POST is not idempotent.
| Method | safe | idempotent |
|---|---|---|
| GET | Yes | Yes |
| HEAD | Yes | Yes |
| OPTIONS | Yes | Yes |
| TRACE | Yes | Yes |
| PUT | No | Yes |
| DELETE | No | Yes |
| POST | No | No |
3-1. Misconception 1 — "PUT is update, POST is create"
The spec does not say that. §9.3.4 defines PUT as requesting that "the state of the target resource be created or replaced," so PUT creates too. §9.3.3 defines POST as requesting that the resource "process the representation … according to the resource's own specific semantics." The dividing line is whether the target URI names the resulting resource. Client picks the URI, use PUT; server picks it, use POST.
3-2. Misconception 2 — "DELETE twice returns 404, so it is not idempotent"
Idempotency is defined by the intended effect on the server being the same, not by the responses being the same. A 204 on the first call and a 404 on the second still leave the same effect — that resource does not exist — so it is idempotent. Whether the client treats the 404 as success is a separate contract.
3-3. Misconception 3 — "Idempotent means retrying is safe"
Idempotency is a property of the request once it reaches the server. When a network timeout eats the response, you do not even know whether it arrived. With PUT, retrying is safe; with POST, you get a duplicate creation. That is why POST needs an idempotency key.
Example — taking an idempotency key as a header.
POST /v1/payments
Idempotency-Key: 5f2a9c1e-6f6c-4a54-9f2f-27ab19d1b3c4
Content-Type: application/json
{ "orderId": "ord_01J9X", "amount": 15000, "currency": "KRW" }
The contract is only complete once you also document the key's retention window, the behavior when the key matches but the body differs (usually 409), and the behavior when the same key arrives concurrently. Idempotency and Retries: APIs You Can Trust goes deeper.
3-4. Do Not Put Side Effects Behind Safe Methods
If GET changes state, prefetchers, proxy caches, and crawlers will trigger that change at random. The point is not that side effects are forbidden but that the client takes no responsibility for them. Incrementing a view counter is fine; authorizing a payment is not.
4. Status Codes Are a Contract
Status codes are values clients branch on, so they are irreversible. Here are only the ones people get wrong, per RFC 9110's definitions.
| Code | RFC 9110's definition |
|---|---|
| 400 | The server cannot or will not process the request due to a client error |
| 401 | "The request lacks valid authentication credentials for the target resource" |
| 403 | "The server understood the request but refuses to fulfill it" |
| 404 | The origin server did not find a current representation for the target resource |
| 409 | "The request conflicts with the current state of the target resource" |
| 422 | Content type and syntax understood, but the instructions cannot be processed |
| 500 / 503 | Internal server error / temporary overload or maintenance |
4-1. Where 401 Ends and 403 Begins
RFC 9110 defines 401 in §15.5.2 and 403 in §15.5.4. 401 is authentication, 403 is authorization. A common third option in practice is returning 404 instead of 403 to hide existence. That is also a contract, so document it. Leave it out and clients read the 404 as "deleted" and purge their caches.
4-2. Where 400 Ends and 422 Begins
By the spec, broken syntax is 400 and syntactically valid but semantically wrong values are 422. Either way, pick one and be consistent. Mix them and clients end up handling both in the same branch.
4-3. 429 Is Not in RFC 9110
429 Too Many Requests is defined in RFC 6585. The text says it means "the user has sent too many requests in a given amount of time ('rate limiting')," and that the response "MAY include a Retry-After header indicating how long to wait." Because it is a MAY, clients must assume the header can be missing. The same RFC's 428 Precondition Required says "the origin server requires the request to be conditional," and exists to prevent the lost-update problem.
4-4. Anti-Pattern — Errors Inside a 200
Example — this removes the status code from the contract.
HTTP/1.1 200 OK
{ "success": false, "errorCode": "INSUFFICIENT_BALANCE", "message": "Insufficient balance" }
This response looks like a success to every proxy, gateway, monitor, and retry middleware on the path. The error-rate dashboard reads 0%, automatic retries never fire, and the circuit breaker never opens.
5. Error Response Format (RFC 9457)
RFC 9457 obsoletes RFC 7807 and defines HTTP API error representation with the application/problem+json and application/problem+xml media types. Unless you have a specific reason, do not invent a format — use this one. §3.1 defines five members.
type: a URI reference and the primary identifier of the problem type. Its default when absent isabout:blank.status: advisory, and must match the actual HTTP status code.title: a human-readable summary. Apart from localization, the spec says it SHOULD NOT change from occurrence to occurrence.detail: an explanation of this occurrence. It should "focus on helping the client correct the problem, rather than giving debugging information."instance: a URI identifying this occurrence.
§3.2 permits extension members and requires that clients "MUST ignore any such extensions that they don't recognize." That is why adding a field to an error response is not a breaking change.
Example — carrying field-level validation errors as an extension member.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "amount must be greater than 0",
"instance": "/v1/payments/req_01J9XQ",
"errors": [
{ "field": "amount", "code": "min_value", "min": 1 }
]
}
5-1. Designing the type URI
type is a value clients branch on, so it is irreversible. Three rules suffice. Be stable (the value should not change even if the domain does), be dereferenceable if convenient (not required), and match granularity to handling (give two errors with no reason to be handled differently distinct type values and clients will write code merging them back).
5-2. Separate What Humans Read From What Code Reads
title and detail are for humans; type and extension members are for code. Break that separation and clients start regex-matching the detail string, at which point the error message becomes the contract and a wording fix requires a client deployment.
§5 also covers security. Information included in problem details "must be carefully vetted," and the spec says to avoid exposing "implementation details such as a stack dump."
6. Pagination: offset vs cursor
6-1. The Real Differences
| Property | Offset-based | Cursor-based |
|---|---|---|
| Arbitrary page jumps | Possible | Not possible |
| Total count | Easy | Expensive or approximate |
| Cost of deep pages | Grows with depth | Independent of depth |
| Consistency under inserts and deletes | Duplicates and gaps | Stable |
| Where the contract hardens | Page number | Cursor string |
Offset's problem is not only performance. If a new item is inserted at the front while a user views page 1, page 2 shows the same item again; if one is deleted, an item is skipped entirely. On an API whose lists change often, this comes back as bug reports.
6-2. Cursors Must Be Opaque
Example — response envelope with an opaque cursor.
{
"data": [
{ "id": "ord_01J9XQ", "createdAt": "2026-08-15T09:30:00Z" }
],
"nextCursor": "eyJjIjoiMjAyNi0wOC0xNVQwOTozMDowMFoiLCJpIjoib3JkXzAxSjlYUSJ9",
"hasMore": true
}
Document the cursor's internal structure and clients will decode and tamper with it, after which you cannot change the structure. Specify only that the cursor is a token to be returned exactly as the server issued it, and you can later change the sort key or add a signature without breaking anyone.
6-3. A Non-Unique Sort Key Breaks the Cursor
Build the cursor from createdAt alone and items sharing a millisecond produce duplicates or gaps. Always append a unique secondary key to the sort key. That is why the cursor in the example above carries both a timestamp and an identifier.
6-4. The Irreversible Part Is the Envelope
The data / nextCursor / hasMore envelope cannot be changed later. Return a top-level array and the moment you need metadata you must change the entire response shape, which is a breaking change.
7. Representing Time, Money, and Enums
All three get carved directly into parsing code and storage schemas, so all three are irreversible.
Example — the representation recommended in this section.
{
"createdAt": "2026-08-15T09:30:00Z",
"scheduledAt": "2026-09-01T14:00:00+09:00",
"scheduleTimeZone": "Asia/Seoul",
"amount": 15000,
"currency": "KRW",
"status": "partially_refunded",
"canceledAt": null,
"externalId": "9007199254740993"
}
7-1. Time
- Send strings. ISO 8601 extended format (e.g.
2026-08-15T09:30:00Z) is safe. Numeric epochs cannot distinguish seconds from milliseconds, so a misparse lands you in 1970 or fifty thousand years out. - Always include the offset. A string without one requires knowing the server's time zone, and that time zone changes for infrastructure reasons.
- Future commitments need a time zone name. An offset pins the rule as of that moment, but daylight saving rules and national policy change. "2pm on September 1 in Seoul" must be stored as a time zone name.
7-2. Money
- Send an integer minor unit together with a currency code. Never floating point. Discovering during settlement that 0.1 + 0.2 is not 0.3 costs days of tracing.
- Decimal places differ by currency. Won has zero, dollars two, some currencies three. "Integer cents" does not hold without the currency code, so always send the ISO 4217 code alongside.
- If exchange rates are involved, include the conversion time and the rate. Add them later and historical rows lack the values, producing dual logic.
7-3. Enums
Whether adding a new enum value is breaking depends on the client's handling rule. If clients exhaustively branch on every value, it is breaking; if the default behavior for unknown values is defined, it is not. So put this sentence in the v1 documentation.
- "New values may be added to this field. Treat unknown values as
unknownand preserve the original string when echoing it back."
Fix value names to lowercase with underscores. Mixed case is normalized differently in different languages and produces comparison bugs.
7-4. null vs Absent Field vs Empty Value
Decide now whether you distinguish the three states. Whether "not canceled" is canceledAt: null or an absent field changes the client's partial-update logic, and if you have a partial-update API you need a way to distinguish "clear this field" from "leave it alone."
7-5. Send Large Integers as Strings
JSON numbers are parsed as double-precision floats in many languages. Send an identifier beyond the safe integer range as a number and the last digits silently change. That is why externalId above is a string.
8. Change and Versioning — Expand, Migrate, Contract
8-1. Parallel Change
Martin Fowler's Parallel Change splits an interface change into three. In the expand phase you "augment the interface to support both the old and the new versions." During the migrate phase you "update all clients using the old version to the new version," and this "can be done incrementally." Then, "once all usages have been migrated to the new version, you perform the contract phase to remove the old version." Fowler attributes the pattern to Joshua Kerievsky.
expand support old and new ── a server deploy is enough
│
migrate move clients one by one ── usage measurement is mandatory
│
contract remove the old version ── only after usage reaches zero
The core of this is admitting that the server does not control the length of the migrate phase. The contract date is set by usage, not by the calendar. That is why the expand phase must ship with usage instrumentation for the old version.
8-2. What Counts as a Breaking Change
Breaking: removing, renaming, or retyping a response field; adding a required request field; changing a status code or error identifier; changing defaults or the default sort; lowering rate limits; and adding an enum value only when clients exhaustively branch on every value.
Not breaking: adding optional fields to responses and requests (when the ignore rule is documented), adding endpoints, adding extension members to error responses, and performance improvements.
The borderline cases cause the accidents. "Adding an optional field" is only safe when clients ignore unknown fields; for a client using strict deserialization, adding a field is breaking too. That is exactly why the three sentences in section 1-3 exist.
8-3. A Contested Point — Path Versioning vs Header or Media-Type Versioning
No winner here either. Just the axes.
- Visibility and debugging: a version in the path shows up directly in logs and the address bar; header versioning requires inspecting the request.
- Routing and caching: path versioning makes routing and cache-key separation obvious. Header versioning requires exact
Varyhandling, and intermediate caches that get it wrong cause cross-contamination. - Granularity and version explosion: path versioning tends to bump the whole API at once, so versions jump in big steps. Media-type versioning can vary per resource but increases what you must maintain.
- Client convenience: if many consumers call by URL alone from a browser, path versioning is far easier.
Whichever you choose, first check whether the change can be made without a version bump. An API whose version number climbs quickly is usually skipping the expand phase.
8-4. Deprecation Procedure
- Announce deprecation in a response header. Documentation alone goes unread. Include the planned date and the replacement path.
- Instrument usage per client. Aggregate totals cannot tell you whom to contact.
- Run a short blackout rehearsal before contracting. Return 410 on the old version briefly, then revert, and the remaining consumers reveal themselves.
The larger picture of phased replacement is covered in The Complete Guide to the Strangler Fig Pattern.
9. How to Verify the Contract
When the contract in the documentation and the response actually on the wire disagree, the contract is the response.
9-1. Make the Schema the Source
Whether you generate the OpenAPI schema from code or the reverse varies by team. What matters is that one of the two is the single source. Maintain both by hand and they will drift, and the client will notice the drift first.
9-2. Block Breaking Changes in CI
Example — wiring a schema diff as a gate.
PR opened
└─ compare the previous commit's schema against the current one
├─ field removed / type changed / required added → fail, needs reviewer approval
├─ optional field added / new endpoint → pass
└─ error type URI changed → fail
The value of this gate is less in blocking than in surfacing. When a breaking change is genuinely needed, a human approves it; the problem is a breaking change merged by someone who did not know it was one.
9-3. Consumer-Driven Contract Tests
Consumers register expectations of the form "I use this field this way" as contracts, and the provider pipeline verifies them. The real benefit is not the tests but an explicit list of who depends on what, and that list becomes the contact list for the deprecation procedure in 8-4.
9-4. Validate Documentation Examples Against the Schema
Hand-written example responses go stale fastest. Put the examples under schema validation and the documentation stays current automatically.
9-5. Real-Traffic Regression
Replay a sample of production requests against both the old and new versions and diff the responses. This catches what schema validation cannot: distribution shifts in values, changes in sort order, empty arrays swapped for null. When building responses by hand, HTTP Request Builder and HTTP Status Codes help.
Quiz: Check Your Understanding
Quiz 1: A list API returns a top-level array. A request comes in to add a total count. What is the problem?
Answer: A top-level array has nowhere to attach metadata, so you must change the whole response into an object — a breaking change.
Explanation: The envelope of a list response is an irreversible decision. Wrap it in an object from the start and adding fields later is just an optional-field addition, which is safe. If it already shipped as an array, you must walk through expand, migrate, and contract.
Quiz 2: DELETE called twice returned 204 then 404. Has this API violated idempotency?
Answer: No. RFC 9110 defines idempotency by the intended effect on the server, not by identical responses.
Explanation: §9.2.2 defines idempotency as "the intended effect on the server of multiple identical requests is the same as for a single request." After two calls the state is the same — the resource does not exist — so it is idempotent. Whether retry logic treats 404 as success is a separate contract, and leaving it undocumented means 404s produced during retries get counted as errors and skew the dashboard.
Quiz 3: A payments API returns HTTP 200 with an error body when the balance is insufficient. The application works fine. What is breaking?
Answer: Every intermediate layer on the path reads this as a success. Error-rate metrics, automatic retries, circuit breakers, and gateway policies are all neutralized.
Explanation: Status codes are not read only by the application — proxies, load balancers, observability pipelines, and client libraries all act on them. Insufficient balance is a request error the client can correct, so a 4xx is right, and the body should be RFC 9457 problem details identifying the error type by type URI.
Quiz 4: You want to add one value to a status enum in a response. How do you judge whether it is a breaking change?
Answer: By whether the documentation defines how clients handle unknown values. Without that rule, it is breaking.
Explanation: Adding an enum value is neutral in itself; the breaking-ness is decided by the consumer's rule. For a client that exhaustively branches on every value, a new value is a runtime error. Writing the handling rule into the v1 documentation is overwhelmingly cheaper than producing a v2.
Quiz 5: You want to remove an old endpoint but are not sure usage is zero. What comes first?
Answer: Add per-client usage instrumentation first, then run a short blackout rehearsal.
Explanation: The contract phase of Parallel Change happens only "once all usages have been migrated to the new version." Aggregate totals do not tell you which consumers remain, so you cannot contact them, and removing without instrumentation means discovering them as an outage.
Conclusion
The place to spend time in API design is not the hard problems but the hard-to-reverse ones. The two often diverge: cache invalidation is hard but reversible, identifier format is easy but irreversible.
The five things to settle before the first release are resource boundaries, identifiers, method semantics, the status code scheme, and the error format. Add the three sentences from section 1-3 and a large share of the changes you will later need drop from breaking change to expansion. Avoid only the failure of demanding perfection on the changeable things while deciding the unchangeable ones carelessly, and half of API design is done.
References
- RFC 9110 — HTTP Semantics — quoted for the §9.2.1 safe definition and its methods, the §9.2.2 idempotent definition and the fact that POST is not idempotent, the §9.3.3 POST and §9.3.4 PUT definitions, the definitions of 400, 401, 403, 404, 409, 422, 500, and 503, and the 401 (authentication) vs 403 (authorization) split per §15.5.2 and §15.5.4. Retrieved 2026-08-15.
- RFC 6585 — Additional HTTP Status Codes — quoted for the definition of 429, the fact that
Retry-Afteris a MAY, 428 Precondition Required as the defense against the lost-update problem, and the fact that 429 lives here rather than in RFC 9110. Retrieved 2026-08-15. - RFC 9457 — Problem Details for HTTP APIs — quoted for obsoleting RFC 7807, the
application/problem+jsonmedia type, the five members defined in §3.1, §3.2 extension members and the client's obligation to ignore unrecognized ones, and the §5 prohibition on exposing stack dumps. Retrieved 2026-08-15. - Parallel Change — Martin Fowler — quoted for the descriptions of the expand, migrate, and contract phases, the fact that migration can be incremental, and the attribution of the pattern to Joshua Kerievsky. Retrieved 2026-08-15.
- The four screening questions, the cost-of-reversal table, the three-identifier comparison, the three sentences for the first documentation, the pre-contraction blackout rehearsal, and the schema-diff gate classification are not taken verbatim from the sources above; they are the procedure organized in this post.
Further reading
- Related post on this blog: The Complete Guide to API Design — REST, OpenAPI, Versioning, Pagination, Idempotency, Webhooks
- Related post on this blog: Idempotency and Retries: APIs You Can Trust
- Related post on this blog: Using HTTP Caching Properly
- Related tool: HTTP Status Codes
- Related tool: HTTP Request Builder
Complete Guide Series
현재 단락 (1/207)
The genuinely dangerous decisions in API design are not the hard ones but the **irreversible** ones....