Note

Modeling a Logistics Workflow with F#

Exploring discriminated unions and pattern matching to model a logistics order workflow in F#.

  • 4 min read

When an order moves through a logistics system, its status is more than a label. It is a set of business rules: an unassigned order can receive a driver, an assigned order can start, and a completed order must not move again.

This small F# Logistic Workflow repository is my way of learning F# domain modeling through that concrete problem. It models an order workflow with discriminated unions (DUs), pattern matching, and Result values.

The workflow

The happy path is straightforward:

Unassigned → Assigned → Started → Arrived → Completed

An order can also fail or be cancelled. Completed, Failed, and Cancelled are terminal states: any command received after that should return an InvalidTransition error.

Commands express an intent, and some carry data:

type Command =
| Assign of DriverId: string
| Start
| Arrive
| Fail of Reason: string
| Complete
| Cancel of Reason: string

Using commands rather than setting a status directly keeps the transition rules in one place. It also makes failure and cancellation reasons part of the domain rather than incidental strings attached later.

First pass: one flat union

The simplest version represents every status in one DU and matches each valid status-command pair:

type OrderStatus =
| Unassigned
| Assigned
| Started
| Arrived
| Failed
| Completed
| Cancelled
let executeCommand currentStatus command : Result<OrderStatus, DomainError> =
match currentStatus, command with
| Unassigned, Assign _ -> Ok Assigned
| Assigned, Start -> Ok Started
| Started, Arrive -> Ok Arrived
| Arrived, Complete -> Ok Completed
| Completed, _ | Failed, _ | Cancelled, _ ->
Error (InvalidTransition (currentStatus, command))
| _, Cancel _ -> Ok Cancelled
| _, Fail _ -> Ok Failed
| _ -> Error (InvalidTransition (currentStatus, command))

This is already a useful improvement over scattered if statements or a large mutable state machine. All legal transitions are visible, and everything else becomes an explicit domain error. But the type still treats active and terminal states as peers, so that distinction is enforced mostly by the match expression.

Second pass: group active and terminal states

The next version makes the lifecycle visible in the type shape:

type ActiveStatus =
| Unassigned
| Assigned
| Started
| Arrived
type TerminalStatus =
| Failed of Reason: string
| Cancelled of Reason: string
| Completed
type OrderStatus =
| Active of ActiveStatus
| Terminal of TerminalStatus

Now the rule for completed work is compact and hard to miss:

| Terminal _, _ -> Error (InvalidTransition (currentStatus, command))

The compiler helps us maintain the model by checking the DU cases we handle. Pattern matching still performs the runtime decision for a particular command, but the model makes terminal states an explicit category instead of a convention we have to remember.

Third pass: model the initial state separately

The final experiment separates Unassigned from work that is already in progress:

type CancellableStatus = Unassigned
type ActiveStatus = Assigned | Started | Arrived
type OrderStatus =
| Cancellable of CancellableStatus
| Active of ActiveStatus
| Terminal of TerminalStatus

This makes an important business distinction clearer: only an unassigned order can receive a driver. The transition starts from Cancellable Unassigned and moves to Active Assigned.

| Cancellable Unassigned, Assign _ -> Ok (Active Assigned)
| Active Assigned, Start -> Ok (Active Started)
| Active Started, Arrive -> Ok (Active Arrived)
| Active Arrived, Complete -> Ok (Terminal Completed)

This is the direction I find most interesting. Good types cannot replace every validation rule, but they can make impossible or confusing states harder to express and make the business language visible in the code.

Running a workflow

Each version folds a list of commands over a starting status. The fold stops naturally on the first Error, while successful runs keep a history of states for display.

let commands = [ Assign "Driver1"; Start; Arrive; Complete ]

That sequence produces:

Unassigned → Assigned → Started → Arrived → Completed

The project also includes failure, cancellation, and invalid-transition examples. Run them with:

Terminal window
dotnet run

What I took from it

F# does not make workflow design automatic, but DUs and pattern matching give the rules a concise, inspectable home. Instead of spreading status checks through application code, we can describe the domain states, the commands that change them, and the errors that explain rejected transitions.

For a real logistics system, I would add richer identifiers, persistence, timestamps, authorization, and tests. As a focused learning project, though, this progression from a flat union to grouped states is a clear demonstration of how types can guide domain design.