DEV Community

Cover image for Road to State Machines Part III - But How Do We Represent the Entire Lifecycle as One Behavioral Contract?
Can Burak Sofyalioglu
Can Burak Sofyalioglu

Posted on Originally published at x.com

Road to State Machines Part III - But How Do We Represent the Entire Lifecycle as One Behavioral Contract?

Our operations now reject invalid changes.

capture_payment() requires a valid created order and a payment record. ship_order() requires a valid paid order. Both inspect the existing record before changing it.

But suppose we ask:

What operations are available to a paid order?

We still have to inspect the functions.

One function says that payment capture requires created. Another says that shipping requires paid. Their assignments tell us where the order goes afterward.

The lifecycle exists, but we must reconstruct it from the implementation.

How do we make that lifecycle explicit and make the operations use it?

Start by writing down the rules we already have

We do not need more states yet. Our model still has three:

created → paid → shipped
Enter fullscreen mode Exit fullscreen mode

It also has two operations:

capture_payment(order, payment)
ship_order(order)
Enter fullscreen mode Exit fullscreen mode

For a valid order, the status-based rules are:

Current status Capture payment Ship order
created Move to paid Reject
paid Reject Move to shipped
shipped Reject Reject

This table contains six combinations. Only two permit a change.

The payment argument still needs validation. The table does not claim that every call to capture_payment() from created is valid, regardless of its arguments. It describes which operations are eligible from each lifecycle state.

That distinction preserves the separation we established in Part II:

The current record must be consistent. The requested movement must also be permitted.

Until now, this table was a summary we produced by reading the code. Let’s reverse that relationship.

The table should define the lifecycle. The operations should consult it.

Give the vocabulary explicit names

First, let’s replace the unrestricted status vocabulary with named values:

from enum import Enum


class OrderState(Enum):
    CREATED = "created"
    PAID = "paid"
    SHIPPED = "shipped"
Enter fullscreen mode Exit fullscreen mode

An enumeration defines a set of named members. Each member here retains the lowercase string we already use to represent the status.[1]

We can distinguish the member from its external representation:

OrderState.PAID
OrderState.PAID.value  # "paid"
Enter fullscreen mode Exit fullscreen mode

We also need names for the inputs that request a change:

class OrderCommand(Enum):
    CAPTURE_PAYMENT = "capture_payment"
    SHIP_ORDER = "ship_order"
Enter fullscreen mode Exit fullscreen mode

We will call these commands because they request operations. They do not announce that the operations have already succeeded.

SHIP_ORDER means “attempt the shipping operation.” It does not mean that a carrier has confirmed shipment.

Likewise, CAPTURE_PAYMENT identifies our existing simulated operation, which records a supplied Payment. We have not added a payment-provider call.

These names give the lifecycle two distinct vocabularies:

States:
    Where is the order?

Commands:
    What operation is being requested?
Enter fullscreen mode Exit fullscreen mode

Update the model without changing its supporting data

The Payment class remains unchanged. In Order, only the status representation changes:

from dataclasses import dataclass
from decimal import Decimal
from typing import Optional


@dataclass
class Payment:
    method: str
    provider: str
    payment_id: str
    captured_amount: Decimal


@dataclass
class Order:
    id: str
    customer_name: str
    delivery_address: str
    status: OrderState = OrderState.CREATED
    payment: Optional[Payment] = None
Enter fullscreen mode Exit fullscreen mode

A newly constructed order now starts with OrderState.CREATED.

When loading a status from an external representation, we can convert it explicitly:

state = OrderState("paid")

assert state is OrderState.PAID
Enter fullscreen mode Exit fullscreen mode

Unknown spellings are rejected by the enum lookup:

OrderState("shiped")  # Raises ValueError.
OrderState("PAID")    # Raises ValueError.
Enter fullscreen mode Exit fullscreen mode

We have not introduced case normalization or guessed what an unknown value means.[1]

However, changing the annotation does not make arbitrary assignment impossible:

order.status = "something_else"
Enter fullscreen mode Exit fullscreen mode

A normal dataclass does not enforce field annotations at runtime.[2] The operation boundary must still validate incoming records.

The enum gives us a defined vocabulary. Validation determines whether a particular record uses it correctly.

Turn the transition table into executable data

Each permitted transition needs three pieces of information:

current state
requested command
next state
Enter fullscreen mode Exit fullscreen mode

We can represent the first two as a dictionary key and the third as its value:

from types import MappingProxyType
from typing import Mapping


TRANSITIONS: Mapping[
    tuple[OrderState, OrderCommand],
    OrderState,
] = MappingProxyType({
    (
        OrderState.CREATED,
        OrderCommand.CAPTURE_PAYMENT,
    ): OrderState.PAID,

    (
        OrderState.PAID,
        OrderCommand.SHIP_ORDER,
    ): OrderState.SHIPPED,
})
Enter fullscreen mode Exit fullscreen mode

