<? phpukraine СПІВБЕСІДИ
Пошук по платформі

Архітектура для Middle: питання на співбесіду

4 питання рівня Middle з теми Архітектура з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
4 питання
ARC
Архітектура·Middle ·репозиторій ·Eloquent ·Doctrine

Репозиторій виправданий, коли він виглядає як колекція агрегатів у мові домену й тримає доменний шар без Illuminate чи Doctrine; він шкодить, коли перетворюється на generic CRUD-обгортку, що повертає той самий Builder або модель і лише додає шар.

Навіщо репозиторій, якщо Eloquent-модель уже вміє where() і find()?
Ви пишете репозиторії, щоб потім замінити MySQL на MongoDB?
Doctrine вже дає EntityRepository — навіщо ще один шар поверх нього?
Чому у вашому репозиторії зʼявився метод findActiveByCityPaginatedSortedBySalary()?

Питання зводиться до того, що саме патерн обіцяє. Репозиторій — це колекціє-подібний інтерфейс над агрегатами: ти кладеш туди обʼєкт і дістаєш обʼєкт, не знаючи, чи за ним таблиця, чи HTTP-API. Ключове слово — «не знаючи»: якщо метод повертає Illuminate\Database\Eloquent\Builder або колекцію моделей, знання про ORM просто переїхало на рівень вище, і жодної інверсії залежності не сталося. Тому першою ознакою корисного репозиторію є його інтерфейс: ofId(), publishedIn(), save() читаються без згадки про ORM, а findAllWhere(array $criteria) — ні.

Друга половина відповіді — різниця в тому, поверх чого ви будуєте шар. Eloquent реалізує Active Record: модель сама вміє себе зберігати, save() летить у базу негайно, а $model->relation тягне ленивий запит. Doctrine реалізує Data Mapper: сутність — звичайний PHP-обʼєкт, зміни накопичує Unit of Work, а в базу вони йдуть на EntityManagerInterface::flush(). Через це в Doctrine репозиторій уже вбудований (EntityRepository, у Symfony — ServiceEntityRepository з doctrine/doctrine-bundle) і відповідає лише за читання: писати другу обгортку поверх нього безглуздо, достатньо, щоб цей клас реалізовував доменний інтерфейс. А ось для Eloquent репозиторій — це справжня робота: треба мапити модель у доменний обʼєкт і назад, бо інакше ви віддаєте назовні persistence-обʼєкт і абстракція існує лише на папері.

Коли шар справді потрібен. Перше — коли в проєкті є доменний і аплікаційний шари, яким заборонено бачити фреймворк (у цьому репозиторії це прямо перевіряють архітектурні тести). Друге — коли складна логіка вибірки має назву в мові бізнесу і повторюється в кількох місцях. Третє — тести: in-memory реалізація того самого інтерфейсу дає швидкі юніт-тести use case без бази, і це часто головна практична вигода. А от аргумент «підмінимо базу» на співбесіді працює проти вас: зміна MySQL на PostgreSQL чи документну базу міняє запити, типи й гарантії транзакцій, і одним новим класом не обійдеться.

