CRUD-операций не всегда хватает. Клиенту нужен «текущий пользователь», «последнее развёртывание», «платёжный метод по умолчанию» — и это не фильтрация, а удобные сокращения. Отдельный класс запросов — доменные команды: подтвердить заказ, отменить, отправить. Для них есть два инструмента: alias-сегменты и action-эндпоинты.
Alias-сегменты: сокращения вместо идентификатора
me — текущий пользователь
Когда смотреть на профиль может и сам пользователь, и администратор с чужим ID, удобно использовать один маршрут GET /users/:id. Строка 'me' в этом случае работает как псевдоним: NestJS передаёт её в параметр id, контроллер проверяет значение и подставляет ID из токена.
@Controller('users')
@ApiTags('Users')
export class UsersController {
@Get(':id')
@ApiOperation({ operationId: 'getUser', summary: 'Get user by id or alias' })
findOne(@Param('id') id: string, @CurrentUser() actor: Actor) {
const resolvedId = id === 'me' ? actor.id : id;
return this.usersService.findOne(resolvedId);
}
}
Вопрос, который помогает понять нужен ли me: «может ли администратор обратиться к этому же эндпоинту по чужому ID?»
- Да → один маршрут
GET /users/:id,meкак alias в параметре. - Нет → отдельный singleton-ресурс
/profileбез alias.
Частая ошибка — заводить @Controller('me') как отдельный контроллер на верхнем уровне. Так делать не нужно: me — alias внутри UsersController, а не самостоятельный ресурс.
Ещё одна ошибка — использовать me там, где эндпоинт уже принадлежит текущему пользователю по контексту токена:
// Неверно: /users/me/orders избыточен, заказы и так привязаны к токену
@Get('users/me/orders')
// Верно: контроллер заказов, ID берётся из токена
@Controller('orders')
@Get()
findAll(@CurrentUser() user: Actor) {
return this.ordersService.findByCustomer(user.id);
}
latest, current, next — временны́е и порядковые alias
Здесь важен порядок объявления маршрутов. В NestJS строковый маршрут должен идти перед параметрическим — иначе 'latest' будет обработан как значение :id.
@Controller('deployments')
@ApiTags('Deployments')
export class DeploymentsController {
@Get('latest') // объявлен ДО ':id'
@ApiOperation({ operationId: 'getLatestDeployment', summary: 'Get latest deployment' })
findLatest() {
return this.deploymentsService.findLatest();
}
@Get(':id')
@ApiOperation({ operationId: 'getDeployment', summary: 'Get deployment by id' })
findOne(@Param('id') id: string) {
return this.deploymentsService.findOne(id);
}
}
Аналогичная конструкция для других alias:
@Get('current') // GET /api/v1/subscriptions/current
@Get('next') // GET /api/v1/invoices/next
@Get('previous') // GET /api/v1/billing-periods/previous
default, primary, active — логические alias
Когда среди ресурсов есть «главный» по бизнес-признаку, alias называет его явно:
@Controller('payment-methods')
@ApiTags('PaymentMethods')
export class PaymentMethodsController {
@Get('default')
@ApiOperation({ operationId: 'getDefaultPaymentMethod', summary: 'Get default payment method' })
findDefault(@CurrentUser() user: Actor) {
return this.paymentMethodsService.findDefault(user.id);
}
@Get(':id')
findOne(@Param('id') id: string) {}
}
Аналогично: 'primary', 'active', 'draft'.
Action-эндпоинты: доменные команды
Некоторые операции нельзя свести к CRUD. Подтверждение заказа — это не просто смена поля status, это доменное событие с бизнес-правилами. Для таких операций используют action-эндпоинты: @Post с глаголом в сегменте пути.
@Controller('orders')
@ApiTags('Orders')
export class OrdersController {
@Post(':id/confirm')
@HttpCode(200)
@ApiOperation({ operationId: 'confirmOrder', summary: 'Confirm order' })
confirm(@Param('id') id: string) {
return this.ordersService.confirm(id);
}
@Post(':id/cancel')
@HttpCode(200)
@ApiOperation({ operationId: 'cancelOrder', summary: 'Cancel order' })
cancel(
@Param('id') id: string,
@Body() dto: CancelOrderDto,
) {
return this.ordersService.cancel(id, dto.reason);
}
@Post(':id/ship')
@HttpCode(200)
@ApiOperation({ operationId: 'shipOrder', summary: 'Ship order' })
ship(
@Param('id') id: string,
@Body() dto: ShipOrderDto,
) {
return this.ordersService.ship(id, dto);
}
}
Несколько правил для action-эндпоинтов:
- Имя сегмента — глагол в инфинитиве:
confirm,cancel,ship,refund. Не существительное (confirmation), не причастие (confirmed). - Метод — всегда
@Post. Не@Put, не@Patch. - Код ответа —
@HttpCode(200). Действие выполнено, но ресурс не создан, поэтому 201 не подходит. - Тело запроса — в
@Body(). Если параметров нет, пустое тело допустимо. operationId— camelCase, относится к тегу родительского ресурса.
Пример DTO с параметрами действия:
export class ShipOrderDto {
@IsString()
trackingNumber: string;
@IsString()
carrier: string;
}
Когда action, а когда PATCH
Простой критерий: есть ли бизнес-правила или смена состояния через конечный автомат?
// Меняем description — нет бизнес-правил → PATCH
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateOrderDto) {}
// Меняем статус через конечный автомат → Action
@Post(':id/confirm')
@HttpCode(200)
confirm(@Param('id') id: string) {}
Action делает семантику видимой в логах: POST /orders/123/confirm понятнее, чем PATCH /orders/123 с { status: 'CONFIRMED' } в теле.
Коротко
me— alias в параметре':id', только когда эндпоинт доступен как «по своему ID», так и «по чужому» (например, для администратора).- Отдельный контроллер
@Controller('me')— неверный подход; alias живёт внутриUsersController. - Строковые маршруты (
'latest','default') объявляют до параметрических (':id') — иначе NestJS их не найдёт. - Action-эндпоинт —
@Post(':id/глагол')+@HttpCode(200). Метод@Patchостаётся для обновления полей без бизнес-правил. - Имя action-сегмента — глагол в инфинитиве; существительные и причастия — частая ошибка.
Что почитать дальше
- URL и ресурсы — формат пути, декораторы контроллеров.
- JSON и формат ответов — структура ответа для action.
- OpenAPI и антипаттерны —
operationIdдля action. - Версионирование — action в v2.