← All posts

How We Built Delivery Management State Machines in OutSystems O11

How we implemented enforced state transitions, role-based permissions, and a complete audit trail for delivery management on New Zealand's largest OutSystems platform.

 

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:

  1. Linked to the transition — not just to the delivery
  2. Reversible — if the delivery is rolled back from Posted to Approved, the stock movement reverses
  3. 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:

  1. Fetches all StockMovement records linked to the forward transition (Approved → Posted)
  2. Creates equal and opposite movements
  3. 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.