Опирается на правила: R-BATCH-1..5, R-ASYNC-1..4, R-LOC-1..3 и X-коды из REST API Style Guide → раздел Batch, async, локализация.

Важно знать

  • Batch обрабатывается поэлементно (partial success). Атомарность явно декларируется в документации.
  • Endpoint: POST /resources/batch или POST /resources/batch/<action>.
  • Request: { items: [...] }. Response: 200 OK + results + summary.
  • @HttpCode(200) обязателен на batch-endpoint — NestJS по умолчанию отдаёт 201 для @Post.
  • Async через polling: 202 Accepted + Location + taskId + statusUrl.
  • Статусы задачи: PENDING / PROCESSING / COMPLETED / FAILED.
  • При COMPLETED — поле resultUrl обязательно. При FAILED — поле error обязательно.
  • Локализация: Accept-Languagedetail и violations[].message. Enum-коды, title, type, имена JSON-полей — не локализуются.

Три сценария, не покрытых стандартным CRUD: массовые операции, длительные задачи, многоязычность. Каждый имеет стандартизованное решение — клиент знает, чего ожидать.

Batch-операции

R-BATCH-1..5: partial success, фиксированный формат запроса и ответа.

Endpoint и DTO

POST /api/v1/orders/batch                  ← batch для коллекции
POST /api/v1/notifications/batch/send      ← batch для action
export class BatchCreateOrderItemDto {
  @ApiProperty()
  @IsUUID()
  productId: string;

  @ApiProperty()
  @IsInt()
  @Min(1)
  quantity: number;
}

export class BatchCreateOrdersRequestDto {
  @ApiProperty({ type: [BatchCreateOrderItemDto] })
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => BatchCreateOrderItemDto)
  items: BatchCreateOrderItemDto[];
}
export class BatchItemResultDto {
  @ApiProperty()
  index: number;

  @ApiProperty({ enum: ['SUCCESS', 'ERROR'] })
  status: 'SUCCESS' | 'ERROR';

  orderId?: string;
  error?: { code: string; detail: string };
}

export class BatchSummaryDto {
  @ApiProperty() total: number;
  @ApiProperty() succeeded: number;
  @ApiProperty() failed: number;
}

export class BatchCreateOrdersResponseDto {
  @ApiProperty({ type: [BatchItemResultDto] })
  results: BatchItemResultDto[];

  @ApiProperty()
  summary: BatchSummaryDto;
}

Контроллер

@ApiTags('Orders')
@Controller('orders')
export class OrdersController {
  @Post('batch')
  @HttpCode(200)
  @ApiOperation({ operationId: 'batchCreateOrders', summary: 'Создание заказов batch' })
  @ApiResponse({ status: 200, type: BatchCreateOrdersResponseDto })
  @ApiResponse({ status: 400, description: 'BATCH_SIZE_EXCEEDED или VALIDATION_ERROR' })
  async batchCreate(
    @Body() dto: BatchCreateOrdersRequestDto,
  ): Promise<BatchCreateOrdersResponseDto> {
    return this.ordersService.batchCreate(dto.items);
  }
}

@HttpCode(200) обязателен — без него NestJS отдаёт 201 для @Post.

Request / Response

POST /api/v1/orders/batch
Content-Type: application/json

{
  "items": [
    { "productId": "c9f3...", "quantity": 2 },
    { "productId": "d1a8...", "quantity": 0 },
    { "productId": "e7b2...", "quantity": 1 }
  ]
}
HTTP/1.1 200 OK

{
  "results": [
    { "index": 0, "status": "SUCCESS", "orderId": "550e..." },
    { "index": 1, "status": "ERROR", "error": { "code": "INVALID_QUANTITY", "detail": "Количество должно быть больше нуля" } },
    { "index": 2, "status": "SUCCESS", "orderId": "6ba7..." }
  ],
  "summary": {
    "total": 3,
    "succeeded": 2,
    "failed": 1
  }
}

200 OK возвращается даже при частичной ошибке — это partial success, не HTTP-failure. index — позиция в исходном массиве (0-based). В error на уровне элемента — упрощённый объект { code, detail }, не полный ProblemDetails (полный ProblemDetails — для HTTP-уровня, не для элемента batch).

Атомарность (all-or-nothing) — не дефолт. Если нужна — явно декларируется в OpenAPI и документации:

@ApiOperation({
  summary: 'Атомарный batch резервирования на складе',
  description: 'Все позиции обрабатываются в одной транзакции. Ошибка любой позиции — откат всех.',
})

Размер batch и BATCH_SIZE_EXCEEDED

