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

URL — это первое, что видит разработчик, который подключается к вашему API. Хорошо спроектированный путь читается как предложение: GET /orders/42/items — «дай мне позиции заказа №42». Плохо спроектированный заставляет открывать документацию на каждый шаг. В этой статье разберём, как NestJS устроен изнутри и какие правила помогут сделать API предсказуемым.

Как NestJS строит URL из кода

В NestJS каждый контроллер отвечает за свой префикс пути. Базовый адрес ресурса задаётся в @Controller, а конкретные эндпоинты — в декораторах методов.

@Controller('orders')           // /orders
export class OrdersController {

  @Get()                        // GET /orders
  findAll() {}

  @Get(':id')                   // GET /orders/:id
  findOne(@Param('id') id: string) {}
}

Чтобы не писать /api/v1 в каждом контроллере вручную, настройте это один раз при запуске приложения.

Глобальный префикс и версионирование

В 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.

Служебные пути — health, ready — должны быть вне этой цепочки, иначе балансировщик придётся настраивать с учётом версии:

@Controller('health')
export class HealthController {
  @Get()
  check() { return { status: 'ok' }; }
}
// → GET /health  (без /api, без версии)

Чтобы контроллер не получил глобальный префикс, укажите { host } или переопределите версию декоратором @Version(NO_VERSION).

Как называть пути

Строчные буквы и дефисы

Стандарт для URL — kebab-case: всё строчными через дефис. Никакого camelCase, никакого snake_case.

@Controller('order-items')   // правильно
@Controller('OrderItems')    // неправильно — заглавные
@Controller('order_items')   // неправильно — подчёркивание
@Controller('orderItems')    // неправильно — camelCase

Без глаголов и расширений

Путь называет существительное — ресурс, а не действие. Действие выражает HTTP-метод.

@Get(':id')         // правильно: /orders/:id
@Get('getAll')      // неправильно: глагол в пути
@Get('list.json')   // неправильно: расширение файла

Без финального слеша

NestJS не делает редирект с orders/ на orders. Если объявить путь со слешем — это будет отдельный роут, и без слеша вернётся 404.

@Get(':id')         // правильно: /orders/:id
@Get(':id/')        // неправильно: trailing slash

Коллекции и одиночные ресурсы

Коллекция — множественное число, одиночный ресурс — единственное. Правило простое: если по одному пути вернётся список — используйте множественное число.

@Controller('orders')    // коллекция — /orders, /orders/:id
@Controller('products')  // коллекция — /products, /products/:id
@Controller('order')     // неправильно для коллекции

Если ресурс всегда один на пользователя — единственное число:

@Controller('profile')   // один профиль текущего пользователя
export class ProfileController {
  @Get()                 // GET /api/v1/profile
  get() {}
}

Имя ресурса берите из предметной области: orders, а не purchases; customers, а не users. Когда слово из бизнеса совпадает с именем в коде — API понятен без дополнительных объяснений.

HTTP-методы

Каждый метод имеет чёткое назначение. Выбор метода — это часть контракта, который видит клиент.

@Controller('orders')
export class OrdersController {

  @Get()                       // GET /api/v1/orders → 200
  findAll() {}

  @Get(':id')                  // GET /api/v1/orders/:id → 200
  findOne(@Param('id') id: string) {}

  @Post()                      // POST /api/v1/orders → 201
  create(@Body() dto: CreateOrderDto) {}

  @Put(':id')                  // PUT /api/v1/orders/:id → 200
  replace(@Param('id') id: string, @Body() dto: ReplaceOrderDto) {}

  @Patch(':id')                // PATCH /api/v1/orders/:id → 200
  update(@Param('id') id: string, @Body() dto: UpdateOrderDto) {}

  @Delete(':id')
  @HttpCode(204)               // DELETE → 204 No Content
  remove(@Param('id') id: string) {}
}

На что обратить внимание:

  • POST при создании возвращает 201 Created — NestJS делает это автоматически.
  • DELETE по умолчанию в NestJS возвращает 200, но правильный ответ — 204 No Content. Нужно явно добавить @HttpCode(204).
  • GET не должен менять данные. Если нужна доменная команда (например, «отменить заказ»), используйте POST с именем действия в пути:
@Get(':id/cancel')   // неправильно: GET меняет состояние
cancel() {}

@Post(':id/cancel')  // правильно: команда через POST
@HttpCode(200)
cancel() {}

Вложенность ресурсов

Когда один ресурс логически принадлежит другому, это можно выразить в пути:

@Controller('orders')
export class OrdersController {

  @Get(':orderId/items')                // GET /api/v1/orders/:orderId/items
  getItems(@Param('orderId') orderId: string) {}

  @Get(':orderId/items/:itemId')        // GET /api/v1/orders/:orderId/items/:itemId
  getItem(
    @Param('orderId') orderId: string,
    @Param('itemId') itemId: string,
  ) {}
}

Параметры именуются уникально: orderId и itemId, а не оба id — это требование инструментов документации вроде Swagger.

Максимальная глубина — два уровня вложенности. Три уровня и глубже делают URL нечитаемым и сложным для кэширования. Вместо этого используйте плоский ресурс с фильтрацией:

// неправильно: три уровня
@Get(':userId/orders/:orderId/items/:itemId')

// правильно: плоский ресурс с параметром запроса
@Get()                                    // GET /api/v1/items?orderId=...
findAll(@Query('orderId') orderId: string) {}

Идентификатор ресурса передаётся в пути, не в теле запроса:

@Put(':id')                              // правильно
replace(@Param('id') id: string) {}

@Put()                                   // неправильно: id в body
replace(@Body() dto: { id: string }) {}

Коротко

  • Пути — kebab-case, строчные, без глаголов, без слеша в конце.
  • setGlobalPrefix('api') + VersioningType.URI дают /api/v1/... для всех контроллеров автоматически.
  • Служебные пути (/health, /ready) — вне глобального префикса.
  • Коллекции — множественное число (/orders), одиночный ресурс — единственное (/profile).
  • Имя ресурса — из предметной области, а не технические синонимы.
  • GET не меняет данные; доменные команды — через POST.
  • DELETE возвращает 204 No Content — нужен @HttpCode(204).
  • Максимум два уровня вложенности; глубже — плоский ресурс с фильтрацией.
  • Параметры в пути именуются уникально (orderId, itemId).

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

  • Версионирование — как устроен enableVersioning и переход между версиями.
  • Alias и Action-эндпоинты — me, latest, доменные команды в NestJS.
  • Query-параметры — DTO-классы, @Query, пагинация.
  • OpenAPI и антипаттерны — operationId, именование параметров.