A supplier delivers 50kg of beef to a restaurant. The delivery driver hands over the paperwork. The manager signs it off. Later, someone notices the actual weight was 43kg.
What happens next depends entirely on whether your system was designed for this.
In a poorly designed system: someone edits the delivery record, the stock figures update silently, and there’s no trace of what changed, when, or why. Six months later when there’s a supplier dispute, nobody can answer what actually happened.
In a well-designed system: the delivery has a state. Moving it backward requires an explicit action. Every transition is logged. The audit trail is complete.
We built the second system for New Zealand’s largest OutSystems platform — delivery management across a live multi-restaurant estate serving companies including McDonald’s NZ with 10,000+ daily users. Here’s how we implemented it in OutSystems O11.
What a state machine actually is
A state machine is a formal model for an entity that moves through defined states via defined transitions.
For a supplier delivery, the states look like this:
Draft → Submitted → Approved → Posted → Closed
↓
Rejected
The rules:
- A delivery can only move from Submitted to Approved — not from Draft to Approved
- Only certain roles can trigger certain transitions
- Every transition is logged with who did it and when
- Some transitions are reversible. Some aren’t.
Without a state machine, a delivery record is just a row with a status field. Any code anywhere can set that field to any value. With a state machine, state is enforced — invalid transitions are impossible, not just discouraged.
The O11 data model
Start with two entities.
DeliveryRecord — the delivery itself:
DeliveryRecord
├── Id (AutoNumber)
├── RestaurantId (FK → Restaurant)
├── SupplierId (FK → Supplier)
├── DeliveryDate (Date)
├── CurrentState (DeliveryState Identifier)
├── CreatedOn (DateTime)
├── CreatedBy (FK → User)
DeliveryStateTransition — the audit log:
DeliveryStateTransition
├── Id (AutoNumber)
├── DeliveryRecordId (FK → DeliveryRecord)
├── FromState (DeliveryState Identifier)
├── ToState (DeliveryState Identifier)
├── TransitionedBy (FK → User)
├── TransitionedOn (DateTime)
├── Notes (Text)
DeliveryState is a Static Entity — not a text field, not an integer, a proper Static Entity with Identifiers. This is important. Static Entity Identifiers are compile-time constants in OutSystems. If you reference Entities.DeliveryState.Approved in your logic and then rename or delete that record, the platform will tell you at compile time. A text field comparison won’t.
Never use magic strings for state. CurrentState = "Approved" is a bug waiting to happen. CurrentState = Entities.DeliveryState.Id.Approved is a contract.
The transition validation action
Every state transition goes through a single Server Action: ValidateAndTransitionDelivery.
It takes three inputs:
DeliveryRecordId(Long Integer)TargetState(DeliveryState Identifier)Notes(Text)
The logic:
1. Fetch the current DeliveryRecord
2. Look up allowed transitions from CurrentState
3. If TargetState is not in allowed transitions → output error, exit
4. Check role permissions for this specific transition
5. If current user lacks permission → output error, exit
6. Begin transaction:
a. Update DeliveryRecord.CurrentState = TargetState
b. Insert DeliveryStateTransition record
7. Return success
Everything goes through this action. No code anywhere in the system sets CurrentState directly on DeliveryRecord. The only way to change state is through ValidateAndTransitionDelivery.
This is the architectural decision that makes everything else work. If you allow state to be set from multiple places — a screen preparation, a timer, a service action — you lose the guarantee. One action, one path.
Defining allowed transitions
The allowed transition map lives in a Static Entity: DeliveryTransitionRule.
DeliveryTransitionRule
├── Id (AutoNumber)
├── FromState (DeliveryState Identifier)
├── ToState (DeliveryState Identifier)
├── RequiredRole (Role Identifier)
Sample rows:
Draft → Submitted RequiredRole: StoreManager
Submitted → Approved RequiredRole: AreaManager
Submitted → Rejected RequiredRole: AreaManager
Approved → Posted RequiredRole: System (Timer)
Posted → Closed RequiredRole: System (Timer)
Approved → Submitted RequiredRole: AreaManager ← rollback
The validation step in ValidateAndTransitionDelivery runs an Aggregate against this entity:
Aggregate: GetTransitionRule
- Source: DeliveryTransitionRule
- Filter: DeliveryTransitionRule.FromState = CurrentState
AND DeliveryTransitionRule.ToState = TargetState
If the Aggregate returns no rows, the transition is invalid. If it returns a row, check the RequiredRole against the current user’s roles before proceeding.
The transition rules live in a Static Entity because they’re application logic, not runtime data. They don’t change based on user input. They change when the business rules change — and that requires a deployment, not a database edit. That’s intentional.
Rollback-safe stock movements
This is where delivery management gets genuinely complex.
When a delivery moves to Posted, it triggers a stock movement — ingredients are added to the restaurant’s inventory. That stock movement needs to be:
- Linked to the transition — not just to the delivery
- Reversible — if the delivery is rolled back from Posted to Approved, the stock movement reverses
- Idempotent — if the Timer that triggers posting runs twice, the stock doesn’t move twice
Link to the transition, not the delivery.
StockMovement has a DeliveryStateTransitionId foreign key, not just DeliveryRecordId. This means you can query “what stock movements were caused by this specific transition” — not just “what stock movements are associated with this delivery.”
When a rollback transition fires (Posted → Approved), the action:
- Fetches all
StockMovementrecords linked to the forward transition (Approved → Posted) - Creates equal and opposite movements
- Logs these as linked to the rollback transition
The inventory stays correct. The audit trail is complete. You can reconstruct every stock movement from the transition log.
Idempotency on the posting timer.
Before creating stock movements, the action checks whether movements already exist for this transition:
Aggregate: GetExistingMovements
- Filter: StockMovement.DeliveryStateTransitionId = TransitionId
If GetExistingMovements.Count > 0 → skip, exit cleanly
Same pattern as period close. Same reason.
The audit trail in practice
The DeliveryStateTransition table gives you a complete history of every delivery:
DeliveryId From To By When Notes
1042 Draft Submitted J.Smith 2026-06-10 09:14 -
1042 Submitted Approved M.Chen 2026-06-10 11:32 Weights verified
1042 Approved Posted System 2026-06-10 23:00 Nightly timer
1042 Posted Approved M.Chen 2026-06-11 08:15 Weight discrepancy
1042 Approved Posted System 2026-06-11 23:00 Nightly timer
1042 Posted Closed System 2026-06-14 23:00 -
That record answers every question a supplier dispute could raise. What state was the delivery in? Who moved it? When? Why?
This is what “traceable” actually means in a food service environment. Not a status field. A history.
What the UI looks like
Screens don’t show all possible actions. They show valid actions for the current state and current user role.
In the Server Action that populates the delivery screen, after fetching the delivery:
GetValidTransitions (Aggregate)
- Source: DeliveryTransitionRule
- Filter: DeliveryTransitionRule.FromState = DeliveryRecord.CurrentState
AND DeliveryTransitionRule.RequiredRole IN CurrentUserRoles
→ output: List of valid ToState values
The screen renders action buttons from this list. A store manager viewing a Draft delivery sees “Submit.” They don’t see “Approve” or “Reject” — those actions aren’t valid for their role at this state.
The UI enforces nothing. The server enforces everything. The UI just reflects reality.
This separation is important. Never rely on hiding a button as your security model. The server action validates. The UI communicates.
Common mistakes
State as a text field. Use Static Entities with Identifiers. Compile-time safety, not runtime string matching.
Transition logic spread across screens. If three different screens can change delivery state via direct Entity updates, you have three places where the rules can be applied inconsistently. One action, one path.
Audit log as an afterthought. You cannot reconstruct history you didn’t log. DeliveryStateTransition needs to be populated from day one. Adding it later means your history starts from whenever you added it.
No idempotency on state-triggered side effects. Timers retry. If posting a delivery triggers stock movements and the timer runs twice, you get duplicate movements. Guard with an existence check before creating side effects.
Reversible transitions without reversible side effects. Rolling back a delivery state without reversing the stock movements leaves your inventory wrong. Every rollback transition needs to define what it undoes.
When to use this pattern
The signal: an entity moves through defined lifecycle stages, with business rules about who can make what transitions, and with side effects that need to be traceable and reversible.
Delivery management is the obvious case. Others in OutSystems O11 environments:
- Purchase order approval workflows — Draft → Submitted → Approved → Fulfilled
- Leave request management — Submitted → Under Review → Approved / Rejected
- Incident tracking — Open → Assigned → In Progress → Resolved → Closed
- Financial period management — Open → Locked → Closed → Archived
If the entity has a status field and more than two possible values, ask whether it should be a state machine. The overhead is low. The payoff — in auditability, in correctness, in debuggability — is high.
The broader principle
State machines aren’t a pattern you use when things get complicated. They’re a pattern you use to prevent things from getting complicated.
A delivery record with an unguarded status field is a system waiting to be corrupted. A delivery record with enforced transitions, role-based permissions, and a complete audit log is a system you can operate with confidence.
On New Zealand’s largest OutSystems platform, we built the second kind. Suppliers get disputed. Deliveries get corrected. Stock gets rolled back. And every time, the system knows exactly what happened, when, and who made the call.
That’s not an edge case feature. That’s what production-grade looks like.