githubEdit

brackets-curlyProgram

This page describes the V4 program implementation, based on the Tagless Final pattern and an async reader design. It resides entirely in the Shopfoo.Programarrow-up-right project.

Solution Structure

πŸ“‚ src/
β”œβ”€β”€πŸ“‚ Core/
β”‚  β”œβ”€β”€πŸ—ƒοΈ Shopfoo.Common
β”‚  β”œβ”€β”€πŸ—ƒοΈ Shopfoo.Domain.Types
β”‚  β””β”€β”€πŸ—ƒοΈ Shopfoo.Program           πŸ‘ˆ Program infrastructure
β”‚     β”œβ”€β”€πŸ“„ Program.fs             πŸ‘ˆ Program type, program CE
β”‚     β”œβ”€β”€πŸ“„ Saga.fs                πŸ‘ˆ Undo types and logic
β”‚     β”œβ”€β”€πŸ“„ Runner.fs              πŸ‘ˆ Workflow runner, instruction preparer
β”‚     β”œβ”€β”€πŸ“„ Metrics.fs             πŸ‘ˆ Observability
β”‚     β””β”€β”€πŸ“„ Dependencies.fs        πŸ‘ˆ DI registration
β”œβ”€β”€πŸ“‚ Feat/
β”‚  β”œβ”€β”€πŸ—ƒοΈ Shopfoo.Home              πŸ‘ˆ Simple features, without workflows
β”‚  β””β”€β”€πŸ—ƒοΈ Shopfoo.Product           πŸ‘ˆ Complex features, with domain workflows
β””β”€β”€πŸ“‚ UI/
   β”œβ”€β”€πŸ—ƒοΈ Shopfoo.Client
   β”œβ”€β”€πŸ—ƒοΈ Shopfoo.Server
   β””β”€β”€πŸ—ƒοΈ Shopfoo.Shared

Program Type

The Program type is a type alias β€” a function that takes an instruction set and returns an asynchronous result:

Key points:

  • IProgramInstructions: Marker interface that all instruction sets must inherit.

  • Instructions<'ins>: Type alias encapsulating the constraint, so it can be changed in one place.

  • Program<'ins, 'ret>: A function from instructions to an async result. This replaces the V3 free monad ADT (Stop | Effect) with a direct function.

  • Res<'t>: Shorthand alias for Result<'t, Error>, used throughout the codebase wherever a computation may fail with a domain error.

Program Module

The Program module provides the standard functional combinators:

The map2 function is essential: it enables parallel execution of two independent programs by starting the first as a child async computation, running the second, and then awaiting the first. This is what powers the applicative let! ... and! ... syntax.

Computation Expression

The ProgramBuilder class provides the program CE:

Applicative syntax:

  • Bind2Return and MergeSources are the CE methods that enable let! ... and! .... When the compiler sees let! a = exprA followed by and! b = exprB, it calls MergeSources (which calls map2) to run both computations concurrently.

  • This is impossible with Bind alone, which is inherently sequential.

Result Bind Overloads

The CE provides multiple Bind overloads so that Result values integrate seamlessly with programs:

This design eliminates manual error-track management in workflows β€” the CE handles the lifting and short-circuiting automatically.

Result Aggregation

The CE does not provide a MergeSources overload for Result. Parallel applicative syntax (let! ... and! ...) only applies to Program values; it cannot aggregate multiple Result values into a combined result, collecting all errors.

Result aggregation must be handled case by case in the program, using the Result.zip helper:

Typical usage β€” validating several independent values before proceeding:

If more than two results need to be combined, Result.zip can be chained, or a dedicated Result.zipN helper can be introduced for the specific arity.

Defining Programs from Instructions

The DefineProgram<'ins> static class provides a single method to define a program from a single instruction:

This is an identity function β€” it exists purely for developer experience. Usage:

The type alias DefineProgram = DefineProgram<IProductInstructions> fixes the instruction set, so each call only requires the shorthand lambda _.Method(args) β€” which triggers IntelliSense on the instruction interface. This is perhaps the most ergonomic part of the whole design.

Saga Support (Undo)

The Saga.fs file provides the types and logic for undoing completed instructions when a workflow fails β€” implementing the Saga pattern for synchronous, in-process workflows (no message bus involved).

