<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
КОНЦЕПЦІЯ DDD · КРОК 9 З 10

CQRS

Код, що змінює дані, і код, що їх читає, мають різні моделі. Запис іде через агрегати й правила, читання через запити, які одразу віддають готові дані для екрана.

6 хв читання Middle → Senior
01 · ЩО ЦЕ

Читати й писати різними шляхами

Бертран Меєр сформулював принцип : метод або змінює стан, або повертає дані, але не робить обидві речі одразу. Грег Янг переніс цю ідею з методів на модель застосунку. ділить код на і , і кожен будується під свою задачу.

СЛОВНИКCQS. Command Query Separation: метод або команда, що змінює стан, або запит, що повертає дані без побічних ефектів.
СЛОВНИКCQRS. Command Query Responsibility Segregation: окремі моделі для запису й читання в межах застосунку.
СЛОВНИКБік запису. Сценарії, агрегати й репозиторії. Тут перевіряються правила й живуть транзакції.
СЛОВНИКБік читання. Query-сервіси, які віддають DTO для екрана. Нічого не змінюють і правил не перевіряють.

Бік запису вантажить агрегат, перевіряє інваріанти й зберігає. Бік читання агрегатів може не знати зовсім: картці каталогу потрібні назва вакансії, компанія й вилка, і їх дає один SQL-запит з join, без мапінгу в доменні обʼєкти. Коли читання стає важким, для нього будують окрему : таблицю чи view, яку оновлюють після запису.

СЛОВНИКRead model. Дані, підготовлені саме для читання: окрема таблиця, materialized view чи індекс пошуку. Оновлюються після запису.

Двох баз, шини подій чи event sourcing CQRS не вимагає. Найпростіша форма живе в одній базі: різні класи для читання й запису над тими самими таблицями. Окреме сховище для читання пришвидшує екрани, але приносить : якийсь час читання показує старі дані.

СЛОВНИКEventual consistency. Стан, коли read model трохи відстає від запису й наздоганяє його згодом.
02 · МЕЖА

Два шляхи до однієї таблиці

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
ЗАПИСsrc/Hiring: сценарії й доменЧИТАННЯsrc/Hiring/Application/CatalognewсценарійSyncJobsFromSourceагрегатJob«interface»JobRepositorysave()контролерJobsController«interface»JobCatalogpage():CatalogPagevacanciesPostgreSQL
ЗАПИСsrc/Hiring: сценарії й доменЧИТАННЯsrc/Hiring/Application/CatalogсценарійSyncJobsFromSourceагрегатJob«interface»JobRepositorysave()контролерJobsController«interface»JobCatalogpage():CatalogPagevacanciesPostgreSQL
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Домен Інфраструктура Клієнт
  • Запис (src/Hiring: сценарії й домен) містить: SyncJobsFromSource, Job, JobRepository.
  • Читання (src/Hiring/Application/Catalog) містить: JobsController, JobCatalog, CatalogPage.
  • SyncJobsFromSource використовує Job.
  • SyncJobsFromSource використовує JobRepository.
  • JobsController використовує JobCatalog.
  • JobCatalog створює CatalogPage.
  • JobRepository використовує vacancies.
  • JobCatalog використовує vacancies.
  1. 1Запис іде через агрегат і репозиторій, читання через query-сервіс, який віддає DTO.
  2. 2Query-сервіс нічого не змінює. Якщо щось треба записати, це окремий метод чи сценарій.
  3. 3Інтерфейс читання живе в шарі застосунку, реалізація з SQL в інфраструктурі.
  4. 4Окрему read model вводять тоді, коли запит до основних таблиць уже не встигає.
03 · ЯК У PHP UKRAINE

Бік читання каталогу

Інтерфейс читання каталогу живе в src/Hiring/Application/Catalog, а EloquentJobCatalog в інфраструктурі реалізує його запитами до бази й одразу повертає картки. Пише в ту саму таблицю vacancies JobRepository з агрегатом Job. За тим самим принципом зроблено JobPageQuery для сторінки вакансії, CompanyDirectory, SalaryReportQuery та інші. Окрема read model є одна: щоденний знімок ринку в таблиці market_snapshots, який команда hiring:snapshot рахує за розкладом. Поки що сторінка методології бере з нього лише дату першого знімка, а історію накопичують для графіків трендів. Межа не скрізь чиста: JobPageQuery має метод recordView(), що збільшує лічильник переглядів, тобто запис в інтерфейсі читання.

