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
Setting a status to its current value is always allowed — it's a no-op, not a transition:
order.status = "DRAFT" # No error, even if DRAFT → DRAFT is not in the map
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(is_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.
-
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.