R-BATCH-4..5: максимальный размер фиксируется в документации.

const MAX_BATCH_SIZE = 100;

async batchCreate(items: BatchCreateOrderItemDto[]): Promise<BatchCreateOrdersResponseDto> {
  if (items.length > MAX_BATCH_SIZE) {
    throw new BadRequestException({
      type: 'urn:problem:order-service:batch-size-exceeded',
      status: 400,
      title: 'Bad Request',
      detail: `Размер batch превышает максимум (${MAX_BATCH_SIZE} элементов)`,
      code: 'BATCH_SIZE_EXCEEDED',
    });
  }
  // ...
}

Исключение попадёт в ProblemDetailsExceptionFilter — он выставит Content-Type: application/problem+json.

Async-операции

R-ASYNC-1..4: 202 Accepted + polling.

Для операций, которые не завершаются за время HTTP-запроса: генерация выписки Сбер за год, пересчёт остатков по продуктовому каталогу, массовый импорт клиентской базы.

Submit — DTO и контроллер

export class GenerateReportRequestDto {
  @ApiProperty({ example: '2026-01-01' })
  @IsDateString()
  dateFrom: string;

  @ApiProperty({ example: '2026-12-31' })
  @IsDateString()
  dateTo: string;
}

export class AsyncTaskResponseDto {
  @ApiProperty() taskId: string;
  @ApiProperty({ enum: ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'] })
  status: string;
  @ApiProperty() createdAt: string;
  @ApiProperty() statusUrl: string;
}
@Post('generate')
@HttpCode(202)
@ApiOperation({ operationId: 'generateReport', summary: 'Запуск генерации отчёта' })
@ApiResponse({ status: 202, type: AsyncTaskResponseDto, headers: { Location: { description: 'URL задачи' } } })
async generate(
  @Body() dto: GenerateReportRequestDto,
  @Res({ passthrough: true }) res: Response,
): Promise<AsyncTaskResponseDto> {
  const task = await this.reportsService.startGeneration(dto);
  const statusUrl = `/api/v1/tasks/${task.taskId}`;
  res.location(statusUrl);
  return {
    taskId: task.taskId,
    status: 'PENDING',
    createdAt: task.createdAt,
    statusUrl,
  };
}

@Res({ passthrough: true }) позволяет вернуть значение через return и при этом установить заголовок Location. Без passthrough: true NestJS передаёт управление ответом полностью в Express — return перестаёт работать.

Submit — request / response

POST /api/v1/reports/generate
Content-Type: application/json

{ "dateFrom": "2026-01-01", "dateTo": "2026-12-31" }

HTTP/1.1 202 Accepted
Location: /api/v1/tasks/550e8400-e29b-41d4-a716-446655440000

{
  "taskId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "PENDING",
  "createdAt": "2026-06-19T10:30:00Z",
  "statusUrl": "/api/v1/tasks/550e8400-e29b-41d4-a716-446655440000"
}

Polling — GET /tasks/{id}

@ApiTags('Tasks')
@Controller('tasks')
export class TasksController {
  @Get(':taskId')
  @ApiOperation({ operationId: 'getTask', summary: 'Статус задачи' })
  @ApiResponse({ status: 200, type: TaskStatusResponseDto })
  getTask(@Param('taskId', ParseUUIDPipe) taskId: string): Promise<TaskStatusResponseDto> {
    return this.tasksService.getTask(taskId);
  }
}
В процессе:
{
  "taskId": "550e8400-...",
  "status": "PROCESSING",
  "progress": 45,
  "createdAt": "2026-06-19T10:30:00Z"
}

Завершено:
{
  "taskId": "550e8400-...",
  "status": "COMPLETED",
  "progress": 100,
  "createdAt": "2026-06-19T10:30:00Z",
  "completedAt": "2026-06-19T10:35:00Z",
  "resultUrl": "/api/v1/reports/550e8400-..."
}

Ошибка:
{
  "taskId": "550e8400-...",
  "status": "FAILED",
  "createdAt": "2026-06-19T10:30:00Z",
  "completedAt": "2026-06-19T10:32:00Z",
  "error": {
    "code": "REPORT_GENERATION_FAILED",
    "detail": "Данные за указанный период отсутствуют"
  }
}

Статусы задачи

R-ASYNC-3..4:

СтатусЗначение
PENDINGсоздана, ожидает обработки
PROCESSINGвыполняется
COMPLETEDзавершена; resultUrl обязателен
FAILEDзавершена с ошибкой; error обязателен

Период опроса клиент выбирает сам. Сервер может вернуть Retry-After в ответе на polling с рекомендацией.

Локализация

R-LOC-1..3: через Accept-Language, реализация через nestjs-i18n.

Подключение nestjs-i18n

import { I18nModule, AcceptLanguageResolver } from 'nestjs-i18n';

@Module({
  imports: [
    I18nModule.forRoot({
      fallbackLanguage: 'ru',
      loaderOptions: { path: join(__dirname, '/i18n/'), watch: true },
      resolvers: [AcceptLanguageResolver],
    }),
  ],
})
export class AppModule {}

fallbackLanguage: 'ru' — если Accept-Language отсутствует или неизвестен, используется ru.

Локализация detail в ошибках

@Catch(OrderNotFoundException)
export class OrderNotFoundFilter implements ExceptionFilter {
  constructor(private readonly i18n: I18nService) {}

  catch(exception: OrderNotFoundException, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const lang = request.headers['accept-language'] ?? 'ru';

    const detail = this.i18n.t('errors.ORDER_NOT_FOUND', { lang });

    response.status(404).contentType('application/problem+json').json({
      type: 'urn:problem:order-service:order-not-found',
      status: 404,
      title: 'Not Found',
      detail,
      code: 'ORDER_NOT_FOUND',
    });
  }
}

Файлы переводов:

// i18n/ru/errors.json
{ "ORDER_NOT_FOUND": "Заказ не найден" }

// i18n/en/errors.json
{ "ORDER_NOT_FOUND": "Order not found" }

Локализация violations при валидации

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
    exceptionFactory: (errors) => {
      const violations = errors.map((e) => ({
        field: e.property,
        code: Object.keys(e.constraints ?? {})[0]?.toUpperCase() ?? 'INVALID',
        message: Object.values(e.constraints ?? {})[0] ?? '',
      }));
      throw new UnprocessableEntityException({ violations });
    },
  }),
);

