Обидва патерни ховають варіанти поведінки за спільним інтерфейсом, і контекст делегує виклик поточній реалізації. Відрізняє їх ініціатива. Стратегію хтось вибирає ззовні: 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(); }
}
Сформулюйте різницю одним реченням: «стратегію мені дають, стан я змінюю сам». Потім покажіть, де живе заборонений перехід: виняток у методі класу стану або відсутній transition у конфігу Workflow. Це і є та гарантія, якої немає у `switch`.