Коли шкодить. Generic BaseRepository з find/all/create/update/delete для кожної моделі — це гірша копія Eloquent: ви втрачаєте скоупи, with(), chunk(), cursor(), paginate(), а натомість не отримуєте нічого. Так само шкодить репозиторій, що обростає методами під кожен екран: findActiveByCityPaginatedSortedBySalary — сигнал, що читання для UI треба винести в окремий query-сервіс, який віддає DTO або пагінатор проєкцій повз домен. У Laravel-застосунку без виділеного домену дешевша й чесніша альтернатива — тонкі скоупи на моделі (scopeActive() або атрибут #[Scope] у Laravel 12) плюс query-обʼєкти для важких вибірок.

Межа проходить по транзакціях і межах агрегату. Репозиторій не керує транзакцією: її відкриває use case через DB::transaction() або wrapInTransaction(), а в Doctrine flush() викликається один раз наприкінці сценарію, інакше два агрегати перестають комітитись атомарно. І репозиторій існує на агрегат, а не на таблицю: рядки vacancy_skills зберігаються разом із вакансією, а не мають власний VacancySkillRepository. Якщо в проєкті немає агрегатів і немає доменного шару — швидше за все, немає й потреби в репозиторії.

// Domain: інтерфейс живе поруч з агрегатом і не знає ані про Illuminate, ані про Doctrine
interface Vacancies
{
    public function ofId(VacancyId $id): ?Vacancy;          // один агрегат
    public function publishedIn(City $city): VacancyList;    // назва з мови бізнесу, не з SQL
    public function save(Vacancy $vacancy): void;            // без flush і без транзакції
}

// Infrastructure: єдине місце, де є ORM і мапінг у доменні обʼєкти
final class EloquentVacancies implements Vacancies
{
    public function ofId(VacancyId $id): ?Vacancy
    {
        $row = VacancyModel::query()->find($id->toString());

        return $row === null ? null : $this->toDomain($row);
    }

    public function publishedIn(City $city): VacancyList
    {
        // eager loading — деталь інфраструктури, назовні Builder не тече
        $rows = VacancyModel::query()
            ->with('company')
            ->where('status', 'published')
            ->where('city', $city->value)
            ->get();

        return new VacancyList($rows->map($this->toDomain(...))->all());
    }

    public function save(Vacancy $vacancy): void
    {
        // Eloquent пише одразу; межу транзакції відкриває use case, а не репозиторій
        VacancyModel::query()->updateOrCreate(
            ['id' => $vacancy->id()->toString()],
            ['title' => $vacancy->title(), 'salary_from' => $vacancy->salary()->from()],
        );
    }

    private function toDomain(VacancyModel $row): Vacancy
    {
        return Vacancy::restore(new VacancyId($row->id), $row->title, new City($row->city));
    }
}
Що репозиторій — це колекціє-подібна абстракція над агрегатами, а не обгортка над таблицею: назовні йдуть доменні обʼєкти, а не Builder і не Eloquent Collection.
Що мотивація «замінимо базу» майже ніколи не реалізується; реальні мотиви — тримати Domain/Application без залежності від ORM, називати запити мовою бізнесу й підставляти in-memory реалізацію в тестах.
Різницю Active Record і Data Mapper: Eloquent-модель сама себе зберігає (`save()`), Doctrine розділяє сутність і `EntityManagerInterface` з `persist()`/`flush()` та Unit of Work.
Що інтерфейс належить доменному шару, а реалізація — інфраструктурному; інакше залежність нікуди не інвертувалась.
Що читання для UI (списки з фільтрами, сортуванням і пагінацією) розумно виносити в окремий query-сервіс повз репозиторій, а не роздувати його методами під кожен екран.
Що межа транзакції — це application service (`DB::transaction()`, `EntityManagerInterface::wrapInTransaction()`), а не метод репозиторію.
Обґрунтовувати репозиторій тим, що «завтра підмінимо MySQL на Mongo»: різні бази означають різні запити й різні гарантії, підміна реалізації однаково не буде безболісною.
Повертати з репозиторію `Builder` або колекцію Eloquent-моделей: абстракція протікає, бо клієнтський код усе одно дописує `->where()` і `->with()`.
Писати `BaseRepository` з generic `find/all/create/update/delete` для всіх моделей — це другий Eloquent, тільки бідніший: без скоупів, eager loading, `chunk()` і `cursor()`.
Плодити методи під кожен екран (`findActiveByCityPaginatedSortedBySalary`): 30 методів в інтерфейсі — сигнал, що потрібні критерії або окремий read-модель.
Класти інтерфейс поруч із реалізацією в Infrastructure: формально інтерфейс є, а домен усе одно тягне ORM.
У Doctrine викликати `flush()` усередині `save()` репозиторію: це ламає Unit of Work і робить неможливою одну транзакцію на кілька агрегатів.
ПОРАДА

Скажіть коротко: «репозиторій виправданий тоді, коли його інтерфейс можна прочитати не знаючи, що там ORM». Якщо в сигнатурах зʼявляються Builder, масиви `['where' => ...]` чи пагінатор — це вже не репозиторій, а обгортка, і вона шкодить.

Сторінка питання →
ARC
Архітектура·Middle ·SOLID ·дизайн ·принципи

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

Наведіть приклад порушення LSP у реальному коді.
Коли DIP заважає, а не допомагає?
Який із принципів SOLID ви порушуєте свідомо?

SOLID — це не чекліст, а набір спостережень про те, де код починає опиратись змінам. На практиці найбільше працюють два з пʼяти. Єдина відповідальність: клас має одну причину для зміни. Не один метод, а один привід, з яким до нього приходять. Симптоми порушення видно без теорії: конструктор на десять залежностей, тести з величезним setup, клас, який правлять і бухгалтерія, і маркетинг. Інверсія залежностей: код залежить від абстракції там, де реалізація може змінитись або її треба підміняти в тестах, тобто на межах із базою, зовнішніми API, часом, файлами.

Решта принципів здебільшого наслідки. Відкритість до розширення означає точки розширення там, де варіанти справді додаються: способи оплати, формати експорту. Підстановка Лісков — це про контракти: підклас, який кидає виняток у методі батька або мовчки змінює його семантику, ламає весь код, написаний під батька. Розділення інтерфейсів — про те, щоб споживач не залежав від методів, які не використовує, і саме воно зазвичай виправляє порушення LSP.

Формальне дотримання всіх пʼяти без потреби дає інтерфейс на кожен клас з однією реалізацією, десятки файлів на один сценарій і код, у якому важко знайти, де щось відбувається. Для CRUD-адмінки це шкідливо. Принципи застосовують там, де є біль: клас, який страшно міняти, тест, який неможливо написати, зміна, яка тягне правки в десяти місцях.

// До: одна причина для зміни? Ні: платежі, лист, PDF, склад в одному класі
final class OrderService
{
    public function checkout(Order $order): void
    {
        $charge = $this->stripe->charge($order->total(), $order->card());   // зовнішній API
        $this->mailer->send(new ReceiptMail($order, $charge));            // формат листа
        file_put_contents("/invoices/{$order->id}.pdf", $this->pdf($order)); // інфраструктура
        $this->db->table('stock')->decrement('qty', $order->qty());          // складський облік
    }
}

// Після: SRP + DIP там, де є межа з зовнішнім світом. Усередині домену конкретні класи.
interface PaymentGateway { public function charge(Money $amount, CardToken $card): Charge; }

final class Checkout
{
    public function __construct(
        private PaymentGateway $payments,        // абстракція: буде FakeGateway у тестах
        private EventDispatcher $events,
    ) {}

    public function handle(Order $order): void
    {
        $charge = $this->payments->charge($order->total(), $order->card());
        $order->markPaid($charge->id());
        $this->events->dispatch(new OrderPaid($order->id)); // лист, PDF, склад — окремі слухачі
    }
}

// LSP порушено: підклас звужує контракт батька
class ReadOnlyOrders extends Orders
{
    public function save(Order $o): void { throw new LogicException('read only'); }
}
// Правильно: окремі інтерфейси OrdersReader і OrdersWriter (ISP), без наслідування
Не розшифровку абревіатури, а приклад: клас, який ви розділили, і що це дало в тестах чи при зміні вимог.
Що SRP це про одну причину для зміни, а не про один метод на клас, і що симптом порушення це клас, який редагують з різних приводів різні люди.
Що OCP на практиці означає точки розширення там, де зміни очікувані, наприклад стратегії оплати, а не інтерфейс на кожен клас.
Реальний приклад LSP: підклас кидає виняток у методі батька або звужує контракт, і код, який працює з батьком, ламається.
Що DIP це залежність від абстракції там, де реалізація може змінитись або її треба підміняти в тестах, а не інтерфейс для єдиної реалізації назавжди.
Переказувати визначення пʼяти принципів без жодного прикладу з власного коду.
Створювати інтерфейс для кожного класу з однією реалізацією й називати це DIP.
Трактувати SRP як один метод на клас і розсипати логіку по десятках класів без цілісності.
Не помічати порушення LSP у власному коді: ReadOnlyRepository extends Repository з save(), який кидає виняток.
Застосовувати принципи до CRUD-коду, де жодної складності немає, і роздувати проєкт шарами.
ПОРАДА

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

Сторінка питання →
ARC
Архітектура·Middle ·value object ·immutability ·DDD

Value object — незмінний обʼєкт без ідентичності, який описується лише своїм значенням: він валідує себе в конструкторі, тому в системі не існує невалідного екземпляра, і порівнюється за вмістом, а не за посиланням.

Чим `Money` кращий за `float $amount` і `string $currency` поруч?
У нас email валідується у трьох місцях по-різному — як це лікувати?
Value object і entity: у чому різниця, крім слова «immutable»?
Як порівняти два value object і чому `===` тут не працює?

Скалярний тип описує форму даних, але нічого не каже про їхній зміст. string $email — це будь-який рядок, зокрема порожній, з пробілом або з двома собаками; float $price — будь-яке число, зокрема відʼємне й з похибкою округлення. Тому валідація розповзається: перевірка у формі, ще одна в сервісі, третя перед відправкою в платіжний шлюз, і всі трохи різні. Value object згортає це в одну точку: інваріант перевіряється в конструкторі, а якщо конструктор відпрацював — невалідного екземпляра в системі не існує. Далі тип у сигнатурі працює як доказ: побачивши Email $to, ви вже знаєте, що всередині не сміття, і повторна перевірка зайва.

Друга властивість — відсутність ідентичності. Value object повністю визначається своїм вмістом: двісті гривень нічим не відрізняються від інших двохсот гривень, тому їх можна вільно замінювати одне одним. Через це VO роблять незмінним: у PHP це readonly-властивості з 8.1 або readonly class з 8.2, а «зміна» повертає новий екземпляр (add(), withCurrency()). Незмінність прибирає цілий клас багів із розділюваним станом: обʼєкт, переданий у три сервіси, не може бути зіпсований одним із них. Памʼятайте про межу readonly: воно захищає саме посилання, тож обʼєкт усередині VO теж має бути незмінним, інакше гарантія фіктивна.

Третя властивість — рівність за значенням, і саме тут найчастіше помиляються. === для обʼєктів порівнює ідентичність, тому два однакові Money будуть нерівні. == порівнює клас і всі властивості рекурсивно, що для простого VO спрацює, але залежить від внутрішніх деталей і вкладених обʼєктів. Тому пишуть явний equals(), який ще й фіксує правило: наприклад, для Email домен зазвичай нечутливий до регістру, і це рішення має бути в коді, а не в чиїйсь голові. Окремо: PHP не дозволяє обʼєкт як ключ масиву, тож для дедуплікації VO беруть його рядкове представлення.

Практичний виграш крім валідації — поведінка, якій нарешті є куди переїхати. Форматування суми, конвертація валют, порівняння цін, нормалізація телефону — усе це раніше жило статичними хелперами й дублювалося; у VO воно лежить поруч із даними, які описує, і покривається тестами без бази. Типовий кандидат: гроші, email, телефон, слаг, період дат, координати, ідентифікатор із форматом (наприклад, IBAN або ЄДРПОУ). Ознака, що VO потрібен: значення мандрує через кілька шарів і в кожному його перевіряють чи форматують заново.

Ціна теж реальна. VO треба мапити на сховище: у Doctrine ORM це #[Embeddable] плюс #[Embedded] у сутності або власний DBAL-тип, в Eloquent — кастомний каст на CastsAttributes, чий set() може повернути масив і розкласти обʼєкт у кілька колонок. Його треба серіалізувати на межах системи: у JSON-відповіді, у payload черги, у форму — тож toString()/fromString() пишуться одразу. І VO не безкоштовний за памʼяттю, тому в гарячих циклах на сотні тисяч ітерацій скаляр із перевіркою на вході чесніший. Правило межі просте: обгортка без інваріанта — це шум, а обгортка з інваріантом окупається з першого ж місця, де ви змогли викинути валідацію.

/**
 * Гроші: сума в мінорних одиницях (копійки), валюта — enum.
 * float заборонений: 0.1 + 0.2 !== 0.3, а гроші мусять сходитись до копійки.
 */
final readonly class Money                       // readonly class — PHP 8.2+
{
    private function __construct(
        public int $amount,                      // копійки, не гривні
        public Currency $currency,               // enum, PHP 8.1+
    ) {
        // Єдине місце, де перевіряється інваріант: далі тип сам є гарантією
        if ($amount < 0) {
            throw new InvalidArgumentException('Сума не може бути відʼємною');
        }
    }

    /** Іменований конструктор: назва пояснює одиниці виміру */
    public static function fromMinorUnits(int $amount, Currency $currency): self
    {
        return new self($amount, $currency);
    }

    /** «Зміна» — це новий екземпляр, старий лишається недоторканим */
    public function add(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->amount + $other->amount, $this->currency);
    }

    /** === порівняв би ідентичність, тому рівність описуємо явно */
    public function equals(self $other): bool
    {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;   // enum-кейси — синглтони
    }

    public function __toString(): string
    {
        return sprintf('%d.%02d %s', intdiv($this->amount, 100), $this->amount % 100, $this->currency->value);
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new DomainException('Не можна додавати різні валюти');
        }
    }
}

