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

Strategy і State: у чому різниця, якщо діаграма класів однакова?

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

У сервісі замовлень `switch ($order->status)` уже в семи методах. Як би ви це переписали?
Strategy і State на діаграмі виглядають однаково: контекст, інтерфейс, кілька реалізацій. Чим вони відрізняються?
Додали статус «повернення», і скасоване замовлення почало відправлятися. Як зробити, щоб новий статус не можна було врахувати лише наполовину?
Коли для статусу вистачить enum, а коли потрібні окремі класи станів?
design patterns Strategy State enum state machine Symfony Workflow

Обидва патерни ховають варіанти поведінки за спільним інтерфейсом, і контекст делегує виклик поточній реалізації. Відрізняє їх ініціатива. Стратегію хтось вибирає ззовні: Checkout отримує ShippingRate у конструкторі, CacheManager створює драйвер за CACHE_STORE, і після цього стратегія лишається тією самою, доки її не замінить той, хто збирав обʼєкт. Стан змінюється зсередини: виклик pay() повертає наступний стан, і контекст сам перемикається на нього. Тому стратегії одна про одну не знають, а кожен клас стану знає щонайменше, куди з нього можна перейти.

Типовий шлях до State починається зі switch ($order->status), який повторюється в pay(), cancel(), ship() і ще в кількох методах. Новий статус треба врахувати в кожній гілці кожного методу, і пропущена гілка нічим себе не видає: метод мовчки нічого не робить або дозволяє недозволене. Коли кожен статус стає класом з повним інтерфейсом, новий статус означає новий клас, і інтерфейс змушує вирішити, що в ньому відбувається з кожною дією. Заборонений перехід стає явним рядком з винятком.

На практиці є три рівні, і вибір між ними залежить від кількості статусів і того, наскільки різна в них поведінка. Для двох-трьох статусів зі схожою логікою вистачає backed enum з методом canBecome() і match у ньому, Eloquent сам перетворить стовпець на enum через casts(). Коли в статусах різні правила, розрахунки й дозволені дії, кожен статус стає класом, як у прикладі. Коли переходів десятки, вони мають guard-умови й слухачів, а бізнес хоче бачити граф, у Symfony беруть компонент Workflow з типом state_machine; у Laravel готового аналога немає, і там лишаються enum або класи станів.

Межі патерну варто назвати самому. Класи станів не повинні знати про базу, пошту чи чергу: вони повертають наступний статус або кидають виняток, а зберігає й повідомляє сервіс застосунку. Вони також перевіряють правило лише в одному процесі, тому паралельні переходи ловлять на записі умовним UPDATE ... WHERE status = ? або версією рядка. І найчастіша пастка: фабрика, що вибирає «стратегію» за статусом обʼєкта, виглядає як Strategy, але насправді це State без правил переходів, і перевіряти дозволеність переходу там нікому.

// У базі статус лишається рядком: enum відновлює з нього обʼєкт стану
enum OrderStatus: string
{
    case New = 'new';
    case Paid = 'paid';
    case Cancelled = 'cancelled';

    public function state(): OrderState
    {
        return match ($this) {
            self::New => new NewOrder(),
            self::Paid => new PaidOrder(),
            self::Cancelled => new CancelledOrder(),
        };
    }
}

interface OrderState
{
    public function pay(): OrderStatus;    // повертає наступний статус
    public function cancel(): OrderStatus; // або кидає виняток
}

final class NewOrder implements OrderState
{
    public function pay(): OrderStatus { return OrderStatus::Paid; }
    public function cancel(): OrderStatus { return OrderStatus::Cancelled; }
}

final class PaidOrder implements OrderState
{
    public function pay(): OrderStatus { throw new DomainException('Замовлення вже оплачене'); }
    // Оплачене скасовують через повернення коштів, а не зміною статусу
    public function cancel(): OrderStatus { throw new DomainException('Спершу оформіть повернення'); }
}

final class CancelledOrder implements OrderState
{
    public function pay(): OrderStatus { throw new DomainException('Скасоване не оплачують'); }
    public function cancel(): OrderStatus { throw new DomainException('Вже скасоване'); }
}

final class Order
{
    public function __construct(private OrderStatus $status) {}

    // Контекст сам перемикає стан після дії, ззовні його ніхто не підставляє
    public function pay(): void { $this->status = $this->status->state()->pay(); }
    public function cancel(): void { $this->status = $this->status->state()->cancel(); }
}
Що різницю пояснюють через ініціативу заміни: стратегію підставляють ззовні (конструктор, конфіг, фабрика), стан змінюється всередині контексту як наслідок виклику `pay()` чи `cancel()`.
Що стратегії взаємозамінні й незалежні, а стани утворюють граф переходів, де заборонений перехід є явною частиною моделі, а не гілкою, яку забули дописати.
Що кандидат вибирає між backed enum з `match`, класами станів і готовою машиною станів (Symfony Workflow) за кількістю статусів і тим, наскільки різна поведінка в кожному.
Що в базі лежить рядок або значення enum, а обʼєкт стану відновлюється під час завантаження і сам не серіалізується.
Що класи станів не захищають від гонок між процесами, і перехід треба підтвердити на записі: умовний `UPDATE` або оптимістичне блокування.
Відповісти «State - це Strategy, яка змінюється» і на цьому зупинитися, не сказавши, хто ініціює зміну і де живуть правила переходів.
Вибирати реалізацію у фабриці за статусом (`match ($order->status)` повертає різні `PricingStrategy`) і називати це стратегією: вийшов прихований State без правил переходів, і недозволений перехід ніхто не перевіряє.
Переписати `switch` на класи станів, але залишити `if ($order->status === 'paid')` у контролерах і Blade-шаблонах: правила знову розкидані, а класи стають декорацією.
Давати класам станів писати в базу чи слати листи: стан повертає наступний стан або кидає виняток, а збереження й побічні ефекти лишаються в сервісі застосунку.
Робити класи станів для двох статусів з майже однаковою поведінкою, де вистачило б bool або enum з методом `canBecome()`.
ПОРАДА

Сформулюйте різницю одним реченням: «стратегію мені дають, стан я змінюю сам». Потім покажіть, де живе заборонений перехід: виняток у методі класу стану або відсутній transition у конфігу Workflow. Це і є та гарантія, якої немає у `switch`.

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

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