Your API is a promise. Every client that integrates with it is holding you to that promise, often for years after you've forgotten the code that made it.
The hard part of API design isn't shipping v1. It's shipping v1 in a way that lets you ship v1.1, v1.2, and v1.50 without a rewrite and without a wave of angry integration partners. This article is about that skill: designing REST APIs that age well.
The Problem: An API Is a Contract That Outlives Its Code
Internal code has one owner. You can rename a method, change a signature, and let the compiler find everyone who breaks. You refactor, and the blast radius is your own repository.
An API is different. The moment a client — a mobile app, a partner integration, another team's service — depends on your endpoint, your freedom to change it disappears. You usually can't see who depends on you, you can't force them to upgrade, and you often can't even reach them.
⚠️ The core tension
Every API design decision either expands or shrinks your ability to change things later. "Aging well" means you keep evolving for years without a v2 rewrite or a wave of broken integrations.
Two forces fight each other:
- Business reality — requirements change constantly; the API must evolve.
- Contract reality — clients depend on today's shape and won't upgrade on your schedule.
A well-designed API resolves this tension by making most change additive — new capabilities that don't disturb existing clients. A poorly designed one forces you to choose between "break clients" and "never improve."
Intuition: The Public Electrical Socket
Think of your API like the electrical sockets in a wall.
Anyone can plug into a socket without knowing what's behind it. You can add new sockets anywhere you like, and no existing appliance cares. But the day you move a socket or change its shape, every device already plugged in stops working — and their owners are furious, because they did nothing wrong.
The entire discipline of designing durable APIs comes down to living on the left side of that diagram as long as possible.
Foundation: Model Resources, Not Actions
REST is built around resources (nouns) — things that have identity and state — not operations (verbs). The HTTP method already carries the verb; your URL should carry the noun.
| Action-oriented | Resource-oriented |
|---|---|
POST /getUserOrders |
GET /users/123/orders |
POST /createOrder |
POST /orders |
POST /cancelOrder?id=5 |
POST /orders/5/cancellation or DELETE /orders/5
|
GET /fetchActiveUsers |
GET /users?status=active |
A few conventions that pay off for years:
-
Use plural nouns for collections:
/orders,/users. Consistency matters more than the singular-vs-plural debate — pick one and never deviate. -
Nest to show relationships, not to show your database:
/users/123/ordersis fine;/users/123/orders/9/items/4/tax-linesis a smell. Deep nesting couples clients to a hierarchy you'll want to change. - Don't leak your schema. Your API is a product surface, not a window into your tables. If your API shape mirrors your DB exactly, every schema migration becomes a breaking API change.
Best Practice
Design the resource model around what clients need to accomplish, not around how you happen to store the data. The two drift apart over time — and you want the API to survive the drift.
Use HTTP the Way It Was Designed
HTTP already defines a rich, well-understood vocabulary. Reusing it correctly means clients, proxies, caches, and frameworks all behave predictably. Reinventing it means surprises.
The two properties that matter most are safe (no side effects) and idempotent (repeating the call has the same effect as making it once):
| Method | Purpose | Safe | Idempotent |
|---|---|---|---|
GET |
Read a resource | ✅ | ✅ |
PUT |
Replace a resource wholesale | ❌ | ✅ |
PATCH |
Partially update a resource | ❌ | ❌* |
POST |
Create, or non-idempotent action | ❌ | ❌ |
DELETE |
Remove a resource | ❌ | ✅ |
PATCH isn't required to be idempotent, though you can design it to be.
Idempotency is not academic. Networks drop responses, and clients retry. If a retried PUT or DELETE is safe to repeat, retries are harmless. POST is the dangerous one — two "create order" calls create two orders. That's exactly the problem idempotency keys solve, covered in the next article in this series.
Status codes are part of the contract too. Return the one that actually describes the outcome:
-
200 OK— success with a body -
201 Created— resource created (return itsLocation) -
204 No Content— success, nothing to return (e.g. aDELETE) -
400 Bad Request— the client sent something invalid -
401 Unauthorized/403 Forbidden— not authenticated / not allowed -
404 Not Found— no such resource -
409 Conflict— the request conflicts with current state -
422 Unprocessable Entity— well-formed but semantically invalid -
429 Too Many Requests— rate limited -
500/503— the server failed / is unavailable
Common Mistake
Returning200 OKwith{"success": false}in the body. Now every client must parse your envelope to know whether the call worked, HTTP tooling can't help, and monitoring can't distinguish success from failure. Let the status code do its job.
The Core Skill: Backward-Compatible Evolution
This is where APIs live or die. Most changes you'll ever make can be done without breaking clients — if you follow a few rules.
Additive changes are safe. Adding a new optional field to a response, a new optional parameter to a request, or a whole new endpoint doesn't disturb existing clients. They simply ignore what they don't know about.
Mutating changes break. Renaming a field, removing one, changing its type, or making an optional input required will break someone.
Two principles make this work in practice:
1. Be a tolerant reader. Clients should ignore fields they don't recognize rather than reject the whole payload. If your clients are strict parsers, you can never add a field. Design your own consumers to tolerate unknowns, and document that expectation for others.
2. Grow enums carefully. Adding a new enum value (e.g. a new orderStatus) is technically additive, but it can still surprise a client that has an exhaustive switch. Document that enums may grow, and tell clients to treat unknown values as a safe default rather than crashing.
Best Practice
Once a field is published, treat its name, type, and meaning as immutable. Need a different shape? Add a new field and deprecate the old one — never repurpose the existing one. Repurposing is the most insidious breaking change because it passes schema validation while silently corrupting behavior.
Versioning: Necessary, but Not a Substitute for Compatibility
Versioning is your escape hatch for the changes that can't be additive. It is not a license to redesign casually — every version you maintain is a version you must support, document, and eventually retire.
There are three common approaches:
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URI path | GET /v1/orders |
Obvious, easy to route, easy to test in a browser | Version leaks into every URL; "not purely RESTful" |
| Custom header | X-API-Version: 1 |
Clean URLs | Invisible, easy to forget, harder to test/cache |
| Media type | Accept: application/vnd.acme.v1+json |
Standards-aligned, per-resource granularity | Complex, poor tooling support, steep learning curve |
My recommendation for most teams: URI path versioning, and version as rarely as possible. It's visible, it's trivial to route and debug, and everyone on the team understands it instantly. The theoretical purity of media-type versioning almost never pays for its operational cost.
The deeper point: a new version should be a last resort, not a release cadence. If you're cutting /v2 every quarter, the real problem is that you're not making changes additively. Version when you must make a genuinely breaking change to a widely used contract — not for every improvement.
Predictable Collections: Pagination, Filtering, Sorting
Any endpoint that returns a list will, eventually, return too many items. Design for that on day one — retrofitting pagination onto an endpoint that used to return everything is itself a breaking change.
Never return an unbounded list. Always paginate, with a sane default and a maximum page size.
There are two pagination styles:
| Offset-based | Cursor-based | |
|---|---|---|
| Looks like | ?page=3&size=20 |
?cursor=eyJpZCI6MTIzfQ&limit=20 |
| Best for | Small, stable datasets; UIs needing page numbers | Large or fast-changing datasets; feeds |
| Weakness | Slow deep pages; items shift when data changes mid-scroll | No random page access; opaque cursors |
For most large or write-heavy datasets, cursor-based pagination is the more durable choice: it stays correct even as rows are inserted and deleted while a client is paging through.
Wrap collection responses in a small envelope so you have room to add metadata later without breaking anyone:
// A response envelope leaves room to grow (add totals, links, etc.) additively.
public record PageResponse<T>(
List<T> items,
String nextCursor // null when there are no more pages
) {}
Standardize filtering and sorting too: ?status=active&sort=-createdAt. Consistency across every collection endpoint is what makes an API feel designed rather than assembled.
Consistent Errors: One Shape, Everywhere
Nothing exposes an immature API faster than errors. One endpoint returns {"error": "..."}, another returns {"message": "..."}, a third returns a stack trace. Every client then writes bespoke error handling per endpoint.
Adopt a single, standard error format across the whole API. The IETF gives us one: Problem Details for HTTP APIs (RFC 9457, which updates the older RFC 7807). Spring Framework 6 / Spring Boot 3 support it natively via ProblemDetail.
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
// A stable, documented URI identifying the *type* of error.
problem.setType(URI.create("https://api.acme.com/errors/order-not-found"));
problem.setTitle("Order Not Found");
// Extra machine-readable context clients can rely on.
problem.setProperty("orderId", ex.getOrderId());
return problem;
}
}
That produces a predictable, self-describing payload:
{"type":"https://api.acme.com/errors/order-not-found","title":"Order Not Found","status":404,"detail":"No order exists with id 9931","orderId":9931}
** Best Practice**
Give errors stable, documented codes (thetypeURI here). Clients should branch on a stable identifier, never on your human-readabledetailmessage — that message is for humans and you'll want to reword it later.
Putting It Together in Spring Boot 3.x
A version-scoped controller returning proper resources, status codes, and an evolvable DTO:
@RestController
@RequestMapping("/v1/orders")
class OrderController {
private final OrderService orders;
OrderController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/{id}")
ResponseEntity<OrderResponse> getOrder(@PathVariable String id) {
return ResponseEntity.ok(orders.findById(id));
}
@PostMapping
ResponseEntity<OrderResponse> create(@RequestBody @Valid CreateOrderRequest request) {
OrderResponse created = orders.create(request);
// 201 + Location pointing at the new resource.
return ResponseEntity
.created(URI.create("/v1/orders/" + created.id()))
.body(created);
}
}
The response DTO is designed to grow. Note the additive currency field and the rule that unknown fields are omitted, not forced onto old clients:
@JsonInclude(JsonInclude.Include.NON_NULL) // omit nulls; clients tolerate absence
public record OrderResponse(
String id,
String status,
BigDecimal total,
String currency // added in a later release — additive, optional, safe
) {}
The key habits: version at the boundary, return real status codes, keep DTOs decoupled from entities, and only ever add to a published response.
A Realistic Production Scenario
The following is an illustrative scenario — a composite of a very common failure mode, not a specific incident.
Imagine an OrderResponse that has always returned a field called amount (a plain number of cents). A new requirement arrives: support multiple currencies. The tempting move is to change amount from an integer of cents into an object like { "value": 1299, "currency": "USD" }.
That single change is a textbook breaking change. Every existing client that reads amount as a number now gets an object, and their parsers fail — even though those clients never asked for multi-currency and did nothing wrong.
The durable fix is additive:
-
Keep
amountexactly as it is (integer cents), unchanged. -
Add a new, optional
currencyfield and, if needed, a richermoneyobject alongside it. -
Document
amountas deprecated in favor ofmoney, with a timeline. - Monitor usage of the old field, and only remove it (in a future major version) once traffic to it approaches zero.
Old clients keep working untouched; new clients adopt the richer shape at their own pace. Nobody is broken, and you still shipped the feature. That is what aging well looks like in practice.
Deprecation Done Right
You can't add fields forever; eventually you need to remove something. Do it as a managed process, not a surprise.
Two HTTP signals help you communicate machine-to-machine:
-
Sunsetheader (RFC 8594) — an HTTP-date telling clients when a resource will stop working. -
Deprecationheader (an IETF draft) — signals that a resource is deprecated.
The technical headers matter less than the discipline: announce early, support both paths during a real grace period, watch the metrics, and only remove when usage has genuinely dried up.
Trade-offs and When Not to Do Things
Good design is knowing what to leave out.
- Don't over-version. A new version multiplies your maintenance, testing, and documentation surface. Exhaust additive options first.
- Don't add fields "just in case." Every field is a promise you must keep forever. Add when there's a real need.
- Don't design for imaginary scale. Cursor pagination and rich error types are cheap insurance; a bespoke query language for an API three teams use is not.
Common Mistakes
-
Verbs in URLs (
/createOrder) — the method is already the verb. -
200 OKfor failures — breaks tooling, monitoring, and clients. - Exposing the database schema as the API — every migration becomes a breaking change.
- Renaming or repurposing a published field — the sneakiest break; passes validation, corrupts behavior.
- Unbounded list endpoints — fine in dev, a memory and latency bomb in production.
- Inconsistent error shapes — forces per-endpoint error handling on every client.
-
Versioning as a habit — a new
/v2every quarter means you're not evolving additively.
Key Takeaways
- An API is a contract that outlives its code. Design every decision to preserve your freedom to change later.
- Model resources (nouns), use HTTP methods and status codes as intended. The protocol already did a lot of design work for you.
- Make change additive. Add fields and endpoints; never rename, retype, or repurpose published ones.
- Version rarely and deliberately — it's the escape hatch for genuinely breaking changes, not a release rhythm.
- Standardize collections and errors (pagination + RFC 9457 Problem Details) so the API feels designed, not assembled.
- Deprecate as a managed process, with communication and metrics — never as a surprise.
📖 References
- RFC 9110 — HTTP Semantics (methods, status codes): https://www.rfc-editor.org/rfc/rfc9110
- RFC 9457 — Problem Details for HTTP APIs (updates RFC 7807): https://www.rfc-editor.org/rfc/rfc9457
- RFC 8594 — The Sunset HTTP Header Field: https://www.rfc-editor.org/rfc/rfc8594
- Spring Framework — Error Responses /
ProblemDetail: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html - MDN — HTTP request methods: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods