<? phpukraine СПІВБЕСІДИ
Пошук по платформі
АРХІТЕКТУРА · MIDDLE

Що таке CQRS і як його застосовувати без event sourcing?

CQRS — це розділення операцій зміни стану (команди) і операцій читання (запити) на різні моделі: команди йдуть через доменні агрегати з інваріантами, читання — окремими DTO або навіть сирим SQL. Event sourcing і окрема база для читання — необовʼязкові додатки, а не частина визначення.

У чому різниця між CQRS і звичайним сервісним шаром з методами save() і find()?
Чи обовʼязково для CQRS мати дві бази і event sourcing?
У нас одна модель обслуговує і форму редагування, і звіт із десятьма JOIN. Що тут не так?
Навіщо команді повертати void, якщо мені потрібен id створеної сутності?
CQRS команди запити read model архітектура

CQRS означає рівно одне: операції, що змінюють стан, і операції, що повертають дані, працюють через різні моделі. Причина в тому, що вимоги до цих двох сторін розходяться. Запису потрібні інваріанти, транзакційні межі й мінімальний обсяг завантажених даних — рівно стільки, щоб перевірити правило. Читанню потрібні денормалізовані плоскі рядки під конкретний екран, часто з кількох таблиць, без жодних правил. Коли обидві потреби обслуговує одна модель (сутність Doctrine чи Eloquent-модель), вона програє обом: у неї додають поля заради звітів і навантажують звʼязками заради списків, а інваріанти тонуть у геттерах.

Мінімальна реалізація не вимагає ніякої нової інфраструктури. Запис: команда як незмінний DTO з наміром і handler, який завантажує агрегат, викликає доменний метод і зберігає. Читання: окремий клас, що виконує SELECT і повертає readonly-DTO під конкретний екран — у Laravel через DB::table(), у Doctrine через DBAL або NativeQuery з ResultSetMapping. Ключове тут те, що читання не проходить через ORM-сутність: сутність потрапляє в Unit of Work, отримує dirty checking і гідрейтинг звʼязків, які на сторінці списку не потрібні. Одна база, одна транзакція, звичайні міграції — і це вже повноцінний CQRS.

Далі йдуть три незалежні кроки, які часто помилково вважають частиною визначення. Перший — окрема схема для читання в тій самій базі: SQL view або денормалізована таблиця, яку оновлює той самий код, що й пише, у тій самій транзакції. Другий — окреме сховище: репліка, Redis, Elasticsearch. Третій — event sourcing, тобто зберігання стану як послідовності подій, з яких проєкція будує read model. Кожен крок вирішує свою проблему (складність запиту, профіль навантаження, потреба в історії) і має свою ціну. Брати їх разом «бо так у статтях про CQRS» — найдорожча помилка в цій темі.

Ціна самого розділення теж не нульова: класів стає більше, і те, що раніше було одним методом сервісу, тепер команда, handler і читач. Тому CQRS вводять точково, а не по всьому проєкту: у модулі, де правила запису нетривіальні, або де екрани читання вимагають агрегацій, яких немає у формі редагування. У простому CRUD-модулі — довідник, налаштування, теги — розділення дає лише зайві файли, і чесна відповідь на співбесіді включає цю межу.

Останнє, про що варто сказати самому: узгодженість. Поки read model оновлюється в одній транзакції із записом, її немає про що обговорювати. Щойно проєкція стає асинхронною (черга, реплікація, зовнішній індекс), зʼявляється вікно, у якому користувач бачить старі дані одразу після власної дії. Це не баг CQRS, а свідомий обмін швидкості читання на затримку узгодження, і його треба закладати в сценарій: читати після запису з primary, показувати нову версію оптимістично або прямо повідомляти, що зміни зʼявляться за кілька секунд.

// Запис: команда + handler. Модель запису — агрегат з інваріантами.
final readonly class PublishArticle
{
    public function __construct(
        public string $articleId,      // ULID генерує клієнт: команда ідемпотентна
        public string $editorId,
    ) {}
}

final readonly class PublishArticleHandler
{
    public function __construct(private ArticleRepository $articles) {}

    // void: результат читання беруть окремим запитом, а не з команди
    public function __invoke(PublishArticle $command): void
    {
        $article = $this->articles->get(ArticleId::fromString($command->articleId));
        $article->publish(EditorId::fromString($command->editorId)); // інваріанти всередині агрегату
        $this->articles->save($article);
    }
}

// Читання: жодного агрегату й жодної ORM-сутності — плоский SELECT у DTO.
final readonly class PublishedArticleRow
{
    public function __construct(
        public string $id,
        public string $title,
        public string $authorName,
        public int $viewCount,
    ) {}
}

final readonly class PublishedArticles
{
    public function __construct(private ConnectionInterface $db) {}

    /** @return list<PublishedArticleRow> */
    public function latest(int $limit = 20): array
    {
        // Та сама база й та сама транзакційна модель — окрема лише модель читання
        $rows = $this->db->table('articles as a')
            ->join('users as u', 'u.id', '=', 'a.author_id')
            ->where('a.status', 'published')
            ->orderByDesc('a.published_at')
            ->limit($limit)
            ->get(['a.id', 'a.title', 'u.name as author_name', 'a.view_count']);

        return $rows->map(fn ($r) => new PublishedArticleRow($r->id, $r->title, $r->author_name, (int) $r->view_count))->all();
    }
}
Що CQRS розділяє саме моделі, а не обовʼязково бази: одна таблиця й одна транзакція цілком сумісні з CQRS.
Що записом керує агрегат з інваріантами, а читання не зобовʼязане проходити через сутності ORM і може бути звичайним SELECT у DTO.
Що event sourcing, окрема read-база й асинхронна проєкція — це три незалежні рішення, кожне зі своєю ціною, і жодне не входить у мінімальний CQRS.
Що ціна CQRS — дублювання моделей і більше класів, тому його вводять там, де форми читання і запису реально розійшлися: звіти, списки з фільтрами, експорти.
Що як тільки читання йде з окремого сховища або проєкції, зʼявляється eventual consistency, і це треба свідомо показати в UI, а не ховати.
Ставити знак рівності між CQRS і event sourcing і відмовлятися від CQRS через складність ES.
Заводити CommandBus і QueryBus, але всередині обох ходити тими самими Eloquent-моделями: розділення на пакети є, розділення моделей немає.
Робити read model через ті самі сутності Doctrine з fetch-join і вважати, що це окрема модель читання: сутність тягне за собою Unit of Work і зайвий гідрейтинг.
Стверджувати, що команда не може повертати нічого: правило «void» стосується даних для читання, а ідентифікатор створеної сутності віддавати нормально, надто якщо його генерує клієнт.
Вводити асинхронну проєкцію заради «швидкості» і отримати скаргу «я зберіг і не бачу змін», не передбачивши на це відповіді в UI.
ПОРАДА

Скажіть, що CQRS — це розділення моделей, а не інфраструктури, і назвіть три незалежні кроки: окремі DTO для читання, окрема схема (view чи денормалізована таблиця), окреме сховище з асинхронною синхронізацією. Більшість проєктів зупиняється на першому.

оновлено 5 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Мінімальний CQRS — дві моделі в одній базі й одній транзакції: агрегат для запису, DTO з SELECT для читання. Все інше — окремі, необовʼязкові рішення.