⟨/⟩ 40_Arch/Tech/CSharp/ADR_SRV_001_Cqrs.cs

227 строк · в начало

using System;
using Ban.Sdaid.Notation.Documents;
using Ban.Sdaid.Icd;

namespace Ban.Sdaid.Icd.Arch.Tech.CSharp
{
    /// <summary>
    /// ADR-SRV-001: CQRS-архитектура бэкенда и единая обёртка ответа. Фиксирует, как
    /// организованы операции над доменом (команды и запросы), как встраивается сквозная
    /// логика (pipeline) и какой контракт результата возвращают все ручки.
    /// </summary>
    public class ADR_SRV_001_Cqrs : IAdrDocument, IFolder<ArchCSharpFolder>
    {
        public string Name => "ADR-SRV-001. CQRS и обёртка ответа";

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

        public string Version => "1.0";
        public string Status => "accepted";
        public string[] Comments => new[]
        {
            "2026-08-12. Принято перед первым реальным эндпоинтом (справочники ввода). Read-ветка уже реализована каркасом; command-ветка, pipeline и БД — целевые, вводятся под первый write-срез.",
        };

        public Type? Supersedes => null;

        public static string S1_Context = """
            ## Контекст и постановка задачи

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

            - код был однороден: операция = предсказуемый набор файлов в предсказуемом месте;
            - сквозную логику (логирование, трассировку, будущую валидацию) можно было встроить, не трогая
              обработчики;
            - чтение и запись опирались на разные контексты данных (read-only без трекинга vs write);
            - контроллер оставался тонким: принял запрос → отдал обработчику → вернул результат;
            - **форма ответа была единой** у всех ручек — успех/ошибки/сообщение в одном контракте, который
              одинаково разворачивает фронт и его сгенерированный клиент.

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

        public static string S2_DecisionDrivers = """
            ## Драйверы решения

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

        public static string S3_ConsideredOptions = """
            ## Рассмотренные варианты

            - **A. Свой лёгкий CQRS** — интерфейсы запроса/команды + процессоры + pipeline в отдельном
              проекте `Ban.Icd.Cqrs`. Полный контроль, нет внешних зависимостей.
            - **B. MediatR** — готовый медиатор с `IPipelineBehavior`. Экосистема есть, но лишняя
              зависимость и чужие абстракции ради того, что делается парой интерфейсов.
            - **C. Без абстракций** — контроллер вызывает сервисы напрямую. Быстро на старте, но теряется
              однородность и негде встроить сквозную логику.
            """;

        public static string S4_DecisionOutcome = """
            ## Решение

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

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

            ```
            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, контексты, единица работы
            ```

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

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

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

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

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

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

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

            ```csharp
            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 их не переводит).

            ### Тонкий контроллер

            ```csharp
            [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.

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

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

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

            ### Что не делаем

            - Не тащим внешний медиатор.
            - Не кладём бизнес-логику в контроллер.
            - Не возвращаем «голые» DTO мимо обёртки — контракт ответа один на все ручки.
            """;

        public static string S5_PositiveConsequences = """
            ## Положительные следствия

            - Новый обработчик = один файл, регистрируется автоматически.
            - Сквозная логика (логирование, трассировка, валидация) добавляется pipeline-шагом, не трогая
              обработчики.
            - Разделение чтения и записи: запросы — read-only без трекинга, команды — write через единицу
              работы с авто-`SaveChanges` при успехе.
            - Единая обёртка — предсказуемая обработка успеха/ошибок на фронте и в генерируемом клиенте.
            """;

        public static string S6_NegativeConsequences = """
            ## Отрицательные следствия и компромиссы

            - Свои процессоры и pipeline — нет готовой экосистемы (в отличие от медиатора).
            - Обобщённые pipeline-шаги (open generics) усложняют DI-регистрацию.
            - Обёртка добавляет уровень вложенности (`payload`) даже там, где хватило бы «голого» ответа —
              принимаем ради единообразия контракта.
            """;

        public static string S7_Validation = """
            ## Проверка

            - Первый контроллер с обработчиком компилируется и отвечает без ручной регистрации в `Program.cs`.
            - Все результаты наследуют `IcdBaseRequestResult`; ответ содержит `payload`/`isSuccess`/`errors`.
            - Добавление обработчика не требует правок `Program.cs`.
            """;

        public static string S8_OpenQuestions = """
            ## Открытые вопросы / отложено

            - **Единица работы (IUnitOfWork)** и **write/read-контексты** — вводятся с БД под первый write-срез.
            - **Pipeline-шаги** (логирование, затем валидация) — добавляются, когда появится сквозной концерн.
            - **RequestId/трассировка** — поле зарезервировано; заполняется pipeline-шагом при появлении
              сквозного логирования/OpenTelemetry.
            - **Коды ошибок (ErrorCode)** — состав enum фиксируется по мере появления ошибочных сценариев.
            """;

        public static string S99_Related = $"""
            ## Связанные артефакты

            - {nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_002_GridQueryContract)} — контракт grid-запросов поверх этой обёртки.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_003_EntityAndEfMapping)} — правила сущностей и EF для write-ветки.
            """;
    }
}