Why CQRS

ПроблемаСценарий принимает пять параметров, и перепутать их в вызове ничто не мешает. Чтение при этом идёт через тот же агрегат: чтобы показать одну строку на экране, сервис собирает счёт целиком со всеми правилами, которые чтению не нужны.

Тот же сервис, что в главах про DDD: счёт, его модули и порты. Домен дальше не меняется — меняется то, как его зовут и как из него читают.

  1. Шаг 1 из 7

    Пять параметров и один агрегат

    Справа сценарий из главы про инфраструктуру. Он работает, и у него две беды.

    Первая видна прямо в подписи: параметров уже пять, а будет семь. Каждый вызов перечисляет их заново, перепутать два аргумента одного типа ничто не мешает, а передать этот вызов дальше — в очередь, в лог, в повтор — нельзя: у него нет формы.

    Вторая в подписи не видна, но она рядом. Чтобы показать счёт на экране, тот же код достаёт агрегат целиком: со строками, инвариантами и транзакцией. Для одной карточки в списке это дорого и незачем — чтению не нужно ничего из того, ради чего агрегат существует.

    Шаг 2 из 7

    Команда как тип

    Команда — это просьба изменить состояние, названная типом. Здесь она рядом со сценарием, в его же модуле: команду придумывает тот, кто её исполняет.

    Что это даёт сразу: у вызова появилась форма. Его можно положить в очередь, записать в лог, повторить после сбоя и сравнить с тем, что пришло, — раньше сравнивать было нечего.

    Validate проверяет форму, а не предметную область: номер не пустой, строк не ноль. Правила остались в домене, и это важно не перепутать — проверка формы отвечает на «можно ли это вообще прочитать», а не «бывает ли такой счёт».

    Шаг 3 из 7

    Хендлер команды

    Тот же файл, и в нём два параметра вместо пяти: ctx и команда. Сценарий стал хендлером: принял команду, проверил форму, отдал работу домену, сохранил, опубликовал факты. Транспорт изменился на одну строку: собирает команду и зовёт Handle.

    Порядок не изменился ни на шаг — изменилось то, чем его зовут. Именно поэтому CQRS начинается не с двух баз и не с шины, а с типа: команда отделяет «что просят» от «как это исполняется».

    Шаг 4 из 7

    Запрос и своя модель чтения

    Запрос живёт в своём модуле и не знает ни агрегата, ни портов записи. Он отдаёт Cardпроекцию под экран: номер, итог, состояние, признак просрочки.

    Это DTO, а не агрегат: ни инвариантов, ни методов, ни правил.

    Сама просьба тоже названа типом — CardQuery, ровно как команда на стороне записи. Пока в ней один номер, но по ней уже видно, что именно просят: такой запрос можно залогировать, закешировать по ключу, передать дальше. Добавится фильтр или страница — они лягут в этот тип, а не в шестой аргумент метода.

    Проверки формы у запроса нет, и это не забывчивость: команда меняет состояние, поэтому её форму проверяют до домена; запрос не меняет ничего, и худшее, что он может, — не найти карточку.

    Порт у чтения свой — Cards, он принимает запрос и отдаёт карточку готовой. За портом может стоять тот же Postgres с одним SELECT, materialized view или отдельная база — чтению всё равно, а домену тем более.

    Чего здесь нет — так это хендлера с Run, который только передавал бы вызов в порт. У чтения нет ни инвариантов, ни транзакции, ради которых такой слой существовал бы; он появляется, когда начинает что-то делать: проверять права, склеивать два источника, держать кеш.

    Ни одной строки домена этот модуль не трогает: он просто не может.

    Шаг 5 из 7

    Само чтение

    Вот и чтение: один SELECT и ни одного агрегата. Строки счёта не поднимаются, инварианты не проверяются, факты не записываются — читателю всё это не нужно.

    Колонки ложатся прямо в поля проекции, потому что запрос написан под экран, а не под модель. Признак просрочки считает сам запрос — грубо, по сроку и статусу; точное правило живёт в домене, и спрашивают его там, где решают, а не там, где показывают.

    Соединение берётся из поля, а не из ctx: транзакция чтению не нужна — нечего откатывать.

    За портом пока та же таблица, в которую пишет запись, и это нормальный первый шаг. Отдельная таблица под чтение, materialized view или своя база появятся, когда чтение начнёт мешать записи, — и код выше не изменится ни на строку: у него есть порт.

    Шаг 6 из 7

    Почему это разделение, а не два стиля

    Запись и чтение спрашивают у модели разное. Записи нужны инварианты и граница транзакции — то есть агрегат целиком. Чтению нужна форма ответа и скорость — то есть плоская таблица под конкретный экран.

    Одна модель обслуживает оба требования плохо: она либо тащит правила туда, где их не проверяют, либо расползается полями, которые нужны только экрану.

    Разделение стоит недорого: два типа и два порта, оба в своих модулях. За этой границей можно уехать сколько угодно далеко — отдельная база под чтение, обновление проекции по факту с шины, кеш. А можно не уезжать вовсе: та же база, тот же SELECT — разделение уже сделало главное.

  2. Шаг 7 из 7

    Что получилось

    Щёлкни по файлу, чтобы прочитать.

    • applications/issuing/command.go — команда и проверка её формы.
    • applications/issuing/issue.go — хендлер: ctx и команда.
    • applications/issuing/transport/http/handler.go — транспорт собирает команду.
    • applications/views/card.go — проекция под экран и порт чтения.
    • infrastructure/postgres/cards.go — само чтение: один запрос под этот экран.

    Домен не изменился ни на строку. Это и есть проверка: если разделение команд и запросов потребовало правок в агрегате, значит разделили не там.

Листать шаги можно стрелками ← и →.