Представьте, что вы выпустили 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 — нужна новая версия. Это изменения, которые сломают существующих клиентов:
- удаление эндпоинта или поля в ответе
- переименование поля (
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 старая версия не умирает сразу. Правильный порядок:
- Пометить v1 как устаревшую (
@ApiOperation({ deprecated: true })). - Добавить заголовок
Sunsetс датой отключения. - Следить за трафиком на v1.
- После даты 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 — как документировать параллельные версии.