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

Command і command bus: чим вони відрізняються від queued job і коли шина справді потрібна?

Command пакує намір разом з усіма даними в обʼєкт, щоб виконати його зараз, пізніше, повторно або з іншої точки входу. Command bus додає одне місце, через яке проходять усі команди: він знаходить обробник за класом команди й загортає виконання в middleware для транзакцій, логування й валідації. Queued job у Laravel - це команда й обробник в одному класі з доставкою через чергу, а Symfony Messenger тримає повідомлення й обробник окремо і вирішує, синхронно чи через транспорт, у конфігу `routing`.

Навіщо command bus, якщо в Laravel вже є jobs?
Одна й та сама дія має запускатися з контролера, з Artisan-команди й з Telegram-бота. Як би ви це спроєктували?
Job отримав у конструктор масив з даними вакансії і через годину опублікував застарілий текст. Що не так з командою?
Чи має команда повертати результат? А якщо вона йде в чергу?
design patterns Command command bus черги Laravel Bus Symfony Messenger ідемпотентність

Патерн Command перетворює виклик методу на обʼєкт: PublishVacancy з ідентифікатором вакансії й автором дії. Метод виконується лише тут і зараз, а обʼєкт можна передати далі: у шину, у чергу, у журнал, у повтор після збою. Тому команда несе тільки дані, потрібні для виконання, і в ній немає ні моделі цілком, ні сервісів. Обробник отримує команду, читає свіжий стан, перевіряє правила й виконує дію.

Command bus додає до цього одне місце, через яке проходять усі команди. Шина за класом команди знаходить обробник, тож контролер, Artisan-команда й бот не знають, хто саме виконає дію, і викликають її однаково. Друга вигода - middleware навколо кожної команди: транзакція, логування, валідація, метрики пишуться один раз, а не в кожному обробнику. У Laravel шина вже є: Bus::dispatch() для класу без ShouldQueue виконує команду синхронно й повертає результат, Bus::map() відокремлює обробник, Bus::pipeThrough() задає middleware. У Symfony ту саму роль грає Messenger: повідомлення окремим класом, обробник з #[AsMessageHandler], middleware на шині.

Queued job у Laravel - це та сама команда, у якої обробник живе в її ж методі handle(), а доставку через чергу вмикає ShouldQueue. Messenger розділяє ці речі: код викликає dispatch(), а чи виконати повідомлення одразу, чи відправити на транспорт, вирішує routing у конфігу. В обох випадках відкладене виконання змінює вимоги до обробника. Дані можуть застаріти, тож обробник перечитує стан за ідентифікатором. Доставка at-least-once, тож повтор не повинен опублікувати вакансію двічі. А постановка в чергу всередині транзакції має чекати коміту, у Laravel через afterCommit() чи after_commit у конфігу зʼєднання черги.

Шина окупається не завжди. Якщо дія викликається з одного місця, швидка й черга їй не потрібна, команда, обробник і шина лише розносять три рядки по трьох файлах, і прямий виклик сервісу застосунку читається краще. Окремі CommandBus і QueryBus мають сенс, коли в них справді різні middleware, наприклад транзакція лише для команд. Поділ «для порядку», з маркерними інтерфейсами на кожен клас, додає церемонію без вигоди.

// Команда: намір і дані, лише ідентифікатори й скаляри
final readonly class PublishVacancy
{
    public function __construct(public int $vacancyId, public int $publishedBy) {}
}

// Обробник окремо: свіжий стан, правила домену, ідемпотентність
final class PublishVacancyHandler
{
    public function __construct(private VacancyRepository $vacancies) {}

    public function handle(PublishVacancy $command): string
    {
        $vacancy = $this->vacancies->get($command->vacancyId);

        if ($vacancy->isPublished()) {
            return 'вже опубліковано'; // повтор з черги нічого не ламає
        }

        $vacancy->publish($command->publishedBy);
        $this->vacancies->save($vacancy);

        return 'опубліковано';
    }
}

// AppServiceProvider::boot(): шина знаходить обробник за класом команди
Bus::map([PublishVacancy::class => PublishVacancyHandler::class]);
Bus::pipeThrough([WrapInTransaction::class]); // ваш pipe: handle($command, $next)

// Контролер, Artisan-команда й бот: синхронно, з результатом
$status = Bus::dispatch(new PublishVacancy($vacancyId, $userId));

// Відкладено: job з ShouldQueue - команда й обробник в одному класі
final class PublishVacancyLater implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $vacancyId, public int $publishedBy) {}

    public function handle(Dispatcher $bus): void
    {
        $bus->dispatch(new PublishVacancy($this->vacancyId, $this->publishedBy));
    }
}
Що команда - це намір у наказовому способі (`PublishVacancy`) з ідентифікаторами й скалярами, а не модель і не сервіси, і що вона відрізняється від події одним обробником і тим, що відправнику важливий результат.
Що шина дає одну точку для наскрізних речей (транзакція, лог, валідація, ретраї) і відвʼязує точку входу від обробника, а не «потрібна для CQRS».
Що кандидат знає Laravel `Bus`: `dispatch()` для класу без `ShouldQueue` виконує його синхронно й повертає результат, `Bus::map()` дає окремий обробник, `Bus::pipeThrough()` додає middleware.
Що знає Messenger: `#[AsMessageHandler]`, routing на транспорт, синхронне виконання без маршруту, `HandleTrait` для результату, кілька шин з різним набором middleware.
Що обробник у черзі отримує дані at-least-once і пізніше, ніж їх поставили, тому читає свіжий стан, перевіряє правила в момент виконання й лишається ідемпотентним.
Класти в команду для черги масив атрибутів або живі сервіси: дані застаріють до виконання, а сервіси не серіалізуються. Кладуть ідентифікатори, а обробник читає свіжий стан; моделі в Laravel job з `SerializesModels` теж серіалізуються лише ключем.
Розраховувати, що обробник виконається рівно один раз: черга доставляє at-least-once, і повтор після таймауту воркера опублікує вакансію вдруге, якщо обробник не перевіряє стан.
Ставити job у чергу всередині `DB::transaction()` без `afterCommit()`: воркер може взяти job раніше за коміт і не знайти запис або побачити старий стан.
Чекати в контролері результат команди, що пішла в чергу: `Job::dispatch()` поверне `PendingDispatch`, Messenger поверне `Envelope`, а результату там не буде. Ідентифікатор нового запису генерують заздалегідь (UUID) і передають у команду.
Будувати `CommandBus`, `QueryBus` і `EventBus` з маркерними інтерфейсами для застосунку з пʼятьма сценаріями й однією точкою входу, де прямий виклик сервісу читався б простіше.
ПОРАДА

Скажіть, що команда - це обʼєкт-намір, а шина - місце для всього, що має відбуватися навколо будь-якої команди. Потім поставте поруч Laravel job (команда й обробник разом, черга через `ShouldQueue`) і Messenger (окремо, транспорт у конфігу). Закінчіть ідемпотентністю обробника: саме вона показує, що ви запускали це в продакшені.

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

Команда існує незалежно від event sourcing і черг: це обʼєкт-намір, який можна виконати зараз, пізніше чи повторно. Job з `ShouldQueue` якраз і є командою з обробником у `handle()`, просто доставленою через чергу. Подія відрізняється від команди тим, що описує факт у минулому і може мати нуль або багато слухачів.