Workflows
Workflow Interface
Each workflow implements IProgramWorkflow<'ins, 'arg, 'ret>, which defines a Run method producing a Program:
[<Interface>]
type IProgramWorkflow<'ins, 'arg, 'ret when 'ins :> IProgramInstructions> =
abstract member Run: 'arg -> Program<'ins, Res<'ret>>In the Product domain, a convenience alias fixes the instruction set:
[<Interface>]
type IProductWorkflow<'arg, 'ret> =
inherit IProgramWorkflow<IProductInstructions, 'arg, 'ret>Design Choice: Which Features Get Workflows?
This is the approach chosen in the Shopfoo solution: evaluate each feature to determine whether it benefits from a workflow implementation.
Generally, commands are the best candidates — they typically contain business complexity and/or orchestrate multiple Data layer calls. Queries usually lack sufficient complexity and can be delegated directly to the Data layer.
However, exceptions exist, as seen in Api.fs:
The
AdjustStockcommand is delegated directly to the Warehouse access client.The
DetermineStockquery is implemented as a workflow.
Product Domain Workflow Examples
Let's examine characteristic workflows in order of increasing complexity.
RemoveListPrice — Basic orchestration
This simple workflow orchestrates two instructions: getPrices then savePrices.
🔗 Source code
Key points:
The workflow class implements the Singleton pattern without relying on the IoC container.
Program.getPricesreturnsProgram<_, Prices option>. TheProgram.requireSomeDatahelper converts the innerNoneto aDataRelatedError, and theprogramCE'sBindoverload automatically lifts it toError.savePricesreturnsProgram<_, Res<PreviousValue<Prices>>>. Writinglet! (PreviousValue _) = ...rather thanlet! _ = ...is required: without the destructuring pattern the compiler cannot determine whichBindoverload to use — the plainProgrambind (which would handRes<PreviousValue<Prices>>to the continuation) or the result-unwrapping bind (which extractsPreviousValue<Prices>). The pattern pins the expected type toPreviousValue<Prices>, resolving the ambiguity and selecting the unwrapping overload. The value itself is then discarded; the saga runner is the one that uses it for undo.
SavePrices — Validation
This workflow demonstrates validation using guard clauses and the validation applicative CE:
🔗 Source code
The Prices.validate function returns a Validation<unit, GuardClauseError> (an alias for Result<unit, GuardClauseError list>). It is built with the validation applicative CE, which collects all errors rather than stopping at the first one:
The let! ... and! ... applicative syntax inside validation { } runs both guards independently and merges any errors into a list. The program CE then provides a Bind overload that lifts Validation<_, GuardClauseError> directly to the common Error type.
AddProduct — Parallel execution
This is the most interesting Product workflow — it demonstrates the let! ... and! ... applicative syntax for parallel execution:
🔗 Source code
The let! ... and! ... syntax compiles into a call to MergeSources, which uses Program.map2 to start both instructions concurrently via Async.StartChild. This pattern is impossible with V3's free monad approach due to F#'s strict variance checking on generics.
Result.zip combines two Result values — if both succeed, it combines them; if either fails, it returns the error(s). Result.ignore discards the success value — a useless pair of unit values.
DetermineStock — Query workflow
This query workflow orchestrates multiple instructions and applies a domain rule inspired by the Decider pattern (*):
🔗 Source code
Notice that getSales and getStockEvents use Program.defaultValue [] instead of requireSome — returning an empty list when data is missing rather than failing. This is a design choice per workflow.
(*) Decider pattern
The Seq.fold accumulating stock quantity over the sorted event list is the evolve function from the Decider pattern: given a current state and an event, produce the next state. Applying it over the full event sequence reconstructs the current state from scratch — the same principle as Event Sourcing, but without a persistent event store.
Complementary resources:
Functional Event Sourcing Decider — Jérémie Chassaing
The Equinox Programming Model — Einar Norðfjörð
Order Domain — Saga and Cancellation
The Shopfoo.Program.Tests project contains an Order domain that showcases the saga pattern (undo on failure) and workflow cancellation. This is a better illustration of these features than the Product domain.
🔗 Source code: OrderContext/
Order Instructions
All instructions are commands (no queries), each returning a Result. Commands that produce an ID (PaymentId, InvoiceId, ParcelId) (*) return it in the Ok track — enabling the saga to pass these IDs to subsequent undo functions.
(*) In production designs, it is generally preferable for the client to generate IDs before sending a command. Client-generated IDs make idempotency straightforward: retrying the same command with the same ID is safe because the server can detect and ignore duplicates. Here, the IDs are generated server-side by the instructions specifically to illustrate two things: how a command's return value flows into the next step of the workflow, and how that same return value is captured by the saga runner and passed to the undo function.
Order Workflow with Cancellation Support
The OrderWorkflow accepts an optional cancelAfterStep parameter that simulates a client cancellation — an event that can occur at any point in real life. A dedicated unit test covers each step at which cancellation may happen, verifying that the workflow behaves as expected in every scenario:
Cancellation mechanism:
The
cancelAfterhelper checks if the current step matches the cancellation target.If so, it transitions the order to
OrderCancelled(recording the previous status) and returns aWorkflowCancellederror — which the saga recognizes as intentional and does not undo.After shipping, cancellation returns a
BusinessErrorinstead — the saga can be configured to not undo in this case either.
Undo Strategies in the Wiring
Each instruction's undo strategy is defined when wiring instructions in the test setup:
Three undo strategies are demonstrated:
Reversible
CreateOrder → DeleteOrder
Strict undo: restores exact initial state
Compensatable
ProcessPayment → RefundPayment
Logical offset: creates a compensating operation
NotUndoable
SendNotification
No undo possible: notifications cannot be recalled
Test Cases
The tests verify both undo on failure and cancellation without undo. For cancellation, a dedicated test covers each step where a client cancellation can occur, ensuring the workflow responds correctly in each case:
The saga state preserves the complete step history in LIFO order, including each step's status (RunDone, UndoDone, RunFailed, UndoFailed). This provides full observability into what happened during the workflow execution.
Saga without messages
This saga pattern operates entirely in-process, synchronously (within a single Async computation). There is no message bus, no distributed transaction coordinator. The "undo" functions are plain async functions called in reverse LIFO order. This makes the pattern suitable for orchestrating multiple data-layer calls within a single request, while keeping the workflow logic pure and testable.
Last updated