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

REST API · VERSIONING Фаза 15. REST API

Версионирование REST API в NestJS - v1/v2 в URL

TypeScript · NestJS · API contracts

Совместимые изменения без новой версии и параллельные контракты для breaking changes.

1. URI versioning настраивается один раз

1 / 5

setGlobalPrefix("api") и VersioningType.URI создают единый формат. Контроллер без явной версии получает defaultVersion 1.

setGlobalPrefixapienableVersioningURI · default 1@Controller orders/api/v1/orders

2. Breaking change требует v2

2 / 5

Удаление или переименование поля, смена типа, удаление enum-значения и ужесточение валидации ломают существующих клиентов.

v1 responsecustomerId: stringstatus: CREATED | PAIDexisting clients depend on itv2 responsebuyerId: stringnew shape · new validationnew major contract

3. Совместимое добавление не требует v2

3 / 5

Новый optional response field, новый endpoint или дополнительное enum-значение для записи обычно не ломают клиентов, если их парсер толерантен.

Original v1orderIdtotalAmountclients read known fieldsExtended v1orderId · totalAmount+ deliveryEta optionalbackward compatible

4. Бизнес-логика остаётся общей

4 / 5

OrdersControllerV1 и V2 различаются DTO и mapping, но вызывают один OrdersService. Версия HTTP-контракта не копирует домен.

OrdersControllerV1OrdersControllerV2DTO mappingv1 responsev2 responseOrdersServiceshared domain

5. Старые версии имеют жизненный цикл

5 / 5

Клиенты переходят постепенно. Метрики использования, deprecation communication и дата удаления делают завершение v1 управляемым.

v1 + v2 activeparallel supportAnnounce deprecationmigration guide + metricsRetire v1after clients moveknown date

Представьте, что вы выпустили 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заработает, когда появится контроллер с версией 2

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 — нужна новая версия. Это изменения, которые сломают существующих клиентов:

  • удаление эндпоинта или поля в ответе
  • переименование поля (customerId → userId)
  • изменение типа поля (string → number)
  • изменение формата поля (date → date-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 → отключить после даты.

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