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():
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:
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:
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:
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:
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:
getter— read the current state- conditions — evaluated against the flow
setter— write the target state- your transition body
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.
Related guides¶
- The State field — defaults and falsy states
- Dataclasses — the same decorators, for frozen and slotted classes
- Django ORM — the planned wrapper for this pattern
- Introspection — driving a UI from a stored state