Another pattern - lets talk about state machines
The state machine pattern isn't something you often hear about, but its critical to almost every piece of technology you experience in every day life
State machines are managers of 'state' and state, as defined by google is 'the current condition or values of a program, system or device at a given time'. Often reserved for logic boards, state machines can be useful in every day web applications.
With that in mind, lets imagine we're building an e-commerce platform. e-commerce systems accept orders and orders have a very procedural flow.
In our example, we'll have orders. Orders have a status that will inherit the below flow:
All of these order statuses are examples of state - the current status, and as such the state of the order record itself. You wouldn't expect - or perhaps more importantly wouldn't want - an order that is currently pending
to somehow get marked as refunded
.
So with that, it's time to manage our state using the concept of state machines. They enforces rules on managing state within your application and ensure its practically impossible for things like this to happen.
Generally, when I use the state machine pattern, I build it out in four parts. I'll go through each in detail. These are:
- Interface
- Abstraction
- State machines
- Implementation
The interface
Lets get going with the interface. As we'll be managing the state of our order, we'll call it OrderStateContract
. This simply templates what our state machines should look like.
Each different state machine will implement this interface, and in turn all of the methods herein. Each of these classes are a machine, that handles and builds the relevant state, hence the terminology 'State Machine'.
For our simple e-commerce application, we'll be sticking with the five different states - or statuses - that we detailed above.
interface OrderStateContract
{
public function __construct(Order $order);
public function pay(): void;
public function ship(): void;
public function cancel(): void;
public function refund(): void;
}
The abstract
That's it for the interface, now onto the abstract. We'll call this OrderState
and our machines will extend from this. You'll see why in a moment - but the state machine pattern can be quite repetitive!
By default, every method in our abstract will throw an exception. This will prevent us from executing any unwanted state changes.
abstract class OrderState implements OrderStateContract
{
public function __construct(public Order $order)
{
//
}
public function pay(): void
{
throw new Exception();
}
public function ship(): void
{
throw new Exception();
}
public function cancel(): void
{
throw new Exception();
}
public function refund(): void
{
throw new Exception();
}
}
The state machines
With that done, it's time to consider our states. I think it's reasonable (for a simple implementation to make the below assumptions:
- Pending - we can pay or cancel the order
- Paid - we can mark the order as shipped or refund
- Shipped, Cancelled or Refunded - the order is beyond handling, allow nothing!
Ok, so lets create our machines. Remember, that each machine extends OrderState
and we'll only implement the methods that are valid for that state.
/**
* From pending, we can mark an order as paid, or cancelled
*/
class PendingState extends OrderState
{
public function pay(): void
{
$this->order->setStatus('paid');
}
public function cancel(): void
{
$this->order->setStatus('cancelled');
}
}
/**
* From paid, it can then be shipped, or refunded
*/
class PaidState extends OrderState
{
public function ship(): void
{
// ToDo: shipping logic
$this->order->setStatus('shipped');
}
public function refund(): void
{
// ToDo: refund logic
$this->order->setStatus('refunded');
}
}
/**
* Once an order is shipped, we cant do anything else with it
*/
class ShippedState extends OrderState
{
//
}
/**
* Once an order is cancelled, we cant do anything else with it
*/
class CancelledState extends OrderState
{
//
}
/**
* Once an order is refunded, we cant do anything else with it
*/
class RefundedState extends OrderState
{
//
}
And that's it, with that we've created five shiny new state machines! Three of them don't do a lot and hopefully you can see why we created the abstract. Without it, every machine would need five methods creating a lot of extra unnecessary code.
The implementation
Now, we've got a load of classes, but here's where it comes together. Within our Order
model, lets create a state
method which allows us to grab the relevant state machine. We'll use a PHP match to quickly generate and return the relevant state machine.
We'll keep this class simple for the purpose of the demonstration, it just has a string called status
.
class Order
{
public function __construct(public string $status)
{
//
}
public function state(): OrderStateContract
{
return match ($this->status) {
'pending' => new PendingState($this),
'paid' => new PaidState($this),
'shipped' => new ShippedState($this),
'cancelled' => new CancelledState($this),
'refunded' => new RefundedState($this),
};
}
public function setStatus(string $status): void
{
$this->status = $status;
}
}
Hopefully you can start to see what's going on here. No? Well lets have a look at a few use cases where we try and manipulate the state of an order:
// a pending order
(new Order('pending'))->state()->pay(); // pending order is marked as paid
(new Order('pending'))->state()->ship(): // EXCEPTION! you cant ship a pending order!
(new Order('pending'))->state()->cancel(); // pending order is cancelled
(new Order('pending'))->state()->refund(); // EXCEPTION! you cant refund a pending order!
// a paid order
(new Order('paid'))->state()->pay(); // EXCEPTION! you cant pay a paid order!
(new Order('paid'))->state()->ship(); // paid order is marked as shipped
(new Order('paid'))->state()->cancel(); // EXCEPTION! you cant cancel a paid order!
(new Order('paid'))->state()->refund(); // paid order is marked as refunded
// a cancelled order
(new Order('cancelled'))->state()->pay(); // EXCEPTION! you cant pay a cancelled order!
(new Order('cancelled'))->state()->ship(); // EXCEPTION! you cant ship a cancelled order!
(new Order('cancelled'))->state()->cancel(); // EXCEPTION! you cant cancel a cancelled order!
(new Order('cancelled'))->state()->refund(); // EXCEPTION! you cant refund a cancelled order!
Benefits of the State Machine Pattern
The state machine brings with it a number of benefits that'll not only make your code better, but also more robust.
Improved maintainability
With state-specific logic tucked away inside your state machines, you create a more organised and modular codebase. You know exactly where the specific logic is, making it easier to understand the systems behaviour, locating and fixing bugs, as well as adding new features or modifying existing ones.
Reduced complexity
When there's a lot of state to handle and intricate transitions, state machines offer a structured approach to manage complexity. They break down the logic into smaller, more manageable units.
Reduced ambiguity
Not only does it make it easier for another developer to pick up and easily understand your code, it ensures that the system behaves in a predictable manner, reducing the risk of unexpected state changes.
Better testability
With modularisation always comes the benefit of improved testability. You can test each state machine in isolation, ensuring that your system behaves as you'd expect under various conditions, without having to build out your tests too much.
Lets summarise
As you can see state machines are powerful tools to ensure a strict implementation of state and 'guard' against unexpected state changes.
State machines are robust and elegant and offer many advantages over other state management patterns. Due to the intial complexity of setup, it's important that you evaluate whether this is suited to your project, as it might not be perfect for everyone.