Road to State Machines IV - But How Do We Let Data Influence Transitions Without Turning Every Value into Another State?
DEV Community

Road to State Machines IV - But How Do We Let Data Influence Transitions Without Turning Every Value into Another State?

Our lifecycle now has one explicit transition table. For a valid order, it tells us: The operations consult that table before making a change. The code that lists available actions consults it too. But Part III ended with another requirement: Consider two orders. Both are paid, and both retain their captured payment records: | Order | Status | Payment record | Delivery address | |---|---|---|---| | A | PAID | Present | Supported | | B | PAID | Present | Unsupported | Both records satisfy our existing invariants. Both reach the same transition-table entry. Yet the shipping request should succeed for only one of them. The table knows where an order is in its lifecycle. It does not yet consider all the information needed to decide whether shipping is permitted. How do we extend it without losing the clarity we just gained? Start with the smallest additional check We already have a delivery_address field. We do not need to change the order model to inspect it. For this example, let’s use a deliberately small, fixed rule: "Istanbul" and "Izmir" are supported destination labels. Every other string is unsupported. This is an illustrative policy, not a statement about real carrier coverage. We are also treating the existing strings as destination labels, not implementing postal-address parsing. def has_supported_address(order: Order) -> bool: return order.delivery_address in {"Istanbul", "Izmir"} We could call this function from the shipping operation: def ship_order(order: Order) -> None: validate_order(order) target = next_state( order.status, OrderCommand.SHIP_ORDER, ) if not has_supported_address(order): raise InvalidOrderOperation( "The delivery address is not supported." ) order.status = target This is a reasonable first change. The function still rejects invalid records and invalid lifecycle movements. It now also rejects unsupported destinations before changing the order. But what does available_actions() report? Its implementation from Part III checks whether the transition table contains an entry. For both paid orders, it finds: PAID + SHIP_ORDER → SHIPPED It therefore offers shipping for both. The shipping function has acquired a rule that the action listing does not know about. We could copy the address check into the listing, but that would recreate the duplication we just removed. The new condition belongs with the transition it controls. A condition that enables a transition A condition that determines whether a transition is eligible is called a guard. State-machine notations commonly distinguish the triggering input from the Boolean condition that must also hold.[1] For our shipping transition: PAID -- SHIP_ORDER [has_supported_address] --> SHIPPED The command asks for the change. The guard decides whether this particular order may take that transition. Our rule now has three separate requirements: - The existing order must satisfy its invariants. - Its current state must permit the requested command. - The transition’s guard must pass. Only then may the operation proceed. Notice that the address check did not replace the payment checks. A supported address does not make an unpaid order eligible for shipping. An unsupported destination does not make the order inconsistent Should we put has_supported_address() into validate_order() instead? Not for the rule we have chosen. A paid order with an unsupported destination can still be a consistent record. The customer paid; the captured payment is recorded; the delivery destination is known. What the order cannot do is take the shipping transition under our current policy. Compare the cases: | Current record | Interpretation | Shipping outcome | |---|---|---| PAID , no payment record | Inconsistent order | Raise InvalidOrderState | CREATED , no payment record | Consistent, but shipping is not permitted | Raise InvalidOrderOperation | PAID , payment present, unsupported destination | Consistent, but the shipping guard fails | Raise InvalidOrderOperation | PAID , payment present, supported destination | Consistent and eligible | Move to SHIPPED | The invariant asks whether the record makes sense. The guard asks whether a particular transition is allowed now. There is another reason not to confuse them. If shipping coverage later changes, that does not necessarily make an already shipped order invalid. A condition checked when an operation begins is not automatically a condition that must remain true forever. For this part, coverage stays fixed. The distinction is still useful. Do we need two kinds of paid state? Another possible solution would be to split PAID : PAID_WITH_SUPPORTED_ADDRESS PAID_WITH_UNSUPPORTED_ADDRESS This can represent the distinction, but it makes the lifecycle responsible for carrying a fact already available in the supporting data. Now suppose we also distinguish three payment providers. We could start creating combinations such as: PAID_WITH_SUPPORTED_ADDRESS_PROVIDER_A PAID_WITH_UNSUPPORTED_ADDRESS_PROVIDER_A PAID_WITH_SUPPORTED_ADDRESS_PROVIDER_B Then add another condition. Each independent dimension multiplies the combinations we might have to name. Most of those names would describe data combinations rather than useful lifecycle stages. More states are not inherently wrong. A separate state can be useful when it represents a different phase with its own permitted operations. But our present requirement does not introduce another phase. It adds a condition to an existing transition. Both orders remain paid. Their supporting data determines whether they may ship. Keep the condition with the transition Our table currently maps a state-command pair directly to a target state. Let’s give each entry room to describe an optional guard as well. The OrderState , OrderCommand , Order , and Payment definitions remain unchanged. We add: from dataclasses import dataclass from typing import Callable, Optional @dataclass(frozen=True) class Transition: target: OrderState guard: Optional[Callable[[Order], bool]] = None rejection_reason: str = "Transition conditions are not satisfied." def is_enabled(self, order: Order) -> bool: return self.guard is None or self.guard(order) guard is either absent or a function that receives an order and returns a Boolean result. An absent guard means this entry has no additional data-dependent condition. It does not mean that record validation or command-argument validation should be skipped. The frozen=True setting prevents ordinary assignment to the transition definition’s fields after construction. It does not make the orders passed to its guard immutable.[2] Now replace the earlier table: from types import MappingProxyType from typing import Mapping TRANSITIONS: Mapping[ tuple[OrderState, OrderCommand], Transition, ] = MappingProxyType({ ( OrderState.CREATED, OrderCommand.CAPTURE_PAYMENT, ): Transition( target=OrderState.PAID, ), ( OrderState.PAID, OrderCommand.SHIP_ORDER, ): Transition( target=OrderState.SHIPPED, guard=has_supported_address, rejection_reason="The delivery address is not supported.", ), }) The lifecycle is still small: | Source | Command | Additional guard | Target | |---|---|---|---| CREATED | CAPTURE_PAYMENT | None | PAID | PAID | SHIP_ORDER | Supported address | SHIPPED | We have not added another order state. We have made one transition more precise. Select the next state using the order The selection function needs access to the supporting data, so its signature must change. Previously, it received only a state: next_state(order.status, command) It will now receive the order: next_state(order, command) The following definition replaces the version from Part III: def next_state( order: Order, command: OrderCommand, ) -> OrderState: validate_order(order) if not isinstance(command, OrderCommand): raise TypeError( "command must be an OrderCommand member." ) try: transition = TRANSITIONS[(order.status, command)] except KeyError: raise InvalidOrderOperation( f"Cannot {command.value} " f"while the order is {order.status.value!r}." ) from None if not transition.is_enabled(order): raise InvalidOrderOperation( transition.rejection_reason ) return transition.target The sequence is deliberate. First, validate the record using the unchanged validator from Part III. A created order must have no captured payment, while a paid or shipped order must contain a Payment record. Then find a transition for the current state and command. Finally, evaluate that transition’s optional guard. The function returns a target or raises an exception. It still does not change the order. A missing transition and a failed guard both reject the operation, but they explain different failures: - The lifecycle contains no such movement. - The movement exists, but this order does not currently satisfy its condition. We keep those explanations separate without inventing extra lifecycle states. Update the operations to use the new selector Because next_state() now validates the order itself, the operations do not need to repeat that call. Replace both operations with: def capture_payment(order: Order, payment: Payment) -> None: target = next_state( order, OrderCommand.CAPTURE_PAYMENT, ) if not isinstance(payment, Payment): raise TypeError("payment must be a Payment record.") order.payment = payment order.status = target def ship_order(order: Order) -> None: target = next_state( order, OrderCommand.SHIP_ORDER, ) order.status = target The address condition no longer appears inside ship_order() . It is attached to the shipping transition. Payment capture still checks its supplied argument and stores it. We have not made shipping coverage a prerequisite for recording a payment. That would be another business rule, not a consequence we should introduce accidentally. The expected rejection checks still run before mutation. These operations also retain their earlier scope: payment capture records the supplied Payment , and shipping changes the local lifecycle status. Neither calls an external service. Let the read path evaluate the same

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.