MappingProxyType provides a read-only view of the mapping. Ordinary callers cannot insert another transition through TRANSITIONS.[3]

We have stored only the permitted changes. The rejected combinations remain absent.

Now we need a function that interprets the table. We keep the exception types from Part II:

class InvalidOrderOperation(ValueError):
    pass


class InvalidOrderState(ValueError):
    pass


def next_state(
    state: OrderState,
    command: OrderCommand,
) -> OrderState:
    if not isinstance(state, OrderState):
        raise InvalidOrderState(
            "status must be an OrderState member."
        )

    if not isinstance(command, OrderCommand):
        raise TypeError(
            "command must be an OrderCommand member."
        )

    try:
        return TRANSITIONS[(state, command)]
    except KeyError:
        raise InvalidOrderOperation(
            f"Cannot {command.value} "
            f"while the order is {state.value!r}."
        ) from None
Enter fullscreen mode Exit fullscreen mode

The function answers one question:

Given this lifecycle state and this command, which next state is permitted?

For example:

assert next_state(
    OrderState.CREATED,
    OrderCommand.CAPTURE_PAYMENT,
) is OrderState.PAID

assert next_state(
    OrderState.PAID,
    OrderCommand.SHIP_ORDER,
) is OrderState.SHIPPED
Enter fullscreen mode Exit fullscreen mode

But this is rejected:

next_state(
    OrderState.CREATED,
    OrderCommand.SHIP_ORDER,
)
Enter fullscreen mode Exit fullscreen mode

The missing entry is not an implementation omission. It expresses the business rule that an unpaid order must not be shipped.

Notice what next_state() does not do.

It does not modify an order. It does not store payment information. It does not call a provider or create a shipment.

It selects a permitted destination or rejects the request.

We now have an explicit finite state machine

A finite state machine describes a finite set of states, an initial state, inputs, and rules that determine the next state from the current state and input.[4]

Our example has each of those elements:

States:
    CREATED, PAID, SHIPPED

Initial state:
    CREATED

Inputs:
    CAPTURE_PAYMENT, SHIP_ORDER

Permitted transitions:
    CREATED + CAPTURE_PAYMENT → PAID
    PAID + SHIP_ORDER → SHIPPED
Enter fullscreen mode Exit fullscreen mode

The same relationship can be written as:

next_state(current_state, command)
Enter fullscreen mode Exit fullscreen mode

or, using conventional mathematical notation:

δ(current_state, command) = next_state
Enter fullscreen mode Exit fullscreen mode

The symbol δ names the transition function. It does not introduce another mechanism.

Our transition model is deterministic: for a particular state and command, there is one declared destination or a defined rejection.

Mathematical treatments sometimes define a transition for every state–input pair. Our application uses a partial transition table: an absent pair means “reject without changing the order.” We do not silently treat rejection as success, nor move the order into a newly invented failure state.

SHIPPED is terminal within this model, because no supported command leaves it. That does not mean the real order can never be delivered or returned. Those processes are outside the lifecycle we have implemented.

“Finite” describes this control model. It does not mean that every possible customer name, delivery address, or payment record has been enumerated.

But the table does not replace the invariants

Consider:

order = Order(
    id="order-66",
    customer_name="Alice",
    delivery_address="Istanbul",
    status=OrderState.PAID,
    payment=None,
)
Enter fullscreen mode Exit fullscreen mode

The transition table contains:

PAID + SHIP_ORDER → SHIPPED
Enter fullscreen mode Exit fullscreen mode

But this particular order is inconsistent. Its payment record is missing.

The table cannot detect that because it receives only the lifecycle state and command.

Let’s update the validator to use our enum while preserving both payment rules:

def validate_order(order: Order) -> None:
    if not isinstance(order.status, OrderState):
        raise InvalidOrderState(
            "status must be an OrderState member."
        )

    if order.status is OrderState.CREATED:
        if order.payment is not None:
            raise InvalidOrderState(
                "A created order cannot contain a captured payment."
            )

    if order.status in (
        OrderState.PAID,
        OrderState.SHIPPED,
    ):
        if not isinstance(order.payment, Payment):
            raise InvalidOrderState(
                f"An order marked {order.status.value!r} "
                "must contain a Payment record."
            )
Enter fullscreen mode Exit fullscreen mode

A created order must still have no payment record. A paid or shipped order must still retain one.

We are not adding validation of payment amounts, currencies, or provider-side capture. The scope remains the same as in Part II.

We now have two deliberately separate functions:

validate_order(order):
    Is the existing configuration consistent?

next_state(state, command):
    Is this lifecycle movement permitted?
Enter fullscreen mode Exit fullscreen mode

Both are necessary.

Make the operations consult the table

We can now replace their status-specific conditions with calls to next_state():

