Designing REST Contracts That Survive Change
A versioned base path is not a versioning strategy. What actually determines whether an API can evolve is the shape of its responses and the discipline of never widening a promise you cannot keep.
Putting /api/v1 in front of everything feels like a versioning strategy. It is not. It is a place to put one, and most teams never come back to fill it in — because by the time v2 would be justified, there are clients they cannot migrate.
What actually keeps an API changeable is a small number of decisions made at the start.
Every collection response has the same envelope
Not because it is elegant. Because the day you need to add a field to pagination metadata, you add it in one place and every client already knows how to ignore unknown fields.
{
"content": [ ... ],
"page": 0,
"size": 12,
"totalElements": 47,
"totalPages": 4,
"first": true,
"last": false
}
The failure mode this prevents is subtle. If one endpoint returns a bare array and another returns an envelope, clients grow per-endpoint handling, and the cost of changing any endpoint scales with the number of clients that special-cased it.
public record PageResponse<T>(
List<T> content, int page, int size,
long totalElements, int totalPages,
boolean first, boolean last) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(), page.getNumber(), page.getSize(),
page.getTotalElements(), page.getTotalPages(),
page.isFirst(), page.isLast());
}
}
Errors are part of the contract
An error response is not a debugging aid. Clients branch on it, and once they do, its shape is as load-bearing as any success payload.
{
"timestamp": "2026-03-14T09:21:44Z",
"status": 422,
"error": "Validation Failed",
"message": "One or more fields are invalid.",
"path": "/api/v1/contact",
"fieldErrors": [
{ "field": "email", "message": "must be a well-formed email address" }
]
}
Two rules make this work. fieldErrors is always an array, present even when empty — a client that has to handle both null and [] will eventually handle one of them wrong. And status codes carry meaning: 400 for a malformed request, 422 for a well-formed request that fails business validation, 409 for a state conflict. Collapsing all of them into 400 forces clients to parse messages, which is how you end up unable to reword an error.
List and detail are different types
The most common contract mistake I see is returning the full entity from a list endpoint because it was less code.
A project list needs a title, slug, summary, cover image and a few technologies. The detail needs the full case study, the gallery, the modules, and the outcomes. If they share a type, the list response carries kilobytes of unused markdown per item, and any change to the detail shape silently changes the list.
public record ProjectSummary(
UUID id, String slug, String title, String summary,
String coverImageUrl, String coverImageAlt,
String categoryName, boolean featured,
List<String> technologies) { }
public record ProjectDetail(
UUID id, String slug, String title, String summary,
String caseStudy, String problemStatement, String roleDescription,
String solutionSummary, String outcomes,
String categoryName, String status,
LocalDate startDate, LocalDate completionDate,
String liveUrl, String repositoryUrl,
String coverImageUrl, String coverImageAlt,
String seoTitle, String seoDescription,
List<String> technologies,
List<ProjectModuleView> modules,
List<ProjectImageView> gallery) { }
Two types, two independent evolution paths. The extra thirty lines pay for themselves the first time you change one without auditing the other.
Never return the entity
Related, and stricter: JPA entities should not cross the controller boundary in either direction.
Outbound, an entity serialises whatever fields it currently has — so adding an internal column silently publishes it, and lazy associations either trigger extra queries during serialisation or throw outside the transaction.
Inbound is worse. Binding a request body directly onto an entity is mass assignment: a client posts "published": true or "id": "..." and your write path accepts it because the field exists. Request records with only the fields a client may set close that off by construction.
The versioning you will actually use
Real evolution is mostly additive, and additive changes do not need a new version:
- Adding a field is safe if clients ignore unknown fields. Verify that assumption once, in a test, rather than believing it.
- Adding an optional parameter is safe if the default preserves existing behaviour.
- Removing or renaming a field is not safe. Add the replacement, populate both, and remove the old one only after client migration — with logging to tell you when the last consumer stopped reading it.
v2 is for the change you cannot make additively: a restructured resource, a different security model, a genuinely different domain shape. Reaching for it more often than that means the contract was too tightly coupled to the schema, and a version bump will not fix that — it will just duplicate it.