Arjan Egges replaces inheritance with decorators in Python state machines
Overview
The if-else blocks and scattered boolean flags in complex logic flows. While traditional implementations rely heavily on
Prerequisites
To follow this tutorial, you should be comfortable with basic
- Type Hinting: Understanding how to use generics (
TypeVar). - Decorators: Knowing how functions can be wrapped to modify behavior.
- Enums: Grouping related constants under a single type.
- Data Classes: Using the
dataclassdecorator for efficient object creation.
Key Libraries & Tools
- typing: Used for
Generic,TypeVar,Callable, andIterableto ensure type safety. - enum: Used to define distinct states (e.g.,
New,Authorized) and events (e.g.,Authorize,Fail). - dataclasses: Simplifies the creation of the
StateMachineand context objects.
Code Walkthrough
The core of this refactor is the StateMachine class. It uses generic types S (State), E (Event), and C (Context) to remain reusable across different domains like payments, logistics, or parsing.
from dataclasses import dataclass, field
from typing import Generic, TypeVar, Callable, Iterable, Dict, Tuple
S = TypeVar("S")
E = TypeVar("E")
C = TypeVar("C")
@dataclass
class StateMachine(Generic[S, E, C]):
transitions: Dict[Tuple[S, E], Tuple[S, Callable[[C], None]]] = field(default_factory=dict)
def add_transition(self, from_state: S, event: E, to_state: S, action: Callable[[C], None]):
self.transitions[(from_state, event)] = (to_state, action)
def transition(self, from_states: S | Iterable[S], event: E, to_state: S):
def decorator(action: Callable[[C], None]):
states = [from_states] if not isinstance(from_states, (list, tuple)) else from_states
for s in states:
self.add_transition(s, event, to_state, action)
return action
return decorator
def handle(self, context: C, current_state: S, event: E) -> S:
if (current_state, event) not in self.transitions:
raise ValueError(f"Invalid transition from {current_state} with {event}")
next_state, action = self.transitions[(current_state, event)]
action(context)
return next_state
In this implementation, the transition method acts as a decorator factory. It allows us to register state changes directly above the functions that perform the business logic. The handle method performs the lookup, executes the action (like logging or database updates), and returns the resulting state.
Syntax Notes
- Generic Constraints: By defining
S = TypeVar("S"), we ensure the state machine works with any type, but we can later restrict this toEnumtypes for better validation. - Decorator Chaining: The
transitiondecorator returns the original action function, allowing the same logic (like afailaction) to be reused across multiple state transitions. - Type Union: Using
S | Iterable[S]allows the decorator to accept either a single state or a collection of states, reducing boilerplate when multiple states share the same exit event.
Practical Examples
Imagine a payment flow. We define states and events using
pay_sm = StateMachine[PayState, PayEvent, PaymentContext]()
@pay_sm.transition(from_states=PayState.NEW, event=PayEvent.AUTHORIZE, to_state=PayState.AUTHORIZED)
def authorize_action(ctx: PaymentContext):
ctx.audit.append(f"Authorized payment {ctx.id}")
@pay_sm.transition(from_states=(PayState.NEW, PayState.AUTHORIZED), event=PayEvent.FAIL, to_state=PayState.FAILED)
def fail_action(ctx: PaymentContext):
ctx.audit.append("Transaction failed")
Tips & Gotchas
- Keep State in the Object: Do not store the current state inside the
StateMachineinstance. Instead, keep it in the subject (e.g., thePaymentclass). This allows a singleStateMachineengine to be shared across thousands of concurrent payment objects. - The Open-Closed Principle: This pattern excels at extension. To add a new transition, you simply write a new function with a decorator rather than modifying an existing class hierarchy.
- Complex Internal State: If a specific state requires massive amounts of internal data that only matters during that phase, the traditional class-based approach might still be superior to avoid cluttering a single context object.