Undo Types

Each command instruction can be marked with an undo strategy:

  • Undo.None: Not undoable (e.g., sending a notification).

  • Undo.Revert: Strict reversal β€” restores the initial state exactly.

  • Undo.Compensate: Compensation β€” produces a new operation that logically offsets the original.

Step Tracking

Every instruction execution is recorded as a ProgramStep:

The saga maintains a LIFO history of steps. On failure, it walks the history in reverse and executes each command's undo function.

Saga Finalization

The Saga.finalize function determines whether to undo and executes the undo phase:

It handles several cases:

  • Success: Status = Done, no undo needed.

  • Workflow cancelled: Status = Cancelled, no undo (cancellation is intentional).

  • Failure but undo not allowed (predicate returns false): Status = Failed, no undo.

  • Failure with undo allowed: Walks steps in reverse, executes undo functions, collects any undo errors.

Workflow Runner

The Runner.fs file defines the infrastructure for running workflows with instruction preparation, monitoring, and saga integration.

Instruction Preparer

The IInstructionPreparer<'ins> interface wraps raw instruction functions with monitoring and undo tracking:

For queries, it wraps the raw function with logging, timing, and step tracking.

For commands, it returns an IWorkCommandBuilder<'arg, 'ret> on which the caller then specifies the undo strategy:

The reason this is a separate interface β€” rather than a single Command method that takes both work and undoFun β€” is F# type inference. F# infers generic type parameters left-to-right within an expression, but it does not propagate inferred types across the arguments of a single method call. If work and undoFun were both parameters of Command, the compiler would not know 'arg and 'ret when it typechecks undoFun, forcing the caller to annotate the lambda explicitly.

By splitting into two calls, Command(work, name) infers 'arg and 'ret from work, and the returned IWorkCommandBuilder<'arg, 'ret> carries those types. The undoFun lambda passed to .Reversible(...) or .Compensatable(...) is then fully typed β€” resulting in clean, annotation-free code:

The fluent style is a pleasant side-effect, common in C#, but the primary motivation is type inference ergonomics.

Workflow Runner

The IWorkflowRunner<'ins> interface provides two execution modes:

  • Run: Simple execution without undo support.

  • RunInSaga: Execution with undo support β€” returns both the result and the saga state (history of all steps and their statuses).

The prepareInstructions parameter is a function that receives an IInstructionPreparer and returns the instruction set implementation. This is where each instruction is wired to its data-layer function and its undo strategy. This wiring happens in the domain's Api class.

Dependency Injection

The Dependencies.fs file provides the DI registration:

The concrete types (MetricsLogger, WorkMonitors, WorkflowRunnerFactory) are defined inside a module private Implementation block, making them invisible outside the Shopfoo.Program assembly. Only the interfaces (IMetricsSender, IWorkMonitors, IWorkflowRunnerFactory) are public. This brings three benefits:

  • Encapsulation: Implementation details cannot be referenced directly by consumers β€” they can only be reached through the interfaces.

  • Reduced API surface: The public contract is limited to what callers actually need, preventing accidental coupling to internals.

  • Dependency inversion (SOLID): Domain and feature projects depend on the abstractions, not the concrete types. Implementations can change or be swapped (e.g., for testing) without touching any call site.

The IWorkflowRunnerFactory creates IWorkflowRunner<'ins> instances for a given domain name. Each domain project calls workflowRunnerFactory.Create(domainName) to obtain its runner.

Summary

Component
Responsibility

Program.fs

Program type (async reader), CE builder with applicative support

Saga.fs

Undo types (Undo, UndoFunc), step tracking, saga finalization

Runner.fs

Workflow runner, instruction preparer with monitoring and undo

Metrics.fs

Timing and metrics for instructions

Dependencies.fs

DI registration

The V4 program achieves:

  • Radical simplicity: Program is a function, not an ADT.

  • Parallel execution: let! ... and! ... runs instructions concurrently via map2.

  • Undo support: Saga pattern with reversible and compensatable commands.

  • Observability: Logging, timing, and step tracking built into the runner infrastructure.

  • Domain isolation: Each domain defines its own instruction interface and wires it independently.

Last updated