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

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

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

Тема
Рівень
4 питання
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, а не лише після завершення.

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