Для локализованных сообщений class-validator поддерживает кастомные message-функции; nestjs-i18n предоставляет декоратор @i18nValidate*. message в violations локализуется, code — нет.

Что не локализуется

R-LOC-X1: машиночитаемые идентификаторы всегда на английском.

// Нарушение R-LOC-X1
{ "code": "ЗАКАЗ_НЕ_НАЙДЕН" }

// Правильно
{ "code": "ORDER_NOT_FOUND", "detail": "Заказ не найден" }
// Нарушение R-LOC-X1
{ "type": "urn:problem:order-service:заказ-не-найден" }

// Правильно
{ "type": "urn:problem:order-service:order-not-found" }

Поля code, title, type используются в клиентском коде (switch (error.code)) — они не зависят от языка пользователя. Локализуется только то, что показывается пользователю: detail и violations[].message.

Что запрещено

АнтипаттернПравилоЧто взамен
Batch без @HttpCode(200) на @PostR-BATCH-3явный @HttpCode(200)
Batch atomicity по умолчанию без документированияR-BATCH-3partial success + явная декларация атомарности
4xx на partial failure batchR-BATCH-3200 + per-item status: ERROR
Batch без summaryR-BATCH-3{ total, succeeded, failed }
Batch без ограничения максимального размераR-BATCH-4BATCH_SIZE_EXCEEDED при превышении
202 без заголовка LocationR-ASYNC-1res.location(statusUrl) обязателен
COMPLETED без resultUrlR-ASYNC-4обязательное поле при финальном статусе
FAILED без errorR-ASYNC-4обязательное поле при финальном статусе
Accept-Language игнорируетсяR-LOC-1nestjs-i18n с AcceptLanguageResolver
fallbackLanguage не заданR-LOC-2fallbackLanguage: 'ru' в I18nModule
code: "ЗАКАЗ_НЕ_НАЙДЕН"R-LOC-X1только UPPER_SNAKE_CASE на английском
Локализованный title в ProblemDetailsR-LOC-X1стандартное название HTTP-статуса на английском
Локализованные имена JSON-полейR-LOC-X1camelCase на английском

Куда дальше

  • node/errors.md — ProblemDetails через Exception Filters, exceptionFactory для ValidationPipe.
  • node/headers.md — Idempotency-Key для batch, Location и traceparent.
  • node/json-and-responses.md — content + метаданные пагинации, undefined вместо null.
  • node/rate-limiting-files-deprecation.md — @nestjs/throttler, StreamableFile, Sunset.
  • node/url-and-resources.md — kebab-case, setGlobalPrefix, URI-версионирование.
  • node/alias-and-actions.md — action-эндпоинты @Post('orders/:id/confirm').
  • Resilience → async polling — task-queue реализация на Node.