All endpoints are prefixed with /v1, set globally in main.ts:
app.setGlobalPrefix('v1');This means every route is reachable at /v1/<resource>, e.g. POST /v1/auth/login.
The version prefix is intentionally coarse-grained — it covers the entire API surface, not individual endpoints. This keeps routing simple and avoids per-route version negotiation.
A change is breaking if existing clients must update their code to keep working:
| Breaking | Not breaking |
|---|---|
| Removing an endpoint | Adding a new endpoint |
| Renaming / removing a required field | Adding an optional field |
| Changing a field's type | Adding a new optional query param |
| Changing HTTP method or status code | Expanding an enum with new values |
| Changing auth requirements | Performance improvements |
| Removing an enum value | Bug fixes that don't alter the contract |
-
Exhaust non-breaking options first. Add optional fields, new endpoints, or query params before reaching for a version bump.
-
Open a tracking issue labelled
breaking-changedescribing what changes and why. Link it from the PR. -
Implement
/v2alongside/v1— never modify/v1in place. In NestJS, scope the new controller with a versioned prefix:// apps/backend/src/auth/auth-v2.controller.ts @ApiTags('auth') @Controller('v2/auth') export class AuthV2Controller { ... }
Register it in the module alongside the existing controller. Both versions run concurrently.
-
Mark deprecated endpoints in Swagger using
@ApiOperation({ deprecated: true })and add aDeprecationresponse header:// In the v1 controller method @ApiOperation({ summary: 'Login (deprecated — use /v2/auth/login)', deprecated: true }) @Header('Deprecation', 'version="v1"') @Header('Sunset', 'Sat, 01 Jan 2028 00:00:00 GMT') login(...) { ... }
-
Update the OpenAPI spec — regenerate and redeploy Swagger UI so consumers see the deprecation notice immediately.
| Phase | Duration | Action |
|---|---|---|
| Announcement | Day 0 | Issue opened, Swagger marked deprecated, Deprecation + Sunset headers added |
| Parallel support | ≥ 90 days | Both versions fully supported |
| Sunset | Day 90+ | v1 endpoints return 410 Gone with a migration message |
| Removal | Next major release | v1 code deleted |
The 90-day minimum may be extended for endpoints with high traffic or external integrations — maintainers decide case by case.
- GitHub issue — opened at announcement, linked from every related PR.
- CHANGELOG.md —
feat!:orfix!:commit triggers a MAJOR bump; the breaking change is described under### ⚠ BREAKING CHANGES. - Swagger UI — deprecated badge visible in the interactive docs at
/api/docs. - Response headers —
DeprecationandSunsetheaders on every deprecated response so API clients can detect it programmatically. - Release notes — the GitHub release created by Release Please includes the full breaking-change description.
v1 response
{ "id": "abc", "email": "user@example.com", "avatar": "https://..." }v2 response
{ "id": "abc", "email": "user@example.com", "avatarUrl": "https://..." }Migration path for clients
- const avatar = user.avatar;
+ const avatar = user.avatarUrl;During the parallel-support window, v1 still works. After sunset:
POST /v1/auth/legacy
→ 410 Gone
{ "message": "This endpoint was removed. Use POST /v2/auth/login instead." }If a field becomes required in v2 but was absent in v1, the v1 endpoint continues to accept requests without it. The v2 controller validates the new field with a class-validator decorator:
// v2 DTO
class LoginV2Dto {
@IsEmail() email: string;
@IsString() @MinLength(8) password: string;
@IsString() @IsNotEmpty() clientId: string; // new required field in v2
}- Opened a
breaking-changeissue and linked it from the PR - Implemented the change under a new version prefix (
/v2/...) - Left the old endpoint in place with
@ApiOperation({ deprecated: true }) - Added
DeprecationandSunsetresponse headers to the old endpoint - Used
feat!:orBREAKING CHANGE:footer in the commit message - Updated this document if the strategy itself changes