Our model can now describe an order’s lifecycle state and retain the payment information that supports it. What it cannot do is enforce the rules we established.
The status must be created, paid, or shipped. Payment capture may move an order from created to paid; shipping may move it from paid to shipped. The status and payment record must also agree. These were the unresolved requirements at the end of Part 1.
How do we move those rules from the explanation into the implementation?
Let’s continue with the model we already have:
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional
@dataclass
class Payment:
method: str
provider: str
payment_id: str
captured_amount: Decimal
@dataclass
class Order:
id: str
customer_name: str
delivery_address: str
status: str = "created"
payment: Optional[Payment] = None
The operations are still simple assignments:
def capture_payment(order: Order, payment: Payment) -> None:
order.payment = payment
order.status = "paid"
def ship_order(order: Order) -> None:
order.status = "shipped"
Both functions change the record without checking whether the change makes sense.
We do not need a new framework to improve this. We need to make the first decision explicit.
Start by protecting the shipping operation
Our shipping rule is:
Only a paid order may be shipped.
The smallest change is to check the current status before assigning the next one:
class InvalidOrderOperation(ValueError):
pass
def ship_order(order: Order) -> None:
if order.status != "paid":
raise InvalidOrderOperation(
f"Cannot ship an order while it is {order.status!r}."
)
order.status = "shipped"
Now consider a newly created order:
order = Order(
id="order-66",
customer_name="Alice",
delivery_address="Istanbul",
)
ship_order(order)
The function raises InvalidOrderOperation. The assignment never runs, so the order remains in the created state.
We have changed more than just the amount of code. The operation now has two possible outcomes: perform the permitted change, or reject the request without changing the order.
The condition order.status == "paid" is a precondition: something that must hold for this operation to complete successfully. The check in the function enforces it.[2]
This protects one path. But does it protect the whole model?
A valid status can belong to an invalid order
Let’s construct the inconsistent order we encountered in Part 1:
order = Order(
id="order-66",
customer_name="Alice",
delivery_address="Istanbul",
status="paid",
payment=None,
)
ship_order(order)
print(order.status, order.payment)
# shipped None
The function accepts it.
Its condition asks whether the status is paid, and the answer is yes. It never asks whether a payment record exists.
Our first check is correct but insufficient. The order is eligible by its label, yet inconsistent with our business rules.
These are two different questions:
Is the current order a valid record?
Is this operation allowed from that valid record?
Checking the second does not automatically answer the first.
Make valid configurations explicit
In Part 1, we required a captured payment record for every paid or shipped order, and no captured payment record for a created order.
That gives us a small set of combinations to consider:
| Status | No payment record | Captured payment record |
|---|---|---|
created |
Valid | Invalid |
paid |
Invalid | Valid |
shipped |
Invalid | Valid |
An unknown status is invalid regardless of the payment field.
Let’s put those checks in one function:
class InvalidOrderState(ValueError):
pass
def validate_order(order: Order) -> None:
if order.status not in ("created", "paid", "shipped"):
raise InvalidOrderState(
f"Unknown order status: {order.status!r}."
)
if order.status == "created" and order.payment is not None:
raise InvalidOrderState(
"A created order cannot contain a captured payment."
)
if order.status in ("paid", "shipped"):
if not isinstance(order.payment, Payment):
raise InvalidOrderState(
f"An order marked {order.status!r} "
"must contain a Payment record."
)
The first check enforces our vocabulary. In this implementation, the actual values are lowercase strings. PAID is not silently converted to paid.
The other checks enforce agreement between lifecycle state and supporting data.
The isinstance() check also prevents a string such as "payment-81" from being accepted in place of a Payment object. The annotation Optional[Payment] describes the intended type, but a plain dataclass does not enforce that annotation at runtime.
These checks have a deliberately limited scope. They validate the relationship between the order’s status and its payment record. They do not validate every field inside Payment, compare the captured amount with an order total, or prove that a provider captured money.
Our model still assumes one full payment in one currency. We haven't added an order total or a provider integration, so we shouldn't claim to verify either.
These consistency rules are invariants
The rules in validate_order() are not specific to shipping. They describe what we consider a valid order.
Let’s call that kind of rule an invariant.
For this implementation, an invariant is a condition that must hold whenever an order is accepted for processing and after a successful operation returns.
For example:
A created order has no captured payment record.
And:
A paid or shipped order retains its captured payment record.
The distinction from a precondition is useful:
| Question | Kind of rule | Example |
|---|---|---|
| Is this order valid? | Invariant | A paid order contains a payment record. |
| May this operation run now? | Precondition | Shipping requires a paid order. |
| What must successful execution establish? | Postcondition | Shipping leaves the order shipped and retains its payment. |
A postcondition describes what must hold after successful execution.
The payment record’s continued presence matters. Shipping should change the lifecycle state, not erase the supporting information that made the operation valid.
Valid endpoints do not prove a valid transition
Our validator has a subtle limit.
These two configurations both satisfy it:
Before:
status = created
payment = None
After:
status = shipped
payment = a Payment record
Nevertheless, moving directly between them would skip the payment transition we defined.
The destination is valid in isolation. Our lifecycle does not permit the move.
validate_order() cannot determine whether the previous state was created, paid, or something else. It receives one record, not a record of how it reached that condition.
We therefore need both kinds of checks:
Configuration checks determine whether the fields describe an allowed condition.
Transition checks determine whether a particular operation may move the order from its current condition to another.
Neither replaces the other.
Put the checks before the changes
Let’s revise both operations.
First, we validate the existing order. Then we will check whether the requested operation is permitted. Only after those checks pass will we change any fields.
def capture_payment(order: Order, payment: Payment) -> None:
validate_order(order)
if order.status != "created":
raise InvalidOrderOperation(
"Cannot record a captured payment "
f"while the order is {order.status!r}."
)
if not isinstance(payment, Payment):
raise TypeError("payment must be a Payment record.")
order.payment = payment
order.status = "paid"
def ship_order(order: Order) -> None:
validate_order(order)
if order.status != "paid":
raise InvalidOrderOperation(
f"Cannot ship an order while it is {order.status!r}."
)
order.status = "shipped"
These definitions replace the earlier unguarded functions.
The distinction between the exceptions is intentional.
InvalidOrderState means the existing record contradicts our model. A paid order with no payment record belongs in this category.
InvalidOrderOperation means the order is valid, but the requested operation is not permitted. A correctly formed created order receiving a shipping request belongs in this category.
Neither case should be repaired by inventing missing information.
If payment is absent, shipping must not construct a placeholder payment to satisfy the validator. If the status is unknown, the operation must not guess which known status was intended.
The request stops at the failed check.
Why check the current record first?
Suppose we receive:
status = created
payment = an existing captured payment
Without validating the current record, capture_payment() could accept another payment, overwrite the existing record, and change the status to paid.
The result would look consistent. But the operation would have hidden an inconsistent input by replacing information.
Our revised function rejects the order before that happens.
This gives us a useful rule:
Do not use a normal lifecycle operation to silently repair a record that already violates the lifecycle.
A repair process may eventually be necessary. It should make its purpose explicit. We have not designed one here.
What successful operations now establish
For capture_payment(), the reasoning is:
A valid created order has no payment. The function accepts a Payment record, stores it, and marks the order as paid. The resulting combination satisfies our consistency rules.
For ship_order(), the reasoning is:
A valid paid order already contains a payment. The function changes only the status to shipped. The payment remains attached, so the resulting combination also satisfies the rules.
All expected rejection conditions occur before either operation mutates the order.
That placement matters. Changing fields and then raising an exception does not, by itself, undo the assignments.
Our payment function still performs two separate assignments:
order.payment = payment
order.status = "paid"
Between them, the object temporarily contains a payment while its status is still being created. Under our current assumptions, no other writer, callback, or reader observes the object during that operation. The invariant applies at the operation boundary.
This is not an atomic transaction or a concurrency guarantee. If another process can observe intermediate writes, or an external operation can fail between them, we will need a stronger boundary.
For now, we are solving the local problem we actually have.
An older way of asking the same correctness question
Once we describe what must be true before and after an operation, we are using a form of reasoning that has a long history.
In his 1969 paper An Axiomatic Basis for Computer Programming, C. A. R. Hoare developed an approach to reasoning about programs through assertions about their initial and resulting conditions. A common notation is:
{precondition} command {postcondition}
For partial correctness, the claim is that when the precondition holds, a terminating execution establishes the postcondition.[2]
Applied to our example, the intended contract is:
{valid order, status is paid}
ship_order(order)
{valid order, status is shipped, payment is retained}
This specifies what we want, not a formal proof of our Python implementation.
Dijkstra’s later work on guarded commands made another relevant idea explicit: a command’s eligibility can depend on a Boolean condition placed before it.
Our shipping check serves that limited purpose. It does not implement Dijkstra’s full calculus, but it follows the same useful discipline: describe when an action is eligible, rather than writing the action alone.
The historical connection is practical. The important unit is not just the assignment. It is the assignment together with the conditions that make it correct.
Check the behavior, including rejection
Let’s run the intended sequence using the same kind of payment record as before:
payment = Payment(
method="credit_card",
provider="stripe",
payment_id="payment-81",
captured_amount=Decimal("120.00"),
)
order = Order(
id="order-66",
customer_name="Alice",
delivery_address="Istanbul",
)
capture_payment(order, payment)
assert order.status == "paid"
assert order.payment is payment
validate_order(order)
ship_order(order)
assert order.status == "shipped"
assert order.payment is payment
validate_order(order)
The assertions check more than the final label. They also check that both operations retain the supplied payment record.
Now test rejection:
unpaid_order = Order(
id="order-67",
customer_name="Alice",
delivery_address="Istanbul",
)
try:
ship_order(unpaid_order)
except InvalidOrderOperation:
pass
else:
raise AssertionError("An unpaid order was allowed to ship.")
assert unpaid_order.status == "created"
assert unpaid_order.payment is None
The final assertions matter.
An operation that raises the expected exception after partially changing the record would still be wrong for this contract.
For valid starting records, our two functions should behave as follows:
| Starting status | capture_payment() | ship_order() |
|---|---|---|
| created | Record payment; become paid |
Reject without changing the order |
| paid | Reject without replacing the payment | Become shipped ; retain payment |
| shipped | Reject without replacing the payment | Reject without changing the order |
Reject without replacing the payment
Reject without changing the order
For duplicate shipping, this implementation chooses the rejection policy discussed in Part 1
. Returning an existing shipment would require a shipment result we do not yet store.
Similarly, capture_payment() remains a simulation that records the supplied payment. It does not call Stripe, despite the provider name in the example. Rejecting a second call protects this local record; it does not guarantee against external charges.
But can another caller still bypass the rules?
Yes.
Our functions have improved. The dataclasses remain mutable, and their fields are still public.
A caller can bypass both operations:
order = Order(
id="order-66",
customer_name="Alice",
delivery_address="Istanbul",
)
order.status = "shipped"
No validation runs automatically on that assignment. Nothing in our class definition invokes validate_order(), either during construction or during later field changes.
Calling the validator afterward will detect the missing payment:
validate_order(order)
# Raises InvalidOrderState.
But detection is not prevention.
There is a second bypass that configuration validation cannot detect:
order = Order(
id="order-66",
customer_name="Alice",
delivery_address="Istanbul",
)
order.payment = payment
order.status = "shipped"
validate_order(order)
# Passes: the resulting configuration is internally valid.
The record is valid, but the writer skipped the required transition through paid.
This is the difference between protecting an operation and protecting every possible write.
Our guarantee must therefore remain specific:
Through these two functions, a valid order either makes a permitted change or the request is rejected without changing it.
We have not made arbitrary mutation impossible.
To rely on that guarantee, application writes must pass through the controlled operations. Restricting field assignment or introducing immutable records can help establish that boundary, but neither technique alone decides which lifecycle transitions are legal.
The rules and the write boundary are separate responsibilities.
What has improved and what is still implicit?
We have now placed all three categories of rules into executable code.
validate_order() checks recognized status values and consistent data. capture_payment() and ship_order() check the allowed changes.
The operations keep their original names, work with the same Order and Payment models, and still need no external service or framework.
This is manageable for two operations.
But look at where the lifecycle is described.
The valid statuses appear in the validator. The permitted source state for payment capture appears in one function. The permitted source state for shipping appears in another. Their target states appear in assignments.
To answer “What can happen while an order is paid?”, a reader must inspect the operations.
A user interface deciding whether to display a shipping action might write another check:
can_ship = order.status == "paid"
That check describes only status-based eligibility; it does not establish that the record is valid. The actual operation must still validate and reject when necessary.
More importantly, the interface has begun repeating part of the lifecycle rule. If the shipping rule changes, the write path and the displayed action can drift apart.
This is not a reason to add a framework immediately. Our current design is small, readable, and already contains state-machine behavior.
It does reveal the next question.
The behavior exists, but its complete description is distributed across validation checks, operation checks, and assignments. We reconstructed the table above by reading the functions; the table does not yet govern them.
We have made individual changes conditional. We have not yet made the entire lifecycle one explicit contract.
But how do we express that lifecycle in one place so the operations, their tests, and the code displaying available actions don't each maintain their own version of it?
Top comments (2)
Good discipline in this post — especially separating
InvalidOrderState(the record is wrong) fromInvalidOrderOperation(the record is fine, the request isn't). That distinction gets blurred in a lot of codebases, and it's the difference between "reject this input" and "repair this data," which should never share a code path.One thing I'd flag from an architecture angle: right now the invariant and transition checks live as ad hoc
ifchains inside each operation function. That's the right level of complexity for three states — but the moment a fourth or fifth status shows up (refunded, cancelled, partially_shipped...), the number of pairwise transition checks grows faster than the states do. That's usually the point where it's worth promoting the transition rules into data — a table of(from_status, event) -> (to_status, guard, side_effect)— rather than scattering them across functions. Same Hoare-style pre/postcondition reasoning, but now the set of legal transitions is inspectable and testable as one artifact instead of implied by which functions happen to exist.The other lever worth considering later in the series: pushing some of this into the type system instead of runtime. If
Orderwere a discriminated union —CreatedOrder | PaidOrder | ShippedOrder, each carrying only the fields valid for that state (Payment, notOptional[Payment], onPaidOrder) — a chunk ofvalidate_order()disappears, because invalid combinations become unrepresentable rather than merely rejected. Doesn't survive contact with ORMs and serialization boundaries, but for the pure domain model it turns a runtime invariant into a compile-time one.Good call naming the concurrency caveat explicitly instead of hand-waving past it — that's the kind of boundary worth deferring on purpose, not assuming away by accident. Looking forward to Part III.
Thank you for your very valuable comments @naveen_alavilli , they are very precious. 🙏