State Machine Concepts for Parcel Lifecycle
A parcel moves through a series of statuses from creation to delivery. Not every transition makes sense --- a delivered parcel cannot suddenly become "in transit" again. In the previous topic, we built tracking event creation with basic status synchronization: when an event is added, the parcel status updates automatically. But that implementation allows any transition. A state machine formalizes the rules so your API can enforce them consistently and reject invalid transitions.
What Is a State Machine?
A state machine is a model that defines:
- A finite set of states an entity can be in
- A set of transitions that move the entity from one state to another
- Rules that determine which transitions are valid from each state
1┌──────────────┐ ┌──────────┐ ┌───────────┐ ┌────────────────┐ ┌───────────┐2│ LabelCreated │────>│ PickedUp │────>│ InTransit │────>│ OutForDelivery │────>│ Delivered │3└──────────────┘ └──────────┘ └───────────┘ └────────────────┘ └───────────┘4 │ │ │ │5 │ │ │ │6 v v v v7 ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐8 │ Exception │ │ Exception │ │ Exception │ │ Exception │9 └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘10 │ │ │ │11 v v v v12 ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐13 │ Returned │ │ Returned │ │ Returned │ │ Returned │14 └──────────┘ └──────────┘ └──────────┘ └──────────┘
Parcel Statuses
Our parcel tracking system uses these statuses:
| Status | Description | Terminal? |
|---|---|---|
LabelCreated | Shipping label generated, parcel not yet handed off | No |
PickedUp | Carrier has collected the parcel | No |
InTransit | Parcel is moving through the carrier network | No |
OutForDelivery | Parcel is on the delivery vehicle | No |
Delivered | Parcel successfully delivered to recipient | Yes |
Exception | Something went wrong (damage, address issue, customs hold) | No |
Returned | Parcel sent back to sender | Yes |
Terminal states mean the parcel's journey is over. No further transitions are allowed.
Valid Transitions
The happy path follows a linear progression:
LabelCreated -> PickedUp -> InTransit -> OutForDelivery -> Delivered
Beyond the happy path, two additional rules apply:
- Exception can be reached from any non-terminal state. Problems can occur at any point in the journey.
- Returned can be reached from
Exception. Once an exception is resolved by returning the parcel, it becomes terminal.
Here is the complete transition table:
| From State | Allowed Next States |
|---|---|
LabelCreated | PickedUp, Exception |
PickedUp | InTransit, Exception |
InTransit | OutForDelivery, Exception |
OutForDelivery | Delivered, Exception |
Exception | Returned |
Delivered | (none -- terminal) |
Returned | (none -- terminal) |
Why Not Allow Arbitrary Transitions?
Without a state machine, you could end up with nonsensical data:
- A parcel marked
Deliveredthat later becomesLabelCreated - A parcel jumping from
LabelCreateddirectly toDeliveredwith no tracking history - A
Returnedparcel suddenly goingOutForDelivery
These corrupt the audit trail and confuse both internal systems and customers looking at tracking information.
Representing States in C#
An enum is the natural choice for a fixed set of states:
csharp1public enum ParcelStatus2{3 LabelCreated,4 PickedUp,5 InTransit,6 OutForDelivery,7 Delivered,8 Exception,9 Returned10}
The allowed transitions map naturally to a dictionary:
csharp1private static readonly Dictionary<ParcelStatus, HashSet<ParcelStatus>> AllowedTransitions = new()2{3 [ParcelStatus.LabelCreated] = [ParcelStatus.PickedUp, ParcelStatus.Exception],4 [ParcelStatus.PickedUp] = [ParcelStatus.InTransit, ParcelStatus.Exception],5 [ParcelStatus.InTransit] = [ParcelStatus.OutForDelivery, ParcelStatus.Exception],6 [ParcelStatus.OutForDelivery] = [ParcelStatus.Delivered, ParcelStatus.Exception],7 [ParcelStatus.Exception] = [ParcelStatus.Returned],8 [ParcelStatus.Delivered] = [],9 [ParcelStatus.Returned] = [],10};
An empty set means no transitions are allowed -- the state is terminal.
Service Layer Separation
Business rules like status validation belong in a service layer, not in the controller. This separation provides several benefits:
- Testability: You can unit test the transition logic without HTTP concerns.
- Reusability: Multiple endpoints or background processes can share the same rules.
- Clarity: Controllers handle HTTP; services handle domain logic.
1┌────────────┐ ┌─────────────────────┐ ┌────────────┐2│ Controller │────>│ ParcelStatusService │────>│ Repository │3│ (HTTP) │ │ (business rules) │ │ (data) │4└────────────┘ └─────────────────────┘ └────────────┘
The controller receives the request, calls the service to validate and execute the transition, and returns the appropriate HTTP response based on the result.
HTTP Status Codes for Business Rule Violations
When a client requests an invalid status transition, the API should return 422 Unprocessable Entity. This status code means:
- The request was well-formed (not a 400)
- The server understood it (not a 500)
- But it cannot process it because it violates business rules
1Client: PATCH /api/parcels/1232 Content-Type: application/json-patch+json3 [{ "op": "replace", "path": "/status", "value": "Delivered" }]4 (parcel is currently LabelCreated)56Server: 422 Unprocessable Entity7 {8 "error": "invalid_transition",9 "currentStatus": "LabelCreated",10 "requestedStatus": "Delivered",11 "allowedStatuses": ["PickedUp", "Exception"]12 }
For terminal states, attempting any modification should also return 422 with a clear message explaining that the parcel is in a terminal state and cannot be modified.
Key Takeaways
- A state machine defines valid states and transitions for an entity
- Parcel status follows a linear happy path with
Exceptionbranching from any active state DeliveredandReturnedare terminal states -- no further changes allowed- Use a dictionary of allowed transitions to implement the state machine in C#
- Business rules belong in a service layer, separate from controllers
- Return 422 Unprocessable Entity for invalid status transitions