← назад к разделу

Представьте, что вы выпустили API, и клиенты уже на нём работают. Через месяц нужно изменить структуру ответа — переименовать поле или убрать лишнее. Но если просто поменять ответ, у клиентов всё сломается.

Решение — версионирование: старый API живёт по адресу /api/v1/orders, новый — /api/v2/orders. Клиенты переходят на v2 в своём темпе, а вы не тормозите разработку.

NestJS умеет делать это встроенными средствами — без ручного роутинга и if-цепочек в контроллерах.

Как включить версионирование

Настройка делается один раз в 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);
}

После этого любой контроллер без явной версии автоматически отвечает на /api/v1/....

/api/v1/orders        — работает
/api/v1/orders/:id    — работает
/api/v2/orders        — работает (если добавить v2-контроллер)

setGlobalPrefix('api') обязателен — без него маршруты будут без /api, что нарушает стандартный формат.

Как объявить версию на контроллере

Декоратор @Version явно указывает, какой версии принадлежит контроллер:

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

@Controller('orders')
@Version('1')
export class OrdersControllerV1 {
  constructor(private readonly ordersService: OrdersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.ordersService.findOne(id);
  }
}

@Controller('orders')
@Version('2')
export class OrdersControllerV2 {
  constructor(private readonly ordersService: OrdersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.ordersService.findOne(id); // тот же сервис
  }
}

Оба контроллера регистрируются в AppModule. Бизнес-логика в OrdersService общая — меняются только DTO и маппинг.

Когда создавать v2

Не каждое изменение API требует новой версии. Это ключевой вопрос, который определяет, сколько работы достанется и вам, и вашим клиентам.

Breaking changes — нужна новая версия. Это изменения, которые сломают существующих клиентов:

  • удаление эндпоинта или поля в ответе
  • переименование поля (customerIduserId)
  • изменение типа поля (stringnumber)
  • изменение формата поля (datedate-time)
  • удаление значения из перечисления
  • переименование URL-пути (/orders/sales-orders)
  • ужесточение валидации (уменьшение maxLength)
  • добавление обязательного параметра

Non-breaking changes — в текущую версию. Это изменения, которые не ломают клиентов, если они написаны правильно:

  • новое необязательное поле в ответе
  • необязательный новый параметр запроса
  • новое значение в перечислении
  • новый эндпоинт
  • ослабление валидации (увеличение maxLength)
  • изменение текста сообщения об ошибке

Частая ошибка — делать v2 ради добавления одного необязательного поля. Это лишняя нагрузка на клиентов и на команду.

Почему клиент не должен ломаться от новых полей

Хорошо написанный клиент игнорирует незнакомые поля в ответе. Тогда добавление нового поля в ответ v1 не требует новой версии:

// Клиент получает { id: "1", status: "CREATED", metadata: {...} }
// Поле metadata — новое, клиент его не знал
const order = await fetch('/api/v1/orders/123').then(r => r.json());
// metadata просто присутствует в объекте — не ломает существующий код

Аналогично для перечислений — клиент должен обрабатывать неизвестные значения как «что-то новое», а не падать с ошибкой:

interface OrderResponse {
  status: 'CREATED' | 'CONFIRMED' | 'SHIPPED' | string; // string — для будущих значений
}

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

Когда breaking change всё же нужен, оба контроллера живут бок о бок. Общая логика — в сервисе, форматирование — в отдельных маппинг-функциях:

// 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); // новый формат
  }
}

После выпуска v2 старая версия не умирает сразу. Правильный порядок:

  1. Пометить v1 как устаревшую (@ApiOperation({ deprecated: true })).
  2. Добавить заголовок Sunset с датой отключения.
  3. Следить за трафиком на v1.
  4. После даты Sunset отвечать статусом 410 Gone.

Частые ошибки в версионировании

Минорные версии в URL (/api/v1.2/) — не используются. Версия — целое число: v1, v2, v3. Минорные изменения, если они не breaking, идут в текущую версию без изменения URL.

Версия в query-параметре (?version=1) — неудобна для кэширования, роутинга и документации. Версия должна быть в пути.

Версионирование через заголовок (Accept-Version: v2) — NestJS поддерживает VersioningType.HEADER, но URI-версионирование проще для клиентов, браузеров и инструментов наподобие curl.

v2 ради необязательного поля — лишняя версия там, где её не требуется. Клиенты вынуждены мигрировать, хотя их ничего не сломало.

Коротко

  • enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }) + setGlobalPrefix('api') — минимальная настройка в main.ts.
  • Без явной версии на контроллере — маршрут получает версию из defaultVersion.
  • @Version('2') на контроллере — явная привязка к v2.
  • Breaking changes требуют новой версии. Non-breaking — нет.
  • Бизнес-логика остаётся общей в сервисе; v1 и v2 отличаются только DTO и маппингом.
  • Клиент, написанный правильно, игнорирует новые поля и неизвестные значения перечислений.
  • После выхода v2: пометить v1 устаревшей → добавить заголовок Sunset → отключить после даты.

Что почитать дальше

  • URL и ресурсы в NestJS — формат /api/v1/, setGlobalPrefix.
  • Ошибки RFC 9457 в NestJS — добавление нового кода ошибки — non-breaking.
  • OpenAPI в NestJS — как документировать параллельные версии.