Status Transitions
DDD CQRS ES
Most aggregates are state machines. An Order moves through DRAFT, PLACED, CONFIRMED, SHIPPED, and DELIVERED. A Subscription cycles through TRIAL, ACTIVE, PAUSED, and CANCELLED. The business rules about which transitions are legal often define the most critical behavior in a domain.
Protean's Status field makes these lifecycle rules explicit and
automatically enforced.
Defining a Status field
A Status field requires an Enum class as its first argument:
from enum import Enum
from protean.fields import Status
class OrderStatus(Enum):
DRAFT = "DRAFT"
PLACED = "PLACED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
CANCELLED = "CANCELLED"
@domain.aggregate
class Order:
status = Status(OrderStatus, default="DRAFT")
Without a transitions argument, Status behaves like
String(choices=OrderStatus). It constrains the field to valid Enum values but
does not enforce transition rules.
Adding transitions
Pass a transitions dict mapping each state to its allowed next states:
@domain.aggregate
class Order:
status = Status(OrderStatus, default="DRAFT", transitions={
OrderStatus.DRAFT: [OrderStatus.PLACED, OrderStatus.CANCELLED],
OrderStatus.PLACED: [OrderStatus.CONFIRMED, OrderStatus.CANCELLED],
OrderStatus.CONFIRMED: [OrderStatus.SHIPPED],
OrderStatus.SHIPPED: [OrderStatus.DELIVERED],
})
States not appearing as keys in the dict are terminal, no outgoing
transitions are allowed. In the example above, DELIVERED and CANCELLED are
terminal states.
How enforcement works
Direct mutation
When you assign a new status value, the framework validates the transition immediately:
order = Order() # status = "DRAFT"
order.status = "PLACED" # OK — DRAFT → PLACED is allowed
order.status = "SHIPPED" # ValidationError — PLACED → SHIPPED is not allowed
The error message tells you exactly what's wrong:
ValidationError: {'status': ["Invalid status transition from 'PLACED' to 'SHIPPED'. Allowed transitions: CONFIRMED, CANCELLED"]}
Attempting to leave a terminal state produces a different message:
ValidationError: {'status': ["Invalid status transition from 'DELIVERED'. 'DELIVERED' is a terminal state with no allowed transitions"]}
Same-value assignment (self-transitions)
By default, assigning a status to its current value is rejected. It is
treated as a transition and must appear in the transition map like any other.
This catches re-entry bugs (calling approve() on an already-approved item) at
the framework level instead of requiring manual guards.
order = Order() # status = "DRAFT"
order.status = "DRAFT" # ValidationError — DRAFT is not in DRAFT's target list
The error message guides you toward the fix:
ValidationError: {'status': ["Re-entry into 'DRAFT' is not allowed. If this operation should be idempotent, add 'DRAFT' to its own target list in transitions"]}
To make a transition idempotent (e.g., cancel() on an already-cancelled
order should be a safe no-op for race condition resilience), add the state to
its own target list:
@domain.aggregate
class Order:
status = Status(OrderStatus, default="DRAFT", transitions={
OrderStatus.DRAFT: [OrderStatus.PLACED, OrderStatus.CANCELLED],
OrderStatus.PLACED: [OrderStatus.CONFIRMED, OrderStatus.CANCELLED],
OrderStatus.CONFIRMED: [OrderStatus.SHIPPED],
OrderStatus.SHIPPED: [OrderStatus.DELIVERED],
# CANCELLED is idempotent — re-entry is explicitly allowed
OrderStatus.CANCELLED: [OrderStatus.CANCELLED],
})
Now order.status = "CANCELLED" succeeds when the order is already cancelled.
Three categories of same-value behavior
When designing your transition map, classify each state:
- Re-entry is a business error (most common), don't list the state in
its own targets.
approve()on a published review fails. - Idempotent by design: List the state in its own targets.
cancel()on a cancelled order succeeds as a no-op. - Terminal state: Omit it from the map entirely. No transitions (including self-transitions) are allowed.
Initialization
Setting the initial value (from None) is always allowed, regardless of the
transition map:
order = Order() # status = "DRAFT" (via default) — OK
order = Order(status="PLACED") # Also OK — initial assignment
Working with atomic_change
When using atomic_change to batch multiple mutations, the framework captures
status snapshots on entry and validates the start-to-end transition on exit.
Intermediate states are not checked:
from protean import atomic_change
order = Order() # status = "DRAFT"
with atomic_change(order):
order.status = "PLACED"
order.amount = 100.0
# On exit: validates DRAFT → PLACED — OK
If the overall transition is invalid:
with atomic_change(order):
order.status = "PLACED" # DRAFT → PLACED (valid step)
order.status = "CONFIRMED" # PLACED → CONFIRMED (valid step)
# On exit: validates DRAFT → CONFIRMED — NOT in DRAFT's allowed list → ValidationError
Event-sourced aggregates
For event-sourced aggregates, raise_() wraps the @apply handler in
atomic_change. The same start-to-end validation applies:
@domain.aggregate(event_sourced=True)
class Order:
status = Status(OrderStatus, default="DRAFT", transitions={
OrderStatus.DRAFT: [OrderStatus.PLACED],
OrderStatus.PLACED: [OrderStatus.CONFIRMED],
})
def place(self):
self.raise_(OrderPlaced(order_id=self.order_id))
@apply
def on_placed(self, event: OrderPlaced) -> None:
self.status = "PLACED" # Validated on atomic_change exit
Event replay (from_events()) does not validate transitions.
Replayed events are historical facts, the framework trusts them.
Programmatic checking
Use can_transition_to() to check whether a transition would be valid
without actually performing it:
order.can_transition_to("status", OrderStatus.SHIPPED) # False
order.can_transition_to("status", OrderStatus.PLACED) # True
This is useful for:
- Aggregate methods that want to check before raising an event
- API responses showing available actions to the client
- Conditional logic in command handlers
Multiple status fields
An aggregate can have multiple independent Status fields. Each validates
its transitions independently:
class PaymentStatus(Enum):
PENDING = "PENDING"
PAID = "PAID"
REFUNDED = "REFUNDED"
class FulfillmentStatus(Enum):
UNFULFILLED = "UNFULFILLED"
FULFILLED = "FULFILLED"
RETURNED = "RETURNED"
@domain.aggregate
class Order:
payment = Status(PaymentStatus, default="PENDING", transitions={
PaymentStatus.PENDING: [PaymentStatus.PAID],
PaymentStatus.PAID: [PaymentStatus.REFUNDED],
})
fulfillment = Status(FulfillmentStatus, default="UNFULFILLED", transitions={
FulfillmentStatus.UNFULFILLED: [FulfillmentStatus.FULFILLED],
FulfillmentStatus.FULFILLED: [FulfillmentStatus.RETURNED],
})
Best practices
-
Design the Enum first: The Enum defines all possible states. Name values clearly. They appear in error messages and database records.
-
Keep transition maps in the aggregate: The transition map is business logic. It belongs where the business rules live.
-
Use terminal states intentionally: Terminal states are states with no outgoing transitions (absent from the map's keys). Design them as deliberate end-of-lifecycle markers.
-
Think about idempotency for every state: For each state, decide whether re-entry is an error or a deliberate no-op. If a
cancel()operation should be safe to call twice (e.g., for race conditions), add the state to its own target list:CANCELLED: [CANCELLED]. -
Combine with invariants:
Statushandles which transitions are legal. Use@invariant.prefor under what conditions, for example, "can only confirm an order if payment has been received." -
Status on Value Objects is not allowed: Value Objects are immutable and cannot transition.
Statuswithtransitionson a Value Object raisesIncorrectUsageErrorat class creation time.
See also
Reference: Status field: Field options and argument details.
Related guides:
- Invariants: Business rules that complement transition enforcement.
- Aggregate Mutation: The
__setattr__mechanism that triggers validation. - Raising Events: How
raise_()and@applyinteract withatomic_change.