Опирается на правила: R-VER-1..6 и R-VER-X1..X4 из REST API Style Guide → раздел Версионирование.

Важно знать

  • VersioningType.URI в enableVersioning/api/v1/... автоматически.
  • setGlobalPrefix('api') + defaultVersion: '1' — минимальная конфигурация.
  • Новая версия только при breaking change. Non-breaking идут в текущую.
  • Клиент обязан игнорировать неизвестные enum-значения и поля в ответе.
  • Параллельная поддержка v1/v2 — отдельные контроллеры, общие use cases, разные DTO.
  • Версия в query (?version=1) — запрещена (R-VER-X2).
  • Минорная версия (v1.2) — запрещена (R-VER-X1).

REST API — публичный контракт. NestJS предоставляет встроенный механизм URI-версионирования, который изолирует версии без ручного роутинга.

Настройка

R-VER-1..3:

// main.ts
import { VersioningType } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.setGlobalPrefix('api');
  app.enableVersioning({
    type: VersioningType.URI,
    defaultVersion: '1',
  });

  await app.listen(3000);
}

Результат: @Controller('orders') без явной версии → /api/v1/orders.

/api/v1/orders         ✓
/api/v1/orders/:id     ✓
/api/v2/orders         ✓ (новая версия контроллера)

/api/v1.2/orders       ✗ — минорная (R-VER-X1)
/api/2024/orders       ✗ — дата (R-VER-X1)
/orders                ✗ — без /api и без версии (R-VER-X3)
/v1/orders             ✗ — без /api (R-VER-X3)

Версия на контроллере

Явная версия через декоратор @Version:

import { Controller, Get, Version } from '@nestjs/common';

@Controller('orders')
@Version('1')
@ApiTags('Orders')
export class OrdersControllerV1 {

  @Get(':id')
  @ApiOperation({ operationId: 'getOrderV1', summary: 'Get order (v1)' })
  findOne(@Param('id') id: string) {
    return this.ordersService.findOne(id);
  }
}

@Controller('orders')
@Version('2')
@ApiTags('Orders')
export class OrdersControllerV2 {

  @Get(':id')
  @ApiOperation({ operationId: 'getOrderV2', summary: 'Get order (v2)' })
  findOne(@Param('id') id: string) {
    return this.ordersService.findOne(id);   // тот же use case
  }
}

Оба контроллера регистрируются в AppModule. Use case — общий; DTO и маппинг — разные.

R-VER-5 — клиент игнорирует неизвестное

Клиент API обязан:

  • Игнорировать неизвестные значения enum (или обрабатывать как UNKNOWN).
  • Игнорировать неизвестные поля в ответе JSON.
// ✓ TypeScript-клиент с forward-compat
interface OrderStatus {
  status: 'CREATED' | 'CONFIRMED' | 'SHIPPED' | string;  // string — неизвестные значения
}

// ✓ десериализация игнорирует новые поля по умолчанию
const order = await fetch('/api/v1/orders/123').then(r => r.json());
// новые поля просто присутствуют в объекте — не ломают код

На этом основано R-VER-6: добавление нового optional поля — non-breaking.

R-VER-6 — breaking vs non-breaking

Breaking — требуют v2

ИзменениеПример
Удаление endpointудалили DELETE /api/v1/orders/:id
Удаление/переименование поля DTOcustomerIduserId
Удаление/переименование query-параметра?customerId=?userId=
Изменение типа поляstringnumber
Изменение формата поляdatedate-time
Удаление значения enumубрали OrderStatus.DRAFT
Изменение HTTP-методаPOST /ordersPUT /orders
Обязательный новый параметр запросадобавили required Idempotency-Key
Изменение URL-пути/orders/sales-orders
Ужесточение валидацииmaxLength: 100maxLength: 50

Non-breaking — в текущую версию

ИзменениеПример
Новое optional поле в ответедобавили metadata в OrderResponse
Необязательный новый query-параметр?channel=web
Новое значение enumOrderStatus.RESERVED
Новый endpointPOST /api/v1/orders/:id/duplicate
Новый error codeORDER_LIMIT_EXCEEDED в enum
Ослабление валидацииmaxLength: 50maxLength: 100
Изменение текста detail / titleулучшили сообщение об ошибке

R-VER-X4: не создавать v2 для добавления необязательного поля.

Параллельная поддержка v1 и v2

// orders.controller.v1.ts
@Controller('orders')
@Version('1')
export class OrdersControllerV1 {
  constructor(private readonly ordersService: OrdersService) {}

  @Get(':id')
  async findOne(@Param('id') id: string): Promise<OrderResponseV1> {
    const order = await this.ordersService.findOne(id);
    return mapToV1(order);
  }
}

// orders.controller.v2.ts
@Controller('orders')
@Version('2')
export class OrdersControllerV2 {
  constructor(private readonly ordersService: OrdersService) {}

  @Get(':id')
  async findOne(@Param('id') id: string): Promise<OrderResponseV2> {
    const order = await this.ordersService.findOne(id);
    return mapToV2(order);   // новый формат
  }
}

OrdersService — общий. Маппинг mapToV1/mapToV2 — изолирует формат от логики.

После выхода v2:

  1. Пометить v1 deprecated (@ApiOperation({ deprecated: true })).
  2. Добавить заголовки Sunset и Deprecation (см. Rate limiting, файлы, deprecation).
  3. Мониторить трафик на v1.
  4. После Sunset410 Gone.

Что запрещено

АнтипаттернПравилоЧто взамен
Минорная версия /api/v1.2/R-VER-X1целое число (v2)
Дата-версия /api/2024/R-VER-X1v1, v2
Версия в query ?version=1R-VER-X2в URL path
Endpoint без /apiR-VER-X3setGlobalPrefix('api')
Endpoint без версииR-VER-X3defaultVersion: '1' или @Version
Новая версия для optional поляR-VER-X4в текущей версии
Header versioning Accept-VersionR-VER-1VersioningType.URI
VersioningType.HEADER или VersioningType.MEDIA_TYPER-VER-1только URI

Куда дальше

  • URL и ресурсы — формат /api/v1/, setGlobalPrefix.
  • Rate limiting, файлы, deprecation — Sunset для v1.
  • Ошибки RFC 9457 — ErrorCode enum — non-breaking расширение.
  • OpenAPI и антипаттерны — operationId при параллельных версиях.
  • REST API Style Guide (нормативно) — формулировки.