Defining the full transition table up front, rejecting invalid transitions at the service layer rather than trusting callers, event sourcing vs CRUD-plus-audit-table for storing order history, and why cancellation-after-shipment needs its own explicit return flow.
Published September 23, 2026
enum OrderStatus { CREATED, PAID, SHIPPED, DELIVERED, CANCELLED, RETURN_REQUESTED, REFUNDED }
private static final Map<OrderStatus, Set<OrderStatus>> VALID_TRANSITIONS = Map.of(
CREATED, Set.of(PAID, CANCELLED),
PAID, Set.of(SHIPPED, CANCELLED),
SHIPPED, Set.of(DELIVERED, RETURN_REQUESTED),
DELIVERED, Set.of(RETURN_REQUESTED),
RETURN_REQUESTED, Set.of(REFUNDED),
CANCELLED, Set.of(), // terminal
REFUNDED, Set.of() // terminal
);
Defining the ENTIRE transition table explicitly, up front, as a single reference structure (rather than scattering if checks across every service method that touches order status) is what makes the state machine actually auditable — a reviewer can look at this ONE map and verify every transition the business allows, rather than having to trace through scattered conditional logic across the codebase to reconstruct what's actually permitted.
public Order transitionTo(String orderId, OrderStatus newStatus, Actor actor) {
Order order = repository.findById(orderId).orElseThrow();
Set<OrderStatus> allowed = VALID_TRANSITIONS.get(order.getStatus());
if (!allowed.contains(newStatus)) {
throw new InvalidTransitionException(order.getStatus(), newStatus);
}
if (!actor.canTrigger(order.getStatus(), newStatus)) { // OMS — Requirements' multi-actor scoping
throw new UnauthorizedTransitionException(actor, newStatus);
}
order.setStatus(newStatus);
order.getHistory().add(new StatusTransition(order.getStatus(), newStatus, actor, Instant.now()));
return repository.save(order);
}
The transition check belongs in ONE centralized service method that every code path touching order status must go through — never trusting an individual caller (a controller, an event handler, a background job) to independently verify a transition is valid before setting it. This is the same principle as Payment — Core Flow's guarded state transitions: centralizing the check is what actually GUARANTEES the state machine's integrity, rather than relying on every caller remembering to check correctly every time.
CRUD + audit table:
orders table: current status only, overwritten on each transition
order_history table: append-only log of past transitions (as shown above)
— simpler to implement and query for "what's the current state"
Event sourcing:
NO current-status column at all — the event log (every transition ever) IS the
system of record; current status is DERIVED by replaying all events for an order
— enables full replay/reconstruction of state as of any past point in time,
and analytical queries across the full event history, at real added complexity cost
CRUD + audit table (current status as a mutable field, plus a separate append-only history table for the record) is simpler to build and query for the common case ("what's this order's status right now") — most of the history table is a byproduct, not the primary read path. Event sourcing treats the event log itself as the ONLY source of truth, with current state always derived by replaying events — this trades real implementation complexity (every read needs a replay, or a maintained "projection" cache of current state) for genuinely powerful capabilities: full point-in-time reconstruction, and the ability to answer analytical questions ("how long do orders typically spend in PAID before shipping") the CRUD approach's history table technically COULD answer too, but event sourcing makes it the natural, first-class query shape rather than an afterthought.
As OMS — Requirements flagged, a cancel request arriving AFTER an order has shipped cannot simply "revert" the status back to CREATED — the physical package is already in transit. The state machine models this correctly by making RETURN_REQUESTED its OWN distinct state (reachable from SHIPPED or DELIVERED), NOT a synonym for CANCELLED reached from CREATED/PAID — the two represent genuinely different real-world processes (a cancellation before fulfillment vs an actual physical return after fulfillment) and conflating them into one transition would hide that difference from anyone reading the state machine, and from any downstream system (the warehouse, the refund flow) that needs to react differently to each.
Q: How would you handle a transition table that grows large and complex over time? A: The centralized-map approach shown above scales reasonably to dozens of states, but a genuinely large/complex state machine sometimes benefits from a dedicated state-machine library (like Spring State Machine) that provides transition validation, guards, and action-hooks as first-class framework features — the underlying PRINCIPLE (explicit transition table, centrally enforced) stays the same either way; the tooling is what changes.
Q: Does event sourcing make the OMS — Requirements' per-transition SLA requirements harder or easier to satisfy? A: Somewhat harder for the READ side specifically — deriving current state by replaying a potentially long event history adds latency unless a current-state PROJECTION is maintained and kept up to date as events arrive (essentially re-introducing something like the CRUD table's current-status column, but maintained AS a projection of the event log rather than as the primary source of truth) — event sourcing systems commonly do exactly this hybrid to keep reads fast.
Q: What happens if two different actors attempt conflicting transitions concurrently (e.g. customer cancels while warehouse marks as shipped)? A: This is a genuine race condition needing the same atomic-conditional-update discipline as Payment — Idempotency Implementation and E-Commerce Checkout & Inventory's inventory reservation — the transition needs to be an atomic, conditional database operation (only succeed if the order is STILL in the expected prior state), so exactly one of the two conflicting transitions wins and the other is rejected as now-invalid, rather than both silently applying and leaving the order in an inconsistent state.
Q: Is a full audit history required for every business, or is this over-engineering for a simple order flow? A: For anything involving money and customer disputes (which basically every order-taking business has), SOME history is close to a hard requirement for handling support inquiries and disputes credibly — the depth (a full event-sourced replay vs a simpler audit table) is the genuinely optional design choice, scaled to how much analytical/replay value the business actually needs beyond just 'what happened and when.'