Command-query split
Модель записи и модель чтения — разные артефакты. CQS на уровне методов, CQRS на уровне архитектуры. Commands выражают намерение и могут быть отклонены; queries не имеют побочных эффектов. Разделение начинается с двух объектных моделей, а не двух баз данных.
В доменной области заказов B2B-платформы была единственная модель Order — нормализованный, тщательно охраняемый aggregate. Она применяла три инварианта: суммарная стоимость позиций поданного заказа не должна превышать кредитный лимит клиента; заказ нельзя изменить после одобрения; условия оплаты должны соответствовать уровню контракта клиента. Команда гордилась моделью. Каждая запись шла через aggregate root. Инварианты держались. Потом продуктовая команда запросила новый дашборд заказов — экран для клиента, показывающий все заказы компании со статусом, суммой, именем аккаунт-менеджера, последним платёжным событием и признаком просрочки. Запрос затрагивал семь таблиц: orders, line_items, customers, contracts, account_managers, payments и billing_events. ORM загружал полный граф aggregate’а для каждого заказа на экране, затем выбрасывал 80% данных при рендере. Время ответа дашборда составило 3,4 секунды. Команда пробовала добавлять индексы, денормализовывать колонки, кешировать ответ. Каждое исправление делало модель записи немного хуже: денормализованные колонки нужно было синхронизировать; логика инвалидации кеша просачивалась в command handlers. Настоящая проблема была структурной: одну модель просили служить двум несовместимым целям. Для записей модель должна быть нормализованной, охраняющей инварианты, aggregate-образной. Для чтений — денормализованной, view-образной, объединённой по многим сущностям. Ни одна модель не может хорошо делать и то, и другое. Это противоречие — одна модель, два несовместимых назначения — и разрешает CQRS.
CQS: принцип за паттерном
CQRS строится на более простой идее Бертрана Мейера — Command-Query Separation (CQS). Принцип гласит: метод должен либо изменять состояние (command), либо возвращать значение (query), но не делать оба одновременно. Метод, который возвращает значение и при этом имеет побочный эффект, нарушает CQS — вызывающий код не может безопасно вызвать его несколько раз, и возвращаемое значение больше нельзя считать чистым.
На уровне объекта:
order.submit()— command, изменяет состояние, ничего не возвращает (или доменное событие)order.getTotal()— query, читает состояние, возвращает значение, ничего не изменяет
Ценность CQS на уровне объекта — ясность: query-метод можно вызвать без страха побочных эффектов. Вы знаете, что command не вернёт данные, от которых зависит читающий код.
CQRS берёт то же разделение и масштабирует его до архитектурного уровня: вместо одного объекта с command-методами и query-методами — две отдельные модели: одна для записей, другая для чтений.
Почему одна модель не может служить двум целям
Write model формируется инвариантами. Aggregate Order нормализован, потому что инварианты должны проверяться над согласованным минимальным набором данных. Если денормализовать — хранить имя аккаунт-менеджера в строке заказа — то каждое изменение имени в account_managers должно распространяться на каждый заказ. Aggregate теперь должен защищать два инварианта: бизнес-инварианты о заказах и инвариант ссылочной целостности денормализованных данных. Эти инварианты конфликтуют друг с другом.
Read model формируется view. Запрос дашборда нуждается в имени аккаунт-менеджера, последнем платёжном событии и признаке просрочки в одном быстром обращении. Никакой инвариант эти поля не охраняет — они просто должны быть корректными и быстрыми. Правильная структура — предварительно объединённая, денормализованная строка, которую query может вернуть напрямую без загрузки полного графа aggregate’а.
Структурная несовместимость реальна: нормализация служит записям; денормализация служит чтениям. Когда их заставляют сосуществовать в одной модели, каждая деградирует другую.
▸Why this works
Это прежде всего не оптимизация производительности. Это наблюдение о моделировании. Нормализованный aggregate — правильная форма для транзакционного применения инвариантов. Денормализованный view — правильная форма для эффективных ответов на вопросы. Попытка заставить одну структуру служить обоим назначениям означает, что она не имеет правильной формы ни для одного из них. CQRS — это не «добавь read-реплику»: это решение о моделировании, которое говорит, что забота о записях и забота о чтениях имеют разные структурные требования и должны моделироваться отдельно.
Commands: намерение, а не CRUD
Command в CQRS — это не обновление записи. Это выражение намерения внешнего мира по отношению к домену. SubmitOrder — это command. ApproveOrderForShipping — это command. RejectLineItemNegotiation — это command. Эти имена берутся из языка домена (см. юнит 06 по DDD и ubiquitous language).
Commands могут быть отклонены. Когда приходит command SubmitOrder и сумма заказа превышает кредитный лимит клиента, write model отклоняет его. Command не провалился молча — он был оценён по инварианту и явно отказан. Command handler возвращает доменное событие при успехе или доменную ошибку при отклонении.
Это отличается от PATCH /orders/:id. PATCH ничего не говорит о намерении. Command говорит точно, что актор намерен сделать, и почему домен может отказать.
Commands также делают ответственность write model яснее. Write model обрабатывает commands. Она не отвечает на queries. Её работа — получить выражение намерения, проверить инварианты, мутировать состояние если инварианты выполнены, и сгенерировать события. Это вся работа.
Queries: без побочных эффектов, view-образные
Query в CQRS — это вопрос. GetOrderDashboard(customerId), GetOrderSummaryForApproval(orderId), ListOverdueOrdersForAccountManager(managerId) — queries. Они возвращают данные. Они не изменяют состояние. Их можно безопасно вызывать несколько раз.
Поскольку queries не имеют побочных эффектов, им не нужно проходить через инвариантный механизм write model. Они читают из read model напрямую — предварительно построенной, view-образной структуры данных, оптимизированной для конкретного задаваемого вопроса.
Разные queries могут иметь разные read models. Запрос дашборда и запрос сводки для одобрения обслуживают разные view с разными потребностями в данных. CQRS позволяет каждому query опираться на наиболее эффективную read model для его конкретной формы.
▸lesson.inset.note
Отдельные модели не означают отдельные базы данных — по крайней мере, не изначально. В простейшей реализации CQRS write model и read model — это два разных в-процессных графа объектов, читающих из одной базы данных. Write model читает нормализованные строки и применяет инварианты; read model выполняет другой, оптимизированный под view запрос к той же базе данных, возможно используя database view или запрос, возвращающий плоский DTO. Отдельные базы данных — это необязательный инфраструктурный шаг, актуальный когда требования к масштабированию или задержке оправдывают его. Многие команды применяют CQRS на уровне модели и никогда не доходят до точки, где нужны отдельные хранилища.
Разработчик говорит: «Мы реализовали CQRS, добавив read-реплику базы данных. Commands идут на primary, queries — на реплику». Применён ли CQRS корректно?
В принципе CQS метод, возвращающий значение И изменяющий состояние, нарушает CQS. Почему это структурная проблема, а не просто стилистическое предпочтение?
Команда применяет CQRS к домену заказов. Младший инженер предлагает: «Command handler SubmitOrder должен возвращать полный Order DTO, чтобы UI мог сразу обновить экран». Каков CQRS-ориентированный ответ?
- 01Какова структурная несовместимость, из-за которой одна модель плохо служит и записям, и чтениям?
- 02В чём разница между command и query в CQRS, и почему commands могут быть отклонены?
- 03Требует ли CQRS отдельных баз данных для write- и read-сторон?
3,4-секундный дашборд из вступительной истории — это не проблема отсутствующего индекса. Это несоответствие моделирования: один aggregate, сформированный для охраны инвариантов, использовался для обслуживания view, которому нужны были денормализованные данные из семи таблиц. Структурное противоречие между нормализацией (записи нуждаются в ней) и денормализацией (чтения нуждаются в ней) — это то, что CQRS называет и разрешает.
CQS — принцип Мейера — разделяет command- и query-методы на одном объекте. CQRS масштабирует это разделение до архитектурного уровня: две отдельные модели, по одной для каждой заботы. Write model нормализована, охраняет инварианты, aggregate-образна. Read model денормализована, view-образна, оптимизирована под запросы. Commands выражают намерение и могут быть отклонены. Queries отвечают на вопросы и не имеют побочных эффектов.
Разделение — это решение о моделировании, а не развёртывание инфраструктуры. Отдельные базы данных необязательны. Команда может реализовать CQRS двумя графами объектов, читающими одну базу данных, и получить большую часть структурной выгоды.
Следующий урок рассматривает read-сторону подробно: как денормализованные read models строятся путём проецирования с write-стороны, какую проблему синхронизации это создаёт и как ограничить устаревание данных.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.