def capture_payment(order: Order, payment: Payment) -> None:
    validate_order(order)

    target = next_state(
        order.status,
        OrderCommand.CAPTURE_PAYMENT,
    )

    if not isinstance(payment, Payment):
        raise TypeError("payment must be a Payment record.")

    order.payment = payment
    order.status = target


def ship_order(order: Order) -> None:
    validate_order(order)

    target = next_state(
        order.status,
        OrderCommand.SHIP_ORDER,
    )

    order.status = target
Enter fullscreen mode Exit fullscreen mode

Compare the new shipping operation with the previous version.

It no longer contains its own declaration that shipping requires paid, followed by an independent assignment to shipped. Both facts come from the transition table.

The same is true of payment capture. Its lifecycle rule comes from the table; the function remains responsible for checking and storing the payment argument.

This is the separation we wanted:

Existing-record consistency → validator

Lifecycle eligibility and destination → transition table

Operation-specific work → domain function
Enter fullscreen mode Exit fullscreen mode

We have not removed the domain functions. They still provide the meaningful interface:

capture_payment(order, payment)
ship_order(order)
Enter fullscreen mode Exit fullscreen mode

Nor have we introduced a generic function with several optional arguments whose meanings depend on which command was passed. For two operations, the explicit functions remain clearer.

All expected rejection checks still happen before mutation.

Payment capture still makes two assignments. As in Part II, this is a local, single-writer example not an atomic transaction or a crash-recovery mechanism.

Let the read path use the same lifecycle

Previously, code displaying available operations could repeat the status checks.

Now it can consult the same table:

def available_actions(order: Order) -> tuple[OrderCommand, ...]:
    validate_order(order)

    return tuple(
        command
        for command in OrderCommand
        if (order.status, command) in TRANSITIONS
    )
Enter fullscreen mode Exit fullscreen mode

The function validates the record first. An inconsistent order is not presented as though it were ready for normal processing.

Let’s follow one order through the complete sequence:

payment = Payment(
    method="credit_card",
    provider="stripe",
    payment_id="payment-81",
    captured_amount=Decimal("120.00"),
)

order = Order(
    id="order-66",
    customer_name="Alice",
    delivery_address="Istanbul",
)

assert available_actions(order) == (
    OrderCommand.CAPTURE_PAYMENT,
)

capture_payment(order, payment)

assert order.status is OrderState.PAID
assert order.payment is payment
assert available_actions(order) == (
    OrderCommand.SHIP_ORDER,
)

ship_order(order)

assert order.status is OrderState.SHIPPED
assert order.payment is payment
assert available_actions(order) == ()

validate_order(order)
Enter fullscreen mode Exit fullscreen mode

The write path and the action listing now agree because they read the same lifecycle definition.

But an available action is not a promise that a later call will succeed.

The listing describes eligibility for the current record. It does not supply a valid Payment argument, grant a user permission, or reserve the order against another change. The operation must still perform its checks when invoked.

Displaying an action does not replace enforcing it.

Test the whole transition surface

We now have a small, explicit surface to test: three states and two commands.

Let’s write expected outcomes independently of TRANSITIONS:

expected_transitions = {
    (
        OrderState.CREATED,
        OrderCommand.CAPTURE_PAYMENT,
    ): OrderState.PAID,

    (
        OrderState.PAID,
        OrderCommand.SHIP_ORDER,
    ): OrderState.SHIPPED,
}

for state in OrderState:
    for command in OrderCommand:
        expected = expected_transitions.get((state, command))

        try:
            actual = next_state(state, command)
        except InvalidOrderOperation:
            assert expected is None, (state, command)
        else:
            assert expected is not None, (state, command)
            assert actual is expected, (state, command)
Enter fullscreen mode Exit fullscreen mode

These assertions are test code, not production validation.

The test exercises all six combinations, including the four that must be rejected. Its expected outcomes describe our business specification rather than being calculated from the implementation table.

Simply iterating over TRANSITIONS and asserting that the lookup returns its stored values would be much weaker. That could confirm that the dictionary works while overlooking an incorrectly permitted transition.

We must also keep the record-level tests from Part II.

For example, rejecting an unpaid shipment must preserve the order:

unpaid_order = Order(
    id="order-67",
    customer_name="Alice",
    delivery_address="Istanbul",
)

try:
    ship_order(unpaid_order)
except InvalidOrderOperation:
    pass
else:
    raise AssertionError("An unpaid order was allowed to ship.")

assert unpaid_order.status is OrderState.CREATED
assert unpaid_order.payment is None
Enter fullscreen mode Exit fullscreen mode

The matrix test does not replace tests for missing payments, captured payments on created orders, invalid payment arguments, or raw strings assigned to status.

They test different obligations.

Choosing a state is not the same as producing an output

Our implementation now makes another distinction visible.

