ADR-SRV-001. CQRS и обёртка ответа

ADR Версия: 1.0 accepted

Бэкенд СОК реализует операции над доменом (ввод, карты, документы, обработка). Операции делятся на команды (меняют состояние) и запросы (читают). Фиксируем единый способ их организации: свой лёгкий CQRS без внешних зависимостей, тонкий контроллер → процессор → обработчик, авто-регистрация обработчиков, место для сквозной логики (pipeline) и единую обёртку ответа для всех ручек.

⟨/⟩ Исходник

1. Контекст и постановка задачи

Каждая операция бэкенда — это либо команда (создать/изменить/удалить, поставить пакет в очередь), либо запрос (справочник, список, карточка). Нужен один способ их организации, чтобы:

Текущее состояние — каркас: реализована только read-ветка (Ban.Icd.Cqrs: процессор запросов и авто-регистрация обработчиков), без pipeline-шагов; command-ветка, БД/EF и разделение read/write-контекстов ещё не введены.

2. Драйверы решения

  1. Однородность — каждая операция живёт в отдельной папке, структура повторяется.
  2. Сквозная логика без правки обработчиков — логирование/трассировка/валидация как шаги pipeline вокруг обработчика.
  3. Разделение чтения и записи — запросы на read-only контексте (no tracking), команды — на write через единицу работы.
  4. Тонкий контроллер — не содержит бизнес-логики, делегирует процессору.
  5. Минимум зависимостей — без внешнего медиатора; контроль над процессором и pipeline у нас.
  6. Единый результат — все ручки возвращают один базовый контракт ответа.

3. Рассмотренные варианты

4. Решение

Выбран Вариант A — свой лёгкий CQRS. Нет внешних зависимостей, процессор и pipeline полностью под нашим контролем, контракт единообразен.

4.1. Раскладка проектов

src-api/
  Ban.Icd.Cqrs/              ← контракты CQRS и процессоры (без ссылок на Api/Domain)
    Queries/Abstracts/        ← IIcdQuery, IIcdQueryHandler, IIcdQueryResult, IIcdQueryProcessor
    Queries/                  ← IcdQueryProcessor; (позже) IIcdQueryPipelineStep + шаги
    Commands/…                ← (целевое) IIcdCommand, IIcdCommandHandler, IIcdCommandResult,
                                 IIcdCommandProcessor, IIcdCommandPipelineStep
  Ban.Icd.Api/
    Application/
      CommonModels/           ← IcdBaseRequestResult, ErrorDetails, (grid) PagingModel/SortingModel
      Abstracts/              ← (grid) IcdBaseGridQuery, IcdBaseGridQueryResult
      Basement/               ← IcdBaseQueryHandler / IcdBaseCommandHandler, ApiException
      Extensions/, Helpers/   ← QueryableExtensions, SortingApplier
      <Area>/Queries/<Name>/  ← Query + (UserFilter) + Result + Handler
      <Area>/Commands/<Name>/ ← Command + Result + Handler
  Ban.Icd.Domain/            ← доменные сущности (спека 50_Domain)
  Ban.Icd.Infrastructure/    ← инфраструктура; (позже) EF, контексты, единица работы

4.2. Контракты read-ветки (реализованы)

IIcdQuery<TResult>                      маркер запроса
IIcdQueryResult                         маркер результата (bool IsSuccess)
IIcdQueryHandler<TQuery,TResult>        .HandleAsync(query, ct)
IIcdQueryProcessor                      .ProcessQueryAsync<TQuery,TResult>(query, ct)

Процессор резолвит обработчик под пару (запрос, результат) из DI и делегирует ему; контроллеры зависят от процессора, а не от конкретных обработчиков (через него же пройдёт pipeline).

4.3. Контракты command-ветки (целевое, вводятся под первый write-срез)

IIcdCommand<TResult> / IIcdCommandResult / IIcdCommandHandler / IIcdCommandProcessor
IcdBaseCommandHandler   ← общий базовый обработчик: авто-SaveChanges при IsSuccess

4.4. Единая обёртка ответа

Все результаты (и запросов, и команд) наследуют один базовый контракт:

public record IcdBaseRequestResult : IIcdQueryResult, IIcdCommandResult
{
    public Guid? RequestId { get; init; }               // для трассировки (пока резерв)
    public bool IsSuccess => Errors.Length == 0;        // успех = нет ошибок
    public int StatusCode { get; init; } = 200;         // HTTP-код операции
    public string? Message { get; init; }               // сопроводительное сообщение
    public ErrorDetails[] Errors { get; init; } = [];   // детализация ошибок
}

public record ErrorDetails
{
    public string? ErrorData { get; init; }             // поле/контекст ошибки
    public ErrorCode ErrorCode { get; init; }           // код ошибки (enum)
    public string? ErrorMessage { get; init; }          // текст (по-русски, от бэка)
}

Полезная нагрузка кладётся в поле Payload конкретного результата — фронт единообразно разворачивает payload и показывает errors (тексты ошибок формирует бэк, UI их не переводит).

4.5. Тонкий контроллер

[ApiController]
[Route("api/[controller]")]
public sealed class InputPacketController(IIcdQueryProcessor processor) : ControllerBase
{
    [HttpGet]
    public Task<GetInputQueueQueryResult> Get(CancellationToken ct)
        => processor.ProcessQueryAsync<GetInputQueueQuery, GetInputQueueQueryResult>(
            new GetInputQueueQuery(), ct);
}

Запросы без вложенных параметров — GET; grid-запросы с вложенными Paging/Sorting/UserFilter принимают тело через POST (см. ADR-SRV-002), т. к. вложенные объекты не ложатся в query string.

4.6. Регистрация в DI (авто-обнаружение)

builder.Services
    .RegisterQueryProcessor()
    .RegisterQueryHandlers(executingAssembly);   // все IIcdQueryHandler<,> из сборки
// (целевое) то же для команд: RegisterCommandProcessor/Pipeline/Handlers

Новый обработчик подхватывается сканированием сборки — Program.cs править не нужно.

4.7. Что не делаем

5. Положительные следствия

6. Отрицательные следствия и компромиссы

7. Проверка

8. Открытые вопросы / отложено

Связанные артефакты

Документы