Order workflows start simple. An order is placed, then it ships. Then someone adds a quality check, a rejection path, a cancellation rule, and suddenly the model has six boolean flags and nobody is sure which combinations are real. Modeling the workflow as an explicit state machine fixes that.
The problem with boolean soup
Flags like is_approved, is_shipped, and
is_cancelled can each be true or false independently. That
is eight combinations for three flags, and most of them should never
exist. The data model is lying about what is possible.
Name the states
A real order has one status at a time. Django TextChoices
makes that set explicit and self-documenting.
from django.db import models
class OrderStatus(models.TextChoices):
RECEIVED = "received", "Order received"
QC_PENDING = "qc_pending", "QC pending"
QC_APPROVED = "qc_approved", "QC approved"
QC_REJECTED = "qc_rejected", "QC rejected"
READY = "ready", "Ready to dispatch"
DISPATCHED = "dispatched", "Dispatched"
CANCELLED = "cancelled", "Cancelled"
Declare the legal transitions
The heart of the machine is a map from each state to the states it is allowed to reach. If a move is not in the map, it does not happen.
TRANSITIONS = {
OrderStatus.RECEIVED: {OrderStatus.QC_PENDING, OrderStatus.CANCELLED},
OrderStatus.QC_PENDING: {OrderStatus.QC_APPROVED, OrderStatus.QC_REJECTED},
OrderStatus.QC_REJECTED: {OrderStatus.QC_PENDING, OrderStatus.CANCELLED},
OrderStatus.QC_APPROVED: {OrderStatus.READY, OrderStatus.CANCELLED},
OrderStatus.READY: {OrderStatus.DISPATCHED},
OrderStatus.DISPATCHED: set(),
OrderStatus.CANCELLED: set(),
}
One method guards every change
No code anywhere assigns status directly. Every change
goes through a single method that checks the map first.
class InvalidTransition(Exception):
pass
class Order(models.Model):
status = models.CharField(
max_length=20,
choices=OrderStatus.choices,
default=OrderStatus.RECEIVED,
)
def transition_to(self, new_status, *, by=None):
if new_status not in TRANSITIONS[self.status]:
raise InvalidTransition(
f"Cannot move from {self.status} to {new_status}."
)
self.status = new_status
self.save(update_fields=["status"])
OrderEvent.objects.create(order=self, status=new_status, actor=by)
The machine writes its own audit log
Because every transition runs through one method, recording an
OrderEvent there gives you a complete history for free.
When support asks why an order is stuck, the answer is a single query.
Testing becomes trivial
With illegal moves rejected by construction, tests read like the business rules they protect.
import pytest
def test_cannot_dispatch_before_qc():
order = OrderFactory(status=OrderStatus.RECEIVED)
with pytest.raises(InvalidTransition):
order.transition_to(OrderStatus.DISPATCHED)
When to reach for this
Not every model needs a state machine. But the moment a workflow has more than two or three statuses, and especially when it has loops or dead ends, an explicit transition map turns a class of bugs into a single rejected exception.