The transition function selects a destination. The domain operation performs the work needed to reach it. A reader may then display information derived from the resulting state.

These are related responsibilities, but they are not identical.

This distinction also appears in early sequential-machine research. George Mealy’s 1955 paper developed systematic methods for synthesizing sequential circuits. The model bearing his name associates output with the current state and input the transition being taken.[4, 5]

Edward Moore’s 1956 paper examined what could be inferred about finite machines through external experiments. In the machines he defined, the next state depended on the previous state and input, while observable output depended on the current state.[6]

The conventional distinction is:

Mealy:
    output = f(state, input)

Moore:
    output = f(state)
Enter fullscreen mode Exit fullscreen mode

For our application, consider two different outputs.

A message reporting that a payment was just recorded belongs to the occurrence of that operation. A status label displaying “Paid” can be derived whenever someone reads the order.

Reading the label must not record the payment again.

These are application-level parallels, not a claim that our complete Order object is a literal Mealy or Moore machine. Payment records and other supporting data remain outside the small control table.

The useful historical lesson is to ask where an observable result belongs: to handling an input, or to occupying a state.

A state-derived display is also not automatically a one-time action executed on entry. Showing “Paid” repeatedly and capturing money repeatedly are very different behaviors.

What became authoritative?

We can now point to one structure for the status-dependent lifecycle:

TRANSITIONS
Enter fullscreen mode Exit fullscreen mode

Changing its shipping entry changes both the destination selected by ship_order() and the eligibility reported by available_actions().

That is a meaningful improvement.

It does not make the table the sole source of every business rule. Payment consistency still lives in validation. Payment-argument checks still live in the operation. A future change to the lifecycle must remain compatible with both.

A transition table can also contain the wrong rule. Adding:

CREATED + SHIP_ORDER → SHIPPED
Enter fullscreen mode Exit fullscreen mode

would not become correct merely because it was expressed declaratively.

The benefit is that the rule is visible, shared, and directly testable.

We also retain the mutation limitation from Part II. A caller can still assign OrderState.SHIPPED directly and bypass the transition function. Using enum members does not establish an exclusive write boundary.

For this implementation, the guarantee remains:

Through the controlled operations, a valid order follows a declared lifecycle transition or the request is rejected without changing it.

That is narrower than “the system can never be wrong.” It is also a guarantee we can inspect and test.

Was the dictionary necessary?

For three states and two operations, the earlier guarded functions remain a reasonable alternative.

They may even be easier to read when no other component needs to inspect the lifecycle.

The table becomes useful because we now have several consumers of the same rule: operations that change the order, code that reports available actions, and tests that examine the entire state–command surface.

We have not replaced conditional logic because conditionals are bad. We have given shared lifecycle rules an explicit representation.

The earlier functions already implemented state-machine behavior. This version makes that behavior a separate model.

But what happens when the next decision depends on more than status?

Our table currently answers:

PAID + SHIP_ORDER → SHIPPED
Enter fullscreen mode Exit fullscreen mode

Now suppose the business introduces a new requirement:

Ship only to supported delivery addresses.

This is a proposed extension, not a rule our current code already enforces.

Consider two internally consistent orders:

Order A:
    status = PAID
    payment = captured payment
    delivery_address = supported destination

Order B:
    status = PAID
    payment = captured payment
    delivery_address = unsupported destination
Enter fullscreen mode Exit fullscreen mode

Both reach the same table entry.

Yet the new rule requires different outcomes.

We could create more states:

PAID_WITH_SUPPORTED_ADDRESS
PAID_WITH_UNSUPPORTED_ADDRESS
Enter fullscreen mode Exit fullscreen mode

Then another rule arrives about a payment provider, a retry limit, or a customer restriction. Should each combination become another state?

The lifecycle table has solved the visibility problem. It has not removed the need for supporting data to influence decisions.

We can now see the next boundary:

A lifecycle state identifies a behavioral mode. It does not necessarily contain every value needed to choose a transition.

But how do we let those values influence the decision without turning every combination into another lifecycle state?

References

[1] Python Software Foundation, enum - Support for enumerations. Named members, values, and enum lookup behavior.

[2] Python Software Foundation, dataclasses - Data Classes. Dataclass fields, generated methods, and the treatment of type annotations.

[3] Python Software Foundation, types.MappingProxyType. Read-only access to a mapping.

[4] Paul E. Black, “Finite State Machine”, NIST Dictionary of Algorithms and Data Structures. State-machine components and the distinction between transition-associated and state-associated outputs.

[5] George H. Mealy, “A Method for Synthesizing Sequential Circuits”, Bell System Technical Journal, 1955.

[6] Edward F. Moore, “Gedanken-Experiments on Sequential Machines”, in Automata Studies, edited by Claude E. Shannon and John McCarthy, Princeton University Press, 1956.

Top comments (0)