Skip to content

Commit f7cbd4a

Browse files
committed
docs: add "Coming from the State Pattern" how-to guide
New migration guide for developers familiar with the GoF State Pattern, showing how to port hand-rolled state implementations to python-statemachine. Covers strict vs reactive modes, structural validation, callbacks, and a side-by-side comparison table.
1 parent 162cfc7 commit f7cbd4a

3 files changed

Lines changed: 405 additions & 0 deletions

File tree

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
(coming-from-state-pattern)=
2+
3+
# Coming from the State Pattern
4+
5+
This guide is for developers familiar with the classic **State Pattern** from the
6+
Gang of Four book (*Design Patterns: Elements of Reusable Object-Oriented Software*).
7+
It walks through a typical State Pattern implementation, discusses its trade-offs,
8+
and shows how to express the same behavior declaratively with python-statemachine.
9+
10+
## The classic State Pattern
11+
12+
The GoF State Pattern models an object whose behavior changes based on its internal
13+
state. The standard recipe has three ingredients:
14+
15+
1. A **Context** class that delegates behavior to a state object.
16+
2. An **abstract State** base class (or protocol) defining the interface.
17+
3. **Concrete State** classes implementing state-specific behavior.
18+
19+
Here is a complete example — an order workflow with four states
20+
(draft, confirmed, shipped, delivered) and a guard condition
21+
(orders can only be confirmed if they have at least one item):
22+
23+
```python
24+
from abc import ABC, abstractmethod
25+
26+
27+
class OrderState(ABC):
28+
"""Abstract base for all order states."""
29+
30+
@abstractmethod
31+
def confirm(self, order):
32+
...
33+
34+
@abstractmethod
35+
def ship(self, order):
36+
...
37+
38+
@abstractmethod
39+
def deliver(self, order):
40+
...
41+
42+
43+
class DraftState(OrderState):
44+
def confirm(self, order):
45+
if order.item_count <= 0:
46+
raise ValueError("Cannot confirm an empty order")
47+
order._state = ConfirmedState()
48+
print("Order confirmed")
49+
50+
def ship(self, order):
51+
raise RuntimeError("Cannot ship a draft order")
52+
53+
def deliver(self, order):
54+
raise RuntimeError("Cannot deliver a draft order")
55+
56+
57+
class ConfirmedState(OrderState):
58+
def confirm(self, order):
59+
raise RuntimeError("Order already confirmed")
60+
61+
def ship(self, order):
62+
order._state = ShippedState()
63+
print("Order shipped")
64+
65+
def deliver(self, order):
66+
raise RuntimeError("Cannot deliver before shipping")
67+
68+
69+
class ShippedState(OrderState):
70+
def confirm(self, order):
71+
raise RuntimeError("Cannot confirm a shipped order")
72+
73+
def ship(self, order):
74+
raise RuntimeError("Order already shipped")
75+
76+
def deliver(self, order):
77+
order._state = DeliveredState()
78+
print("Order delivered")
79+
80+
81+
class DeliveredState(OrderState):
82+
def confirm(self, order):
83+
raise RuntimeError("Order already delivered")
84+
85+
def ship(self, order):
86+
raise RuntimeError("Order already delivered")
87+
88+
def deliver(self, order):
89+
raise RuntimeError("Order already delivered")
90+
91+
92+
class Order:
93+
def __init__(self, item_count=0):
94+
self._state = DraftState()
95+
self.item_count = item_count
96+
97+
def confirm(self):
98+
self._state.confirm(self)
99+
100+
def ship(self):
101+
self._state.ship(self)
102+
103+
def deliver(self):
104+
self._state.deliver(self)
105+
```
106+
107+
This works — but notice how much code it takes for just four states and three events.
108+
109+
110+
## Pros and cons of the classic pattern
111+
112+
**Pros:**
113+
114+
- Encapsulates state-specific behavior in dedicated classes, eliminating large
115+
`if/elif` chains.
116+
- Follows the Open/Closed Principle for adding new states — you create a new class
117+
without modifying existing ones.
118+
- Each state class is independently testable.
119+
120+
**Cons:**
121+
122+
- **Class explosion** — every state requires a full class, even if most methods just
123+
raise "invalid operation" errors. The example above has 4 state classes and 12
124+
method implementations, 9 of which only raise exceptions.
125+
- **Transitions are scattered** — to understand the full workflow you must read every
126+
concrete state class. There is no single place showing all transitions at a glance.
127+
- **No structural validation** — orphaned states, unreachable states, or missing
128+
transitions are only discovered at runtime.
129+
- **Guards are manual** — conditions like "only confirm if items > 0" are embedded in
130+
method bodies, mixed with transition logic.
131+
- **No diagrams** — visualizing the state machine requires manual drawing.
132+
- **No async support** — adding async behavior requires rewriting the entire interface.
133+
- **Signature duplication** — every state class must implement every method, even the
134+
ones that are not valid for that state.
135+
136+
137+
## Porting to python-statemachine
138+
139+
The same order workflow expressed declaratively:
140+
141+
```py
142+
>>> from statemachine import State, StateChart
143+
>>> from statemachine.exceptions import TransitionNotAllowed
144+
145+
>>> class OrderMachine(StateChart):
146+
... allow_event_without_transition = False
147+
...
148+
... # States
149+
... draft = State(initial=True)
150+
... confirmed = State()
151+
... shipped = State()
152+
... delivered = State(final=True)
153+
...
154+
... # Transitions (the complete workflow at a glance)
155+
... confirm = draft.to(confirmed, cond="has_items")
156+
... ship = confirmed.to(shipped)
157+
... deliver = shipped.to(delivered)
158+
...
159+
... item_count = 0
160+
...
161+
... @property
162+
... def has_items(self):
163+
... return self.item_count > 0
164+
165+
>>> sm = OrderMachine()
166+
>>> sm.item_count = 3
167+
>>> sm.send("confirm")
168+
>>> sm.confirmed.is_active
169+
True
170+
171+
>>> sm.send("ship")
172+
>>> sm.shipped.is_active
173+
True
174+
175+
>>> sm.send("deliver")
176+
>>> sm.delivered.is_active
177+
True
178+
179+
```
180+
181+
That is the **entire** state machine — states, transitions, and the guard condition,
182+
all in one place. Setting `allow_event_without_transition = False` gives strict
183+
behavior equivalent to the GoF pattern — invalid events raise
184+
`TransitionNotAllowed`:
185+
186+
```py
187+
>>> sm = OrderMachine()
188+
>>> sm.item_count = 3
189+
190+
>>> try:
191+
... sm.send("ship") # can't ship from draft
192+
... except TransitionNotAllowed:
193+
... print("Blocked: can't ship from draft")
194+
Blocked: can't ship from draft
195+
196+
```
197+
198+
Guards work the same way — when the condition is not met, the transition is
199+
rejected:
200+
201+
```py
202+
>>> sm = OrderMachine()
203+
204+
>>> try:
205+
... sm.send("confirm") # item_count is 0
206+
... except TransitionNotAllowed:
207+
... print("Cannot confirm an empty order")
208+
Cannot confirm an empty order
209+
210+
```
211+
212+
### Going reactive
213+
214+
The strict mode above is a direct equivalent of the GoF pattern. But `StateChart`'s
215+
default (`allow_event_without_transition = True`) follows the SCXML specification:
216+
events that have no valid transition are **skipped**. This makes the
217+
machine reactive — it only responds to events that are meaningful in its current
218+
state, without requiring the caller to know which events are valid:
219+
220+
```py
221+
>>> class ReactiveOrderMachine(StateChart):
222+
... draft = State(initial=True)
223+
... confirmed = State()
224+
... shipped = State()
225+
... delivered = State(final=True)
226+
...
227+
... confirm = draft.to(confirmed, cond="has_items")
228+
... ship = confirmed.to(shipped)
229+
... deliver = shipped.to(delivered)
230+
...
231+
... item_count = 0
232+
...
233+
... @property
234+
... def has_items(self):
235+
... return self.item_count > 0
236+
237+
>>> sm = ReactiveOrderMachine()
238+
>>> sm.item_count = 3
239+
240+
>>> sm.send("ship") # no transition for "ship" from draft — skipped
241+
>>> sm.draft.is_active # still in draft
242+
True
243+
244+
>>> sm.send("confirm") # this one is valid
245+
>>> sm.confirmed.is_active
246+
True
247+
248+
```
249+
250+
This is particularly useful when the machine receives events from external sources
251+
(message queues, UI frameworks, network protocols) where the sender doesn't track
252+
the machine's current state. See {ref}`behaviour` for a comparison of all
253+
class-level defaults.
254+
255+
### Adding callbacks
256+
257+
State-specific behavior (e.g., sending notifications) uses naming conventions
258+
or inline declarations — no need to scatter logic across state classes:
259+
260+
```py
261+
>>> from statemachine import State, StateChart
262+
263+
>>> class OrderWithCallbacks(StateChart):
264+
... draft = State(initial=True)
265+
... confirmed = State()
266+
... shipped = State()
267+
... delivered = State(final=True)
268+
...
269+
... confirm = draft.to(confirmed, cond="has_items")
270+
... ship = confirmed.to(shipped)
271+
... deliver = shipped.to(delivered)
272+
...
273+
... item_count = 0
274+
...
275+
... def __init__(self, **kwargs):
276+
... self.log = []
277+
... super().__init__(**kwargs)
278+
...
279+
... @property
280+
... def has_items(self):
281+
... return self.item_count > 0
282+
...
283+
... def on_enter_confirmed(self):
284+
... self.log.append("confirmed")
285+
...
286+
... def on_enter_shipped(self):
287+
... self.log.append("shipped")
288+
...
289+
... def on_enter_delivered(self):
290+
... self.log.append("delivered")
291+
292+
>>> sm = OrderWithCallbacks()
293+
>>> sm.item_count = 2
294+
>>> sm.send("confirm")
295+
>>> sm.send("ship")
296+
>>> sm.send("deliver")
297+
>>> sm.log
298+
['confirmed', 'shipped', 'delivered']
299+
300+
```
301+
302+
### Structural validation catches design errors
303+
304+
Imagine a new requirement: orders can be cancelled from `draft` or `confirmed`.
305+
With the GoF pattern, a developer adds a `CancelledState` class — but forgets to
306+
wire the transitions in `DraftState` and `ConfirmedState`. The code compiles and
307+
runs fine; the bug only surfaces when someone tries to cancel an order and
308+
discovers there is no way to reach `CancelledState`. In a large codebase with
309+
dozens of states, this kind of mistake can go unnoticed for a long time.
310+
311+
python-statemachine catches this at **class definition time**:
312+
313+
```py
314+
>>> from statemachine import State, StateChart
315+
>>> from statemachine.exceptions import InvalidDefinition
316+
317+
>>> try:
318+
... class BrokenOrderMachine(StateChart):
319+
... draft = State(initial=True)
320+
... confirmed = State()
321+
... shipped = State()
322+
... delivered = State(final=True)
323+
... cancelled = State(final=True) # added but never connected
324+
...
325+
... confirm = draft.to(confirmed)
326+
... ship = confirmed.to(shipped)
327+
... deliver = shipped.to(delivered)
328+
... except InvalidDefinition as e:
329+
... print(e)
330+
There are unreachable states. ...Disconnected states: ['cancelled']
331+
332+
```
333+
334+
The fix is to declare the missing transitions — and now the full workflow is
335+
visible in a single glance:
336+
337+
```py
338+
>>> class FixedOrderMachine(StateChart):
339+
... draft = State(initial=True)
340+
... confirmed = State()
341+
... shipped = State()
342+
... delivered = State(final=True)
343+
... cancelled = State(final=True)
344+
...
345+
... confirm = draft.to(confirmed)
346+
... ship = confirmed.to(shipped)
347+
... deliver = shipped.to(delivered)
348+
... cancel = draft.to(cancelled) | confirmed.to(cancelled)
349+
350+
>>> sm = FixedOrderMachine()
351+
>>> sm.send("cancel")
352+
>>> sm.cancelled.is_active
353+
True
354+
355+
```
356+
357+
358+
## Side-by-side comparison
359+
360+
| Concept | State Pattern (GoF) | python-statemachine |
361+
|---|---|---|
362+
| State definition | One class per state | `State()` class attribute |
363+
| Transition | Method in source state class sets `_state` | `.to()` declaration |
364+
| Guard / condition | `if` check inside method body | `cond=` / `unless=` parameter |
365+
| Invalid transition | Manual `raise` in every method | `TransitionNotAllowed` or skipped ({ref}`configurable <behaviour>`) |
366+
| All transitions | Scattered across state classes | Visible in the class body |
367+
| Context / model | Separate `Order` class | `StateChart` itself (or `model=`) |
368+
| Adding a new state | New class + update all interfaces | New `State()` attribute + transitions |
369+
| Entry / exit actions | Manual in transition methods | `on_enter_<state>()` / `on_exit_<state>()` |
370+
| Diagrams | Manual | Built-in `_graph()` |
371+
| Validation | None (runtime errors only) | Definition-time structural checks |
372+
| Async support | Rewrite entire interface | Auto-detected from `async def` |
373+
| Dependency injection | Not available | Built-in via `SignatureAdapter` |
374+
375+
376+
## What you gain
377+
378+
By moving from the State Pattern to python-statemachine, you get:
379+
380+
- **Declarative definition** — the entire workflow is visible in one class body.
381+
- **Structural validation** — unreachable states, missing transitions, and unresolved
382+
callbacks are caught before the machine ever runs
383+
(see {ref}`validations`).
384+
- **Automatic diagrams** — call `_graph()` on any instance to generate a Graphviz
385+
diagram (see {ref}`diagrams`).
386+
- **Guards and conditions** — use `cond=`, `unless=`, or
387+
{ref}`expression strings <condition expressions>` instead of manual `if` checks.
388+
- **Dependency injection** — callbacks receive only the parameters they declare
389+
(see {ref}`actions`).
390+
- **Async support** — define `async def` callbacks and the engine auto-switches to
391+
async processing (see {ref}`async`).
392+
- **Listeners** — attach cross-cutting concerns (logging, auditing) as separate
393+
objects without modifying the state machine
394+
(see {ref}`listeners`).
395+
- **No class explosion** — four states and three events require one class with a few
396+
attributes, not four classes with twelve methods.

0 commit comments

Comments
 (0)