// Сигнатура сама себе документує і не дає переплутати аргументи місцями
$total = Money::fromMinorUnits(19900, Currency::UAH)->add(Money::fromMinorUnits(5000, Currency::UAH));
Що VO не має ідентичності: два `Money(100, 'UAH')` взаємозамінні, а два `User` з однаковим імʼям — ні.
Що інваріант перевіряється один раз у конструкторі, тому тип `Email` у сигнатурі вже є гарантією, і повторна валідація в сервісах зайва.
Що незмінність досягається `readonly`-властивостями (PHP 8.1) або `readonly class` (8.2), а «зміна» — це новий екземпляр, а не мутація.
Що порівняння йде через власний `equals()`, бо `===` для обʼєктів порівнює ідентичність, а не вміст.
Що гроші зберігаються цілим числом у мінорних одиницях, а не `float`, і що VO — природне місце для цього правила.
Що VO треба мапити на сховище: Doctrine `#[Embedded]`, Laravel custom cast через `CastsAttributes`.
Називати VO будь-який DTO: DTO переносить дані без інваріантів, VO гарантує їх і порівнюється за значенням.
Валідувати в сеттері чи в статичному методі `isValid()`, залишаючи можливість створити невалідний обʼєкт напряму через конструктор.
Порівнювати через `===` (завжди false для різних екземплярів) або через `==` без розуміння, що воно рекурсивно порівнює всі властивості, зокрема вкладені обʼєкти.
Робити VO з `float` для грошей: `0.1 + 0.2 !== 0.3`, і VO лише ховає помилку за фасадом.
Загортати в VO геть усе, включно з булевими прапорцями й лічильниками, від чого код розбухає без жодної нової гарантії.
Додавати VO ідентифікатор чи `updated_at` — це вже entity, і зберігати його треба інакше.
ПОРАДА

Сформулюйте вигоду через сигнатуру: `charge(Money $amount, Email $to)` неможливо викликати з переплутаними аргументами й неможливо викликати з невалідними даними, а `charge(float $amount, string $currency, string $email)` — можна, і компілятор мовчатиме. Далі згадайте `equals()` і мапінг у базу — це відрізняє того, хто VO писав, від того, хто про них читав.

Сторінка питання →
ARC
Архітектура·Middle ·CQRS ·команди ·запити

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

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

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 чи денормалізована таблиця), окреме сховище з асинхронною синхронізацією. Більшість проєктів зупиняється на першому.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти