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

Питання на співбесіду з архітектури

Архітектурні питання для Senior і Lead: межі сервісів, транзакційна цілісність, ідемпотентність, кешування, розрізання моноліту, компроміси рішень.

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

Сторінка питання →
ARC
Архітектура·Senior ·DDD ·bounded context ·компроміси

DDD має сенс там, де складність саме в домені: багато правил, які часто змінюються, і потрібна спільна мова з бізнесом; для CRUD-адмінки чи інтеграційного сервісу агрегати й репозиторії додають шарів без вигоди.

Як зрозуміти, що домен достатньо складний для DDD?
Що таке bounded context на прикладі магазину?
Чому папки Domain, Application, Infrastructure ще не DDD?

Domain-Driven Design виправданий тоді, коли складність продукту зосереджена в бізнес-правилах. Ознаки: правила мають винятки й змінюються разом із бізнесом, експерти з різних відділів вкладають різне значення в одні слова, помилка в логіці коштує грошей, продукт житиме роками. У такому середовищі модель, яка говорить мовою бізнесу, окупає себе: зміну вимоги можна знайти в коді по назві, а інваріанти живуть в одному місці.

Найкорисніша частина DDD стратегічна. Bounded context — це межа, всередині якої слово має одне значення. Product у каталозі, на складі й у замовленні означає різні речі, і спроба зробити одну модель на всіх дає гігантський клас, який ніхто не розуміє. Три невеликі моделі з явними інтеграціями через події простіші, навіть якщо тактичних патернів у них мінімум.

Шкодить DDD там, де складності немає. CRUD-адмінка, звіти, інтеграційний сервіс, який перекладає один JSON в інший, не потребують агрегатів і репозиторіїв: пʼять класів на одну форму лише сповільнюють команду. Так само шкодить підміна суті формою: папки Domain, Application, Infrastructure без моделі та мови це не DDD, а бюрократія.

Зріла відповідь містить обидва приклади: контекст, де модель окупилась, і контекст, де від DDD свідомо відмовились на користь транзакційного скрипта й Eloquent. DDD можна застосовувати до одного складного контексту всередині звичайного застосунку, і саме так він найчастіше працює.

// Складний контекст: правила живуть у моделі, мова та сама, що в бізнесу
final class Subscription
{
    private function __construct(
        public private(set) SubscriptionId $id,
        private Plan $plan,
        private Period $period,
        private SubscriptionStatus $status,
    ) {}

    public function renew(Clock $clock): void
    {
        if ($this->status === SubscriptionStatus::Cancelled) {
            throw new CannotRenewCancelledSubscription($this->id);
        }
        if ($this->period->endsAfter($clock->now()->modify('+14 days'))) {
            throw new RenewalTooEarly($this->id, $this->period);   // правило з договору, не з таблиці
        }
        $this->period = $this->period->extendBy($this->plan->billingCycle());
        $this->record(new SubscriptionRenewed($this->id, $this->period));
    }
}

// Простий контекст поруч: CRUD без агрегатів, і це свідомий вибір
final class TagController
{
    public function store(Request $request): RedirectResponse
    {
        Tag::create($request->validate(['name' => ['required', 'max:50']]));
        return back();
    }
}

// Межа контекстів: той самий «продукт», різні моделі, інтеграція через подію
// Catalog\Product   — опис, фото, SEO
// Warehouse\StockItem — sku, залишок, комірка
// Orders\OrderLine   — назва й ціна, зафіксовані на момент покупки
Що цінність DDD у моделі й ubiquitous language, а не в структурі каталогів або в наборі патернів Entity, Repository, Value Object.
Критерії доречності: правила, які змінюються з бізнесом, кілька команд або підсистем із різними значеннями одних слів, довгий горизонт життя продукту.
Що стратегічна частина, тобто bounded contexts і context map, дає найбільше навіть без тактичних патернів, і що межі контекстів визначаються мовою, а не таблицями.
Приклад, де DDD не застосували свідомо: адмінка, звіти, інтеграція, і чому анемічна модель з транзакційним скриптом там була правильним вибором.
Розуміння ціни: більше коду, повільніший старт, потреба в дисципліні команди, і що DDD можна застосовувати до одного контексту, а не до всього застосунку.
Ототожнювати DDD зі структурою папок або з гексагональною архітектурою.
Застосовувати агрегати й репозиторії до CRUD і отримувати пʼять класів на одну форму.
Робити один величезний домен без bounded contexts, де Product означає одне для складу й інше для каталогу.
Проєктувати агрегати від таблиць бази, а не від інваріантів, і отримувати гігантський Order з усіма звʼязками.
Вважати, що DDD вимагає CQRS, event sourcing і мікросервіси.
ПОРАДА

Найсильніша відповідь — приклад, де ви свідомо не застосували DDD і чому це було правильно. Другий за силою: приклад слова, яке в двох контекстах означало різне.

Сторінка питання →
ARC
Архітектура·Senior ·модульний моноліт ·мікросервіси ·межі модулів

Модулі й сервіси розділяє не код, а межа процесу: усередині моноліта виклик модуля — це синхронний метод у спільній транзакції, між сервісами — мережа з таймаутами, частковими збоями й eventual consistency. Тому за замовчуванням беруть модульний моноліт із твердими межами, а окремий сервіс виділяють лише там, де є конкретна причина — незалежний профіль навантаження, окремий цикл релізу під окрему команду або інший рантайм — і платять за неї розподіленими транзакціями та експлуатацією.

У нас моноліт на 300 тисяч рядків і деплой раз на тиждень. Це вже привід переходити на мікросервіси?
Як ви зрозумієте, що конкретний модуль пора виносити в окремий сервіс?
Ми розпиляли моноліт на вісім сервісів, і тепер релізимо їх усі разом однією командою. Що ми зробили не так?
Чим межа модуля в моноліті відрізняється від межі мікросервісу, якщо в обох випадках це просто інтерфейс?

Різниця між модулем і сервісом лежить не в коді, а в межі процесу. Усередині моноліта звертання одного модуля до іншого — це виклик методу: він або відбувся, або кинув виняток, він синхронний, він швидкий і, найважливіше, він може бути в тій самій транзакції, що й локальний запис. Щойно та сама межа стає мережевою, зникають усі три властивості одразу. Зʼявляється третій стан «невідомо» (запит пішов, відповідь не повернулася, але операція могла виконатися), зʼявляється затримка, яка складається вздовж ланцюжка, і зникає спільний COMMIT. Тому питання «моноліт чи мікросервіси» — це не питання про чистоту архітектури: чисті межі, порти, окремі схеми даних і заборона крос-модульних залежностей цілком досяжні в одному деплої. Це питання про те, чи готові ви заплатити розподіленою узгодженістю за незалежність деплою й масштабування.

Звідси випливає й порядок дій. За замовчуванням береться модульний моноліт: один деплой, кілька модулів із вузьким публічним контрактом, у кожного свої таблиці, звертання одне до одного тільки через порт. Ключове тут — межі мають бути машинно перевіреними, інакше вони не витримають і кварталу. qossmic/deptrac описує шари й дозволені залежності у deptrac.yaml і завалює CI на порушенні; PHPArkitect і arch-тести Pest роблять те саме в термінах правил на неймспейси. Другий рівень захисту — дані: у Doctrine це окремі entity manager-и на модуль, у Laravel — окремі connection-и або принаймні тверда заборона на звʼязки Eloquent між модулями. Без цього перший же звіт із JOIN на чужі таблиці зшиває модулі назад, і «межа» лишається тільки в теці.

Сигнал до виділення сервісу завжди про різницю, а не про розмір. Різний профіль ресурсів: модуль рендерингу PDF або обробки відео тримає CPU й памʼять, і через нього доводиться масштабувати весь застосунок. Різний цикл релізу: команда готова релізити щодня, але стоїть у спільній черзі, і це вимірюється в днях затримки, а не у відчуттях. Інший рантайм: довгоживучий процес, WebSocket, інша версія PHP чи взагалі інша мова. Ізоляція ризику й даних: платіжна частина з окремим доступом і аудитом. Різна вимога до доступності: нестабільна зовнішня інтеграція не має класти основний трафік. «Кодова база велика» і «деплой довгий» у цей список не входять — обидва лікуються модулями, тестами й пайплайном, значно дешевше за мережу між класами.

Ціна розділення концентрується в одному місці: транзакція, що охоплювала два модулі, більше не існує. Двофазний коміт у типовому PHP-стеку не розглядають — практичної підтримки ні в PDO, ні в чергах немає. Замість нього — outbox: у тій самій транзакції, що й локальний запис, зберігається повідомлення, а окремий процес доставляє його далі. У Symfony Messenger цю роль грає доктрин-транспорт, який пише в messenger_messages тим самим зʼєднанням, а messenger:consume забирає; у Laravel — драйвер database разом із after_commit у config/queue.php або ->afterCommit() на джобі, щоб подія не поїхала раніше за COMMIT. Далі приймальна сторона має бути ідемпотентною за ключем повідомлення, а сценарій — мати компенсацію на випадок, коли другий крок не вдався. Якщо на етапі проєктування виявляється, що операція мусить бути атомарною, це найкраща з можливих новин: межу сервісу тут проводити не можна, і ви дізналися це до, а не після винесення.

Найдорожча помилка в цій темі — розподілений моноліт: сервіси є, а незалежності немає. Ознаки перевіряються за півгодини: чи можна зарелізити один сервіс без інших; чи ділять вони базу або міграції; чи ходять один в одного синхронним ланцюжком (три сервіси по 99.9% дають уже близько 99.7% на ланцюжку, і це помітно); чи піднімається локально один сервіс без усього стека. Якщо відповіді погані, правильний рух — не додавати сервісів, а зливати назад ті, що ділять дані й цикл релізу. Коли ж рішення ділити ухвалено свідомо, робиться це поступово, у стилі strangler fig: спершу єдиний порт на всі звертання, потім переведення того, що можна, на асинхронний обмін і відділення даних, потім версійований контракт із таймаутами, ретраями й ідемпотентними ключами, і лише в кінці — перенесення реалізації в окремий деплой, спершу на частині трафіку. Кожен крок має цінність сам по собі, і на будь-якому з них можна зупинитися, якщо виявиться, що причини для окремого процесу насправді не було.

// Публічний контракт модуля Billing: єдина точка входу для інших модулів.
// Решта класів Billing для них закрита — це перевіряє deptrac у CI.
interface Invoices
{
    public function issue(IssueInvoice $command): InvoiceId;
}

// Сьогодні: виклик у тому самому процесі й у тій самій транзакції.
final readonly class LocalInvoices implements Invoices
{
    public function __construct(private IssueInvoiceHandler $handler) {}

    public function issue(IssueInvoice $command): InvoiceId
    {
        return ($this->handler)($command);
    }
}

// Завтра, якщо Billing поїде окремим деплоєм, змінюється лише ця реалізація
// і біндинг у контейнері. Код модуля Hiring не чіпаємо взагалі.
final readonly class HttpInvoices implements Invoices
{
    public function __construct(private PendingRequest $http) {}

    public function issue(IssueInvoice $command): InvoiceId
    {
        // Мережа приносить те, чого не було в методі: таймаут, ретрай, ідемпотентність
        $response = $this->http
            ->timeout(3)                 // краще швидка помилка, ніж зависла черга воркерів
            ->retry(2, 200)              // ретрай безпечний лише завдяки ключу нижче
            ->withHeader('Idempotency-Key', $command->invoiceId) // ULID генерує викликач
            ->post('/invoices', ['order_id' => $command->orderId]);

        return InvoiceId::fromString($response->throw()->json('id'));
    }
}

// Чого цей код не ховає: транзакції, що охоплювала обидва модулі, більше немає.
// Якщо виклик мусив бути атомарним із локальним записом — потрібен outbox
// і компенсація, і саме тут вирішується, чи проходить межа сервісу в цьому місці.
Що мікросервіси — це рішення про межу процесу й деплою, а не про якість коду: чисті межі досяжні в моноліті, і саме там їх треба спершу зробити.
Що причини для виділення сервісу називаються конкретно й перевіряються цифрами: різний профіль масштабування, різний цикл релізу під окрему команду, інший рантайм чи вимоги ізоляції — а не «моноліт великий».
Що ціна розділення — це втрата транзакції між модулями: там, де був один COMMIT, зʼявляються saga, outbox, ідемпотентні хендлери й компенсації.
Що межі всередині моноліта мають бути машинно перевірені (deptrac, PHPArkitect, arch-тести Pest), інакше «модулі» — це просто теки, і за рік вони зростуться.
Що кандидат розуміє антипатерн розподіленого моноліта: сервіси, які релізяться разом і ходять один в одного синхронно ланцюжком, дають ціну мікросервісів без їхньої користі.
Що виносити треба через контракт і strangler-підхід: спершу єдина точка входу в модуль і асинхронний обмін, потім винесення реалізації, а не «перепишемо на сервіси за квартал».
Називати причиною розміру кодової бази або довгий деплой: і те, і те лікується модулями, тестами й пайплайном, а не мережею між класами.
Ділити за технічними шарами («сервіс API», «сервіс бази», «сервіс нотифікацій») замість бізнес-можливостей: тоді будь-яка фіча зачіпає всі сервіси одразу.
Вважати, що спільна база між сервісами — прийнятний компроміс: щойно два деплої пишуть в одну таблицю, це один сервіс із двома процесами і подвійним ризиком міграцій.
Обіцяти «просто загорнемо в HTTP» і не закласти таймаут, ретрай та ідемпотентність: перший же лаг сусіда кладе весь ланцюжок запитів.
Розраховувати на розподілену транзакцію (2PC) через PDO чи чергу: у типовому PHP-стеку її немає, і бізнес-логіку доводиться переписувати на компенсації.
Мірятися кількістю сервісів і забувати про операційну ціну: окремий CI, окремий моніторинг, трасування, версіювання контрактів, локальний запуск усього стека розробником.
ПОРАДА

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

Сторінка питання →
ARC
Архітектура·Senior ·outbox ·dual write ·повідомлення

Outbox pattern прибирає проблему подвійного запису: подію зберігають рядком у таблиці `outbox` тією ж транзакцією, що й зміну стану, а окремий процес-релей уже після коміту читає ці рядки й публікує в брокер. База і подія стають атомарними, ціною чого є доставка at-least-once — споживач зобовʼязаний бути ідемпотентним.

Ми пишемо в базу і одразу публікуємо подію в Kafka. Транзакція відкотилась, а подія вже пішла — як це прибрати?
Чому не можна просто дочекатись коміту й після нього викликати publish()?
Що станеться, якщо воркер упаде між COMMIT і відправленням у брокер?
Ви поклали подію в таблицю outbox — скільки разів її отримає споживач?

Проблема називається dual write: у транзакції ви змінюєте базу, а поряд треба сказати про це решті світу — покласти повідомлення в Kafka, RabbitMQ, SQS чи Redis. Це два різні сховища без спільної транзакції, і жоден порядок дій не рятує. Опублікуєте всередині транзакції — ROLLBACK відкотить рядок, а споживачі вже отримали подію про те, чого не сталося: гроші «списані», лист надісланий, склад зарезервував товар. Опублікуєте після COMMIT — між комітом і publish() лишається проміжок у кілька мілісекунд, і падіння процесу, OOM-kill контейнера чи розрив зʼєднання з брокером саме там означають, що стан змінився, а події не буде ніколи, і ніхто про це не дізнається. Двофазний коміт закрив би питання теоретично, але Kafka, SQS і Redis його не пропонують, а MySQL XA історично болісний у відновленні підвішених гілок — на практиці цей шлях не беруть.

Вихід у тому, щоб зробити атомарним не «база + брокер», а «база + база»: подію записують рядком у таблицю outbox тим самим зʼєднанням і тією самою транзакцією, що й зміну стану. Тепер відкат забирає обидва записи разом, а успішний COMMIT означає, що подія існує як факт, зафіксований на диску. Окремий процес-релей після коміту читає невідправлені рядки, публікує їх у брокер і позначає sent_at. Ключова деталь вибірки — SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL 9.5+, MySQL 8.0+): він дозволяє кільком релеям розбирати різні пачки й не дає двом процесам взяти ту саму сотню рядків. Чого робити не варто — тримати позначку id > :last зовні: значення послідовності видаються до коміту, тому рядок з меншим id може стати видимим пізніше за рядок з більшим, і подія тихо випаде з обробки.

Головне, чого outbox не дає — exactly-once. Релей може опублікувати повідомлення і впасти до UPDATE ... SET sent_at, після рестарту він опублікує його вдруге; сам брокер теж працює в режимі at-least-once. Тому дедуплікація не опція, а друга половина патерна: кожна подія несе message_id, згенерований ще в транзакції запису, а споживач в одній транзакції вставляє цей id у таблицю оброблених повідомлень з унікальним індексом і виконує корисну дію. Порушення унікальності = «вже робили», повідомлення підтверджують і йдуть далі. Це та сама ідея ключа ідемпотентності, що й у платежах, тільки застосована до споживача черги. Порядок теж не глобальний: у межах агрегату його дає ключ партиціювання (aggregate_id як ключ повідомлення в Kafka) або номер версії всередині події, який споживач порівнює з уже застосованим.

У payload кладуть уже серіалізований стан на момент транзакції, а не лише ідентифікатор. Різниця принципова: релей, який за id піде читати поточний рядок, віддасть стан на момент читання — після ще двох змін, а подія має описувати факт, що стався. Тонка подія з самим id теж має право на життя (персональні дані, великі документи), але тоді треба свідомо визнати, що споживач працює зі свіжішим станом, і закласти версіонування. Поряд з payload у рядку тримають тип події, ключ агрегату, occurred_at і версію схеми — цього достатньо, щоб через рік додати новий формат, не ламаючи старих споживачів.

У PHP-стеку є приємна деталь: те, що часто описують як «треба зробити outbox», у багатьох проєктах уже стоїть. Черга Laravel на драйвері database пише завдання в таблицю jobs, а Doctrine-транспорт Symfony Messenger (doctrine://default) — у messenger_messages. Якщо це те саме зʼєднання і диспетч відбувається всередині вашої транзакції, ви вже отримали транзакційний outbox без єдиного власного класу, а воркер грає роль релея. Плутати з цим dispatch()->afterCommit() (чи after_commit => true) і DispatchAfterCurrentBusMiddleware не можна: вони лише відкладають відправлення за коміт, лікуючи «job не бачить щойно створеної моделі», і вікно втрати між COMMIT і publish у них залишається.

Ціна патерна — затримка й експлуатація. Подія доходить не миттєво, а за час циклу релея, і це треба закласти в UX там, де користувач очікує реакції одразу. Таблиця росте: рядки або видаляють одразу після публікації, або позначають і чистять пакетно за розкладом, і в PostgreSQL частковий індекс WHERE sent_at IS NULL тут майже обовʼязковий, бо інакше індекс тягне за собою весь архів, а постійні UPDATE+DELETE роздувають таблицю. Обовʼязковий і моніторинг: алерт на вік найстарішого невідправленого рядка й на розмір черги — застряглий релей інакше виявляють через скаргу «клієнту не прийшов лист», коли минуло вже пів дня. Коли polling впирається в межу або систем-джерел стає багато, наступний крок — CDC: Debezium читає WAL/binlog і публікує рядки outbox без запитів до бази, ціною Kafka Connect, слотів реплікації та ще одного сервісу, за яким треба стежити.

// 1. Запис стану і події — одна транзакція, одне зʼєднання. Брокера тут немає.
DB::transaction(function () use ($order): void {
    $order->markPaid();
    $order->save();

    DB::table('outbox')->insert([
        'message_id'   => (string) Str::ulid(), // ключ дедуплікації для споживача
        'aggregate_id' => $order->id,           // ключ партиції: порядок у межах замовлення
        'type'         => 'order.paid',
        'payload'      => json_encode([        // стан НА момент транзакції, не id
            'order_id' => $order->id,
            'amount'   => $order->amount_cents,
            'currency' => $order->currency,
        ]),
        'occurred_at'  => now(),
        'sent_at'      => null,
    ]);
}); // ROLLBACK відкотить і зміну, і подію — фантомних подій не буває

// 2. Релей: окремий процес, крутиться в циклі. Публікує вже після коміту.
final readonly class OutboxRelay
{
    public function __construct(private Publisher $broker) {}

    public function drainBatch(int $limit = 100): int
    {
        return DB::transaction(function () use ($limit): int {
            // SKIP LOCKED: кілька релеїв беруть різні пачки (PostgreSQL 9.5+, MySQL 8.0+)
            $rows = DB::table('outbox')
                ->whereNull('sent_at')
                ->orderBy('id')
                ->limit($limit)
                ->lockForUpdate()->skipLocked()
                ->get();

            foreach ($rows as $row) {
                // Падіння тут -> рядок лишиться невідправленим -> повтор.
                // Падіння після publish, але до update -> дубль. Звідси at-least-once.
                $this->broker->publish($row->type, $row->aggregate_id, $row->payload, $row->message_id);
                DB::table('outbox')->where('id', $row->id)->update(['sent_at' => now()]);
            }

            return $rows->count();
        });
    }
}
Чітке формулювання проблеми: база й брокер — два різні сховища без спільної транзакції, тому будь-який порядок «запис + publish» ламається на падінні між ними.
Що outbox не робить доставку exactly-once: він гарантує «подія не загубиться і не зʼявиться без коміту», але дублі неминучі, бо релей може впасти між publish і позначкою sent.
Що релей має брати рядки через `SELECT ... FOR UPDATE SKIP LOCKED` (MySQL 8.0+, PostgreSQL 9.5+), а не через високу водяну позначку по `id` — інакше на PostgreSQL він пропускатиме рядки з меншим id, закомічені пізніше.
Що в outbox кладуть уже серіалізований payload на момент транзакції, а не лише id сутності: релей, який читає поточний стан, віддасть стан пізнішого часу і зруйнує сенс події.
Що на боці споживача пару виду outbox/inbox замикає таблиця оброблених message_id, а не «сподіваємось, брокер не задублює».
Що черга на базі (`messenger_messages` у Symfony Messenger, `jobs` у Laravel) на тому ж зʼєднанні й у тій же транзакції — це вже outbox, і окрему таблицю вигадувати не треба.
Плутати outbox з `dispatch()->afterCommit()` у Laravel чи `DispatchAfterCurrentBusMiddleware` у Symfony: вони лише зсувають відправлення за коміт, але між COMMIT і `publish()` процес усе одно може впасти, і подія зникне назавжди.
Публікувати в брокер усередині транзакції: якщо далі буде ROLLBACK, споживачі вже отримали подію про те, чого не сталося, і компенсувати це нічим.
Обіцяти exactly-once і не робити дедуплікацію на споживачі: дублі — нормальний режим роботи outbox, а не аварія.
Тягнути XA / двофазний коміт: Kafka, Redis, SQS його не підтримують у потрібному вигляді, а MySQL XA історично має проблеми з відновленням підвішених гілок; ціна не окупається.
Забирати рядки простим `SELECT ... LIMIT 100` без блокування і запускати два релеї: обидва прочитають ту саму сотню і опублікують усе двічі без потреби.
Не чистити таблицю: outbox росте вічно, а в PostgreSQL постійні UPDATE+DELETE ще й дають роздування таблиці й тиск на autovacuum.
ПОРАДА

Скажіть одним реченням, звідки проблема: «база й брокер — два сховища без спільної транзакції, тож атомарним може бути лише запис у ту саму базу». І одразу назвіть ціну: at-least-once, тому дедуплікація на споживачі — частина патерна, а не окрема опція.

Сторінка питання →
ARC
Архітектура·Senior ·ідемпотентність ·платежі ·черги

Клієнт передає idempotency key, сервер зберігає його разом із результатом операції під унікальним індексом і на повторний запит повертає збережений результат замість другого платежу.

Клієнт натиснув «Оплатити» двічі: як не списати гроші двічі?
Де зберігати idempotency key і скільки?
Що робити, якщо вебхук від платіжки приходить повторно?

Повтори в платіжній системі неминучі: подвійний клік, таймаут мережі з автоматичним retry, redelivery з черги, повторний вебхук провайдера. Ідемпотентність означає, що будь-який із цих повторів дає той самий результат, що й перший виклик, без другого списання.

Базовий механізм — idempotency key. Клієнт генерує унікальний ключ на бізнес-операцію, а не на HTTP-запит, і передає його заголовком. Сервер атомарно резервує ключ у таблиці з унікальним індексом, виконує операцію й зберігає результат разом із ключем. Повторний запит із тим самим ключем отримує збережену відповідь. Той самий ключ передається платіжному провайдеру, тому навіть падіння між викликом провайдера і збереженням результату не створює другого платежу.

Гонку закриває саме унікальний індекс, а не перевірка в коді: два одночасні запити проходять if (! exists) разом, але лише один пройде INSERT. Стан ключа має проміжне значення processing, щоб повтор під час виконання отримав 409 з Retry-After, а не чужий результат і не другу спробу. Ключ із тим самим значенням, але іншим тілом запиту повертає 422.

Вебхуки провайдера обробляються за тим самим принципом: дедуплікація по event id через унікальний індекс і перевірка стану агрегату перед кожною дією, бо доставка гарантована щонайменше один раз, а не рівно один. Ключі мають час життя, зазвичай від доби до трьох, і чистяться фоновим процесом.

// CREATE TABLE idempotency_keys (key text, client_id bigint, request_hash text,
//   status text, response_code int, response_body jsonb, created_at timestamptz,
//   PRIMARY KEY (client_id, key));

final class ChargeAction
{
    public function __invoke(ChargeRequest $request): JsonResponse
    {
        $key = $request->header('Idempotency-Key') ?? abort(400, 'Idempotency-Key required');
        $hash = hash('sha256', $request->getContent());

        // 1. Атомарне резервування ключа: унікальний індекс закриває гонку
        try {
            IdempotencyKey::create(['key' => $key, 'client_id' => $request->clientId(), 'request_hash' => $hash, 'status' => 'processing']);
        } catch (UniqueConstraintViolationException) {
            $existing = IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)->firstOrFail();

            if ($existing->request_hash !== $hash) {
                abort(422, 'Idempotency-Key reused with a different payload');
            }
            if ($existing->status === 'processing') {
                return response()->json(['status' => 'processing'], 409)->header('Retry-After', '2');
            }

            return response()->json($existing->response_body, $existing->response_code); // збережений результат
        }

        // 2. Сама операція: провайдер отримує той самий ключ, повтор не створить другий платіж
        $charge = $this->gateway->charge($request->amount(), $request->card(), idempotencyKey: $key);

        // 3. Результат зберігається разом з ключем в одній транзакції
        DB::transaction(function () use ($key, $request, $charge) {
            Payment::create(['charge_id' => $charge->id, 'amount' => $request->amount()]);
            IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)
                ->update(['status' => 'succeeded', 'response_code' => 201, 'response_body' => ['charge' => $charge->id]]);
        });

        return response()->json(['charge' => $charge->id], 201);
    }
}
Що ідемпотентність означає однаковий результат при повторі, і що повтори неминучі: подвійний клік, таймаут із retry, redelivery з черги, повторний вебхук.
Що ключ генерує клієнт на одну бізнес-операцію, а не на запит, і сервер зберігає його разом із результатом під унікальним індексом.
Що унікальний індекс у базі є останньою лінією захисту: перевірка існування в коді не закриває гонку двох одночасних запитів.
Що обробка йде через стани: pending, processing, succeeded, failed, і повтор під час processing отримує 409 або чекає, а після завершення отримує збережену відповідь.
Що вебхуки платіжного провайдера дедуплікуються по event id, а стан агрегату перевіряється перед кожною дією, бо доставка щонайменше один раз.
Перевіряти if (! exists) у коді й вважати, що цього досить: два запити проходять перевірку одночасно.
Генерувати ключ на сервері на кожен запит: повторний запит отримує новий ключ і створює другий платіж.
Повертати на повтор новий результат замість збереженого, або повертати 200 без тіла, ламаючи клієнта.
Зберігати ключі вічно без TTL або, навпаки, лише в Redis без гарантій, втрачаючи їх при рестарті.
Не розрізняти повтор того самого запиту й інший запит з тим самим ключем: другий має отримувати 422, а не чужий результат.
ПОРАДА

Згадайте унікальний індекс у БД як останню лінію захисту: перевірка в коді гонку не закриває. І назвіть, як обробляєте повтор під час processing, а не лише після завершення.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти
ПІДТЕМИ
Межі сервісів Ідемпотентність Кеш-стратегії Черги Моноліт і сервіси Компроміси
НА ЧОМУ ВАЛЯТЬСЯ

Тут немає правильних відповідей — перевіряють, чи ви бачите компроміси. Сильна відповідь завжди містить «за яку ціну».