ADR-SRV-001. CQRS и обёртка ответа
Бэкенд СОК реализует операции над доменом (ввод, карты, документы, обработка). Операции делятся на команды (меняют состояние) и запросы (читают). Фиксируем единый способ их организации: свой лёгкий CQRS без внешних зависимостей, тонкий контроллер → процессор → обработчик, авто-регистрация обработчиков, место для сквозной логики (pipeline) и единую обёртку ответа для всех ручек.
1. Контекст и постановка задачи
Каждая операция бэкенда — это либо команда (создать/изменить/удалить, поставить пакет в очередь), либо запрос (справочник, список, карточка). Нужен один способ их организации, чтобы:
- код был однороден: операция = предсказуемый набор файлов в предсказуемом месте;
- сквозную логику (логирование, трассировку, будущую валидацию) можно было встроить, не трогая обработчики;
- чтение и запись опирались на разные контексты данных (read-only без трекинга vs write);
- контроллер оставался тонким: принял запрос → отдал обработчику → вернул результат;
- форма ответа была единой у всех ручек — успех/ошибки/сообщение в одном контракте, который одинаково разворачивает фронт и его сгенерированный клиент.
Текущее состояние — каркас: реализована только read-ветка (Ban.Icd.Cqrs: процессор запросов и
авто-регистрация обработчиков), без pipeline-шагов; command-ветка, БД/EF и разделение
read/write-контекстов ещё не введены.
2. Драйверы решения
- Однородность — каждая операция живёт в отдельной папке, структура повторяется.
- Сквозная логика без правки обработчиков — логирование/трассировка/валидация как шаги pipeline вокруг обработчика.
- Разделение чтения и записи — запросы на read-only контексте (no tracking), команды — на write через единицу работы.
- Тонкий контроллер — не содержит бизнес-логики, делегирует процессору.
- Минимум зависимостей — без внешнего медиатора; контроль над процессором и pipeline у нас.
- Единый результат — все ручки возвращают один базовый контракт ответа.
3. Рассмотренные варианты
- A. Свой лёгкий CQRS — интерфейсы запроса/команды + процессоры + pipeline в отдельном
проекте
Ban.Icd.Cqrs. Полный контроль, нет внешних зависимостей. - B. MediatR — готовый медиатор с
IPipelineBehavior. Экосистема есть, но лишняя зависимость и чужие абстракции ради того, что делается парой интерфейсов. - C. Без абстракций — контроллер вызывает сервисы напрямую. Быстро на старте, но теряется однородность и негде встроить сквозную логику.
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. Что не делаем
- Не тащим внешний медиатор.
- Не кладём бизнес-логику в контроллер.
- Не возвращаем «голые» DTO мимо обёртки — контракт ответа один на все ручки.
5. Положительные следствия
- Новый обработчик = один файл, регистрируется автоматически.
- Сквозная логика (логирование, трассировка, валидация) добавляется pipeline-шагом, не трогая обработчики.
- Разделение чтения и записи: запросы — read-only без трекинга, команды — write через единицу
работы с авто-
SaveChangesпри успехе. - Единая обёртка — предсказуемая обработка успеха/ошибок на фронте и в генерируемом клиенте.
6. Отрицательные следствия и компромиссы
- Свои процессоры и pipeline — нет готовой экосистемы (в отличие от медиатора).
- Обобщённые pipeline-шаги (open generics) усложняют DI-регистрацию.
- Обёртка добавляет уровень вложенности (
payload) даже там, где хватило бы «голого» ответа — принимаем ради единообразия контракта.
7. Проверка
- Первый контроллер с обработчиком компилируется и отвечает без ручной регистрации в
Program.cs. - Все результаты наследуют
IcdBaseRequestResult; ответ содержитpayload/isSuccess/errors. - Добавление обработчика не требует правок
Program.cs.
8. Открытые вопросы / отложено
- Единица работы (IUnitOfWork) и write/read-контексты — вводятся с БД под первый write-срез.
- Pipeline-шаги (логирование, затем валидация) — добавляются, когда появится сквозной концерн.
- RequestId/трассировка — поле зарезервировано; заполняется pipeline-шагом при появлении сквозного логирования/OpenTelemetry.
- Коды ошибок (ErrorCode) — состав enum фиксируется по мере появления ошибочных сценариев.