anishfyi.com Projects All writings

April 28, 20269 min readDjango, Architecture, Patterns

Modeling an Order Workflow as a State Machine

Turning a tangle of status flags into an explicit, testable state machine that guards every order transition.

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.

Working on something in this territory? Book thirty minutes.