githubEdit

arrow-progressWorkflows

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.fsarrow-up-right:

  • The AdjustStock command is delegated directly to the Warehouse access client.

  • The DetermineStock query 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 codearrow-up-right

Key points:

  • The workflow class implements the Singleton pattern without relying on the IoC container.

  • Program.getPrices returns Program<_, Prices option>. The Program.requireSomeData helper converts the inner None to a DataRelatedError, and the program CE's Bind overload automatically lifts it to Error.

  • savePrices returns Program<_, Res<PreviousValue<Prices>>>. Writing let! (PreviousValue _) = ... rather than let! _ = ... is required: without the destructuring pattern the compiler cannot determine which Bind overload to use — the plain Program bind (which would hand Res<PreviousValue<Prices>> to the continuation) or the result-unwrapping bind (which extracts PreviousValue<Prices>). The pattern pins the expected type to PreviousValue<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 codearrow-up-right

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 codearrow-up-right

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 codearrow-up-right

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.

circle-info

(*) 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:

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/arrow-up-right

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.

circle-info

(*) 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 cancelAfter helper checks if the current step matches the cancellation target.

  • If so, it transitions the order to OrderCancelled (recording the previous status) and returns a WorkflowCancelled error — which the saga recognizes as intentional and does not undo.

  • After shipping, cancellation returns a BusinessError instead — 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:

Strategy
Example
Behavior

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.

circle-info

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