Skip to content

Binding state to storage

By default the state lives on the instance. Real workflows keep it in a database row, and the flow object is created fresh on every request.

Three decorators bridge the gap:

Decorator Called For
@state.getter() Every time the state is read Reading the stored value
@state.setter() Before the transition body runs Writing the new value back
@state.on_success() After the transition commits Saving, logging, dispatching events

None of them are ORM-specific. open-fsm has no runtime dependencies and never learns what your storage is.

A flow over a record

OrderRecord stands in for a row — a status column and a save():

order.py
class OrderRecord:
    """Stands in for a stored row: a ``status`` column and a ``save()``."""

    def __init__(self, reference: str, status: str = '') -> None:
        self.reference = reference
        self.status = status
        self.saves = 0

    def save(self) -> None:
        self.saves += 1

OrderFlow holds no state of its own. It reads and writes the record's status, and saves on success:

order.py
class OrderFlow(StateEngine):
    state = State(OrderState, default=OrderState.CART)

    def __init__(self, record: OrderRecord) -> None:
        self.record = record

    @state.getter()
    def _read_status(self) -> str:
        return self.record.status

    @state.setter()
    def _write_status(self, value: OrderState) -> None:
        self.record.status = value

    @state.on_success()
    def _save(self, transition: Any, source: OrderState, target: OrderState, **kwargs: Any) -> None:
        self.record.save()

    @state.transition(source=OrderState.CART, target=OrderState.PAID)
    def pay(self, amount: int) -> None:
        """Take payment for the order."""

    @state.transition(source=OrderState.PAID, target=OrderState.SHIPPED)
    def ship(self) -> None:
        """Hand the order to the carrier."""

    @state.transition(source=OrderState.SHIPPED, target=OrderState.DELIVERED)
    def deliver(self) -> None:
        """Confirm the order arrived."""

    @state.transition(source=[OrderState.PAID, OrderState.SHIPPED], target=OrderState.REFUNDED)
    def refund(self) -> None:
        """Give the money back, before delivery."""

A fresh record has an empty status, and the flow reports the declared default:

>>> record = order.OrderRecord('R-1')
>>> record.status
''
>>> flow = order.OrderFlow(record)
>>> flow.state
<OrderState.CART: 'CART'>

A transition writes through to the record and saves it:

>>> flow.pay(amount=1000)
>>> record.status
<OrderState.PAID: 'PAID'>
>>> record.saves
1

The flow is disposable. Build another one over the same record and it picks up where the first left off — which is the whole point:

>>> order.OrderFlow(record).state
<OrderState.PAID: 'PAID'>

The empty-value rule

A getter returning None or '' means "nothing stored yet", and the declared default is used instead. Any other falsy value is taken at face value:

>>> class Level:
...     state = State(int, default=1)
...     def __init__(self, row):
...         self.row = row
...     @state.getter()
...     def _read(self):
...         return self.row.get('level')
...     @state.setter()
...     def _write(self, value):
...         self.row['level'] = value
>>> Level({'level': 0}).state
0
>>> Level({'level': None}).state
1
>>> Level({}).state
1

0 survives; None and '' fall back. This is what lets a NULL column and a blank CharField both mean "new" while 0 stays a real state.

The fallback only applies when the default is truthy

The check is if self._default: — a machine whose default is itself 0, '' or None returns the getter's value unchanged, including None. Either pick a truthy default, or have the getter substitute the starting state itself.

The on_success hook

It runs after the state has been written and the body has returned, which makes it the right place to persist:

>>> record.saves
1
>>> flow = order.OrderFlow(record)
>>> flow.ship()
>>> record.saves
2

It receives the transition, the state moved from, the state moved to, and the keyword arguments the caller passed:

>>> seen = {}
>>> class Shipment:
...     state = State(['packing', 'sent'], default='packing')
...     @state.on_success()
...     def _record(self, transition, source, target, **kwargs):
...         seen.update(source=source, target=target, kwargs=kwargs)
...     @state.transition(source='packing', target='sent')
...     def send(self, carrier=''):
...         """Hand the shipment over."""
>>> Shipment().send(carrier='royal-mail')
>>> seen
{'source': 'packing', 'target': 'sent', 'kwargs': {'carrier': 'royal-mail'}}

Accept **kwargs, or a caller will break your hook

Every keyword argument passed to a transition is forwarded. A hook written as def _save(self, transition, source, target) works right up until someone calls flow.pay(amount=1000), then raises TypeError from inside the commit — after the state has already been written. Positional arguments are not forwarded.

The hook does not run when the body raises

An exception rolls the state back and propagates, so on_success is skipped. What it does not do is undo a partial write your setter already made to the record — if the record is saved by something other than the hook, a failed transition can leave the row ahead of the flow. Keep the save in the hook.

Ordering, and what it costs

For one transition, in order:

  1. getter — read the current state
  2. conditions — evaluated against the flow
  3. setter — write the target state
  4. your transition body
  5. on_success — save

The setter runs before the body. A body that raises is rolled back by calling the setter again with the original value, so the record is corrected — but between those two points the record held the new value. That matters if something else can read it concurrently; take a lock or a transaction around the call if it does.

The getter is called on every read, including once per transition and once per condition evaluated by get_available_transitions(). Keep it a plain attribute read — resolve the record once in __init__, as OrderFlow does, rather than querying inside the getter.

Introspection still works

The flow is an ordinary StateEngine, so a stored state answers the same questions as an in-memory one:

>>> shipped = order.OrderFlow(order.OrderRecord('R-2', status='SHIPPED'))
>>> [transition.slug for transition in shipped.get_available_transitions()]
['deliver', 'refund']

refund is there because it names two sources in one declaration:

>>> sorted(str(transition.source) for transition in order.OrderFlow.refund.get_transitions())
['OrderState.PAID', 'OrderState.SHIPPED']

Flow object or model mixin?

Two shapes work. OrderFlow above is the first:

Separate flow object Mixin on the model
Declaration OrderFlow(record) class Order(Model, StateEngine)
Getter/setter Required Not needed — the field is the storage
Model stays free of workflow code Yes No
Works with a model class you do not own Yes No
One record, several workflows Yes Awkward — one State per class

A mixin is less code. A separate flow object keeps the transition table, the conditions and the persistence policy in one file that is not your model, and it is the only option when a record has more than one lifecycle. Neither is wrong.

The flow object is the shape Viewflow uses, and the one the planned ORM integrations build on — they supply the __init__, getter, setter and save hook, leaving you the State declaration and the transitions.