php JobCatalog.php фрагмент
1/**
2 * Read side of the catalog. Implemented directly over the database; no aggregate mapping on reads.← без агрегатів
3 */
4interface JobCatalog
5{
6 public function count(JobFilter $filter): int;
7
8 public function countPublishedSince(\DateTimeImmutable $since): int;
9
10 /**
11 * When the boards were last read successfully, or null before the first sync.
12 */
13 public function lastUpdatedAt(): ?\DateTimeImmutable;
14
15 public function page(JobFilter $filter, int $page, int $perPage = 20): CatalogPage;← DTO для екрана
16
17 public function facets(JobFilter $filter): Facets;← лічильники чипів
18
19 /**
20 * Visible vacancies by id, in the given order; unknown or closed ids are simply absent.
21 *
22 * @param list<int> $ids
23 * @return list<JobCard>
24 */
25 public function byIds(array $ids): array;
26}
04 · ТИПОВІ ПОМИЛКИ

Як CQRS зазвичай перекручують

✕ 1

Повний CQRS для довідника

Дві бази, шина подій і проєкції для списку міст, який змінюють раз на рік. Кожне нове поле проходить пʼять класів і міграцію в двох місцях.

Натомість: почніть з окремих query-класів над тими самими таблицями. Окреме сховище додавайте, коли основна база не встигає.

✕ 2

Читання через агрегати

Каталог вантажить двадцять агрегатів Job з усіма полями, мапить їх у картки й для кожної дочитує компанію. Сторінка робить десятки запитів.

Натомість: query-сервіс одним запитом з join віддає саме ті поля, які потрібні картці.

✕ 3

Забута затримка read model

Користувач зберіг зміну, а сторінка після редиректу читає проєкцію, яку ще не оновили. Здається, що збереження не спрацювало, і людина натискає ще раз.

Натомість: після власної зміни показуйте дані з боку запису або з відповіді команди. Затримку проєкції покажіть явно, якщо вона помітна.

05 · ДЕРЕВО РІШЕНЬ

Чи потрібна вам окрема модель читання

1
Чи відрізняється те, що показують, від того, що змінюють?
Картка каталогу збирає вакансію, компанію й вилку, а змінюють одну вакансію.
НІ →Це CRUD: одна модель для читання й запису цілком годиться.
2
Чи заважають агрегати читанню?
Зайві поля, мапінг, N+1 на кожній сторінці списку.
НІ →Читайте через репозиторій, поки це не болить.
3
Чи стане запитів до основних таблиць замало?
Медіани зарплат по всьому ринку на кожен запит або пошук по кількох контекстах.
НІ →Зупиніться на окремих query-класах над тими самими таблицями, як каталог PHP Ukraine.
ТАК НА ВСІ →Вам потрібна окрема read model. Оновлюйте її після запису й чесно покажіть затримку.
06 · СЛОВНИК
CQS
Command Query Separation: метод або команда, що змінює стан, або запит, що повертає дані без побічних ефектів.
CQRS
Command Query Responsibility Segregation: окремі моделі для запису й читання в межах застосунку.
Бік запису
Сценарії, агрегати й репозиторії. Тут перевіряються правила й живуть транзакції.
Бік читання
Query-сервіси, які віддають DTO для екрана. Нічого не змінюють і правил не перевіряють.
Read model
Дані, підготовлені саме для читання: окрема таблиця, materialized view чи індекс пошуку. Оновлюються після запису.
Eventual consistency
Стан, коли read model трохи відстає від запису й наздоганяє його згодом.
07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Команда змінює стан і нічого не повертає, хіба що ID нового запису. Запит повертає дані й нічого не змінює, тому його можна викликати скільки завгодно разів без наслідків.

Ні. Достатньо окремих моделей для запису й читання, які можуть ходити в ті самі таблиці, як каталог вакансій PHP Ukraine. Окреме сховище для читання додають, коли саме читання стає вузьким місцем, і разом з ним погоджуються на затримку оновлення.

Оновлювати її після запису: у тій самій транзакції, обробником доменної події або перерахунком за розкладом, як щоденний знімок ринку в PHP Ukraine. Для подій без втрат використовують outbox, а проєкцію роблять ідемпотентною, щоб її можна було перебудувати з нуля.