Repository F# setup
open System
open System.IO
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers
open Axial.Console
open Axial.FileSystem
open Axial.Hosting
open Axial.Hosting.Browser
open Axial.Hosting.Node
open Axial.PlatformService
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScriptTask vs Flow: seven scenarios
This page compares seven application operations implemented twice: once with Task, exceptions, cancellation tokens,
and manually passed services; once with Flow<'env, 'error, 'value>. Both versions use the same domain types and
service interfaces.
Flow does not remove side effects or make remote operations transactional. It gives each operation a compatible contract and runtime model. The type records the capabilities it needs and expected failures it can return. The runtime owns cancellation, child work, and scoped cleanup. Retry, timeout, parallelism, and recovery are policies you compose with the operation instead of conventions each caller must reproduce.
The comparison test file tests each scenario's behavior. Each scenario identifies what the type communicates, what the runtime guarantees, and what your application must still handle. ZIO concepts are named alongside their Axial counterparts.
| Scenario | Type makes visible | Runtime enforces |
|---|---|---|
| 1. Checkout orchestration | required services and the complete failure set | release runs on every exit shape, including defects |
| 2. Resilient HTTP with a retry budget | transient/terminal error taxonomy and both capabilities | only predicate-accepted typed failures retry; the timeout reaches in-flight work |
| 3. Parallel dashboard fan-out | which branch may fail silently | loser interruption and cause merging |
| 4. Scoped temporary workspace | acquisition and finalization as one signature | the finalizer runs on every exit shape and its failure is preserved |
| 5. Application wiring | the full capability set | nothing — the compiler checked it |
| 6. Producer/consumer pipeline | stream failure and environment requirements stay typed | pull-based evaluation stops the producer with the consumer |
| 7. Atomic inventory reservation | STM as a transaction value, separate from effects |
all-or-nothing commit; coherent-snapshot retry |
Each row links to its full comparison below.
A self-contained pair, checked here: a lookup that fails the same way in both models, but only Flow names the
failure in the type.
Show page setup
open System
open System.Threading.Tasks
open Axial
let shouldEqual expected actual =
if actual <> expected then failwithf "Expected %A but got %A" expected actualtype UserId = UserId of int
type LookupError = | Missing of int
let directory = Map [ (1, "Ada"); (2, "Grace") ]
// Task: a missing user is an exception the signature does not name.
let taskLookup (UserId id) : Task<string> =
task {
match Map.tryFind id directory with
| Some name -> return $"Hello, {name}"
| None -> return raise (InvalidOperationException($"no user {id}"))
}
// Flow: the same failure is part of the type.
let flowLookup (UserId id) : Flow<unit, LookupError, string> =
flow {
match Map.tryFind id directory with
| Some name -> return $"Hello, {name}"
| None -> return! Flow.fail (Missing id)
}
FsLiveDocsGeneratedPage6_2D306DEB1823.UserIdUserIdintAn abbreviation for the CLI type . Basic Types
FsLiveDocsGeneratedPage6_2D306DEB1823.LookupErrorMissingdirectory: Map<int,string>``.ctor``: (int * string) seq -> Map<int,string>Builds a map that contains the bindings of the given IEnumerable. The input sequence of key/value pairs. The resulting map. This is an O(n log n) operation, where n is the number of elements in the sequence. Map [ (1, "a"); (2, "b") ] // evaluates to map [(1, "a"); (2, "b")]
taskLookup: UserId -> Task<string>_arg1: UserIdSystem.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
stringAn abbreviation for the CLI type . Basic Types
task: TaskBuilderBuilds a task using computation expression syntax.
Microsoft.FSharp.Collections.MapModuleContains operations for working with values of type .
tryFind: 'Key -> Map<'Key,'T> -> 'T optionLookup an element in the map, returning a Some value if the element is in the domain of the map and None if not. The input key. The input map. The found Some value or None. Maps are represented as binary trees so this is an O(log n) operation, where n is the number of bindings in the map. let sample = Map [ (1, "a"); (2, "b") ] sample |> Map.tryFind 1 // evaluates to Some "a" sample |> Map.tryFind 3 // evaluates to None
id: intSomeThe representation of "Value of type 'T" The input value. An option representing the value.
name: stringNoneThe representation of "No value"
raise: Exception -> 'TRaises an exception The exception to raise. The result value. open System.IO exception FileNotFoundException of string let readFile (fileName: string) = if not (File.Exists(fileName)) then raise(FileNotFoundException(fileName)) File.ReadAllText(fileName) readFile "/this-file-doest-exist" When executed, raises a FileNotFoundException.
``.ctor``: string -> unitInitializes a new instance of the class with a specified error message. The message that describes the error.
flowLookup: UserId -> Flow<unit,LookupError,string>Axial.Flow`3unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
flow: FlowBuilderThe universal flow { } computation expression.
Axial.Flowfail: 'error -> Flow<'env,'error,'value>Same as error. The error value to wrap in a failing flow. A flow that always fails with the provided error. let result = Flow.fail "error" |> Flow.run () // result = Failure (Cause.Fail "error")
taskLookup (UserId 1) |> fun (t: Task<string>) -> t.GetAwaiter().GetResult() |> shouldEqual "Hello, Ada"
flowLookup (UserId 1) |> Flow.run () |> shouldEqual (Exit.Success "Hello, Ada")
flowLookup (UserId 99) |> Flow.run () |> shouldEqual (Exit.Failure (Cause.Fail (Missing 99)))
taskLookup: UserId -> Task<string>UserId(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
t: Task<string>System.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
stringAn abbreviation for the CLI type . Basic Types
GetAwaiter: unit -> Runtime.CompilerServices.TaskAwaiter<string>Gets an awaiter used to await this . An awaiter instance.
GetResult: unit -> stringEnds the wait for the completion of the asynchronous task. The result of the completed task. The object was not properly initialized. The task was canceled. The task completed in a state.
shouldEqual: 'a -> 'a -> unitflowLookup: UserId -> Flow<unit,LookupError,string>Axial.Flowrun: 'env -> Flow<'env,'error,'value> -> Exit<'value,'error>Runs the workflow and blocks until the final exit is available. The environment used by the workflow. The workflow to run. The final workflow exit. let exit = workflow |> Flow.run environment
Axial.Exit`2Represents the final outcome of a workflow execution. The type of the success value. The type of the domain-specific failure value.
SuccessThe workflow completed successfully.
FailureThe workflow failed due to a specific cause.
Axial.Cause`1Represents the cause of a failed workflow. The type of the domain-specific failure value.
FailAn expected domain-specific failure.
Missing1. Checkout orchestration with compensation
Reserve stock, charge a card, create a shipment; release the reservation when charging or shipment creation fails.
Read the checkout comparison source.
The ordinary version is a Task<CheckoutReceipt> whose expected failures leave the signature as exceptions, and
whose compensation lives in a catch block. The comparison includes checkoutBuggy, a failure branch added outside
the try, and a test proving the reservation leaks.
let checkout (sku: string) (amount: decimal) : Flow<CheckoutEnv, CheckoutError, CheckoutReceipt> =
let reserve: Flow<CheckoutEnv, CheckoutError, ReservationId * IInventory> =
flow {
let! inventory = Flow.envWith _.Inventory
let! reservation = inventory.Reserve(sku, 1) |> Bind.mapError Inventory
return reservation, inventory
}
let fulfil (reservation: ReservationId) : Flow<CheckoutEnv, CheckoutError, CheckoutReceipt> =
flow {
let! payments = Flow.envWith _.Payments
let! payment = payments.Charge amount |> Bind.mapError Payment
let! shipping = Flow.envWith _.Shipping
let! shipment = shipping.CreateShipment reservation |> Bind.mapError Shipping
return { Reservation = reservation; Payment = payment; Shipment = shipment }
}
Flow.scoped (
Flow.scopeAcquireRelease
reserve
(fun (reservation, inventory) -> Flow.fromTask (fun _ -> task { do! inventory.Release reservation }))
|> Flow.bind (fun (reservation, _) -> fulfil reservation))Failures enter CheckoutError at each bind site with
Bind.mapError. The Bind guide explains why the same mapping syntax works for
Result, asynchronous results, and Flow sources. The release action is attached to the reservation's lexical
lifetime, so it runs on success, typed failure, defect (a test throws from the shipping
adapter), and interruption. ZIO correspondence: environment services, typed errors, acquireRelease.
- Made visible by the type: the required services (
CheckoutEnv) and the complete failure set (CheckoutError). - Enforced by the runtime: release runs on every exit shape, including defects the catch-based version can miss.
- Still the application's responsibility: remote compensation is not transactional; idempotency keys and reconciliation are still required.
2. Resilient HTTP call with a retry budget
Fetch an exchange rate through Axial.HttpClient: retry only transient
transport failures, back off exponentially, stop after three attempts, and turn a two-second deadline into
RateError.TimedOut. Never retry malformed successful responses.
Read the retry-budget comparison source.
The ordinary version interleaves a retry loop, CancellationTokenSource.CancelAfter, Task.Delay, and exception
classification in one function, and a broad with _ -> would also retry a NullReferenceException.
let transientOnly =
Retry.schedule
{ Retries = 2
Backoff = Backoff.Exponential(TimeSpan.FromMilliseconds 50.0, TimeSpan.FromSeconds 1.0)
When =
function
| Transport _ -> true
| TimedOut
| Malformed _ -> false }
request
|> Flow.retry transientOnly
|> Flow.timeout (TimeSpan.FromSeconds 2.0) TimedOutRetry and timeout are policies applied to a cold workflow from outside. The retry predicate selects typed failures;
defects and interruption are never retried, because Flow.retry re-runs Cause.Fail only. The
tests pin all four behaviors: recovery within the budget, budget exhaustion, no retry of Malformed, and the
timeout interrupting a hung request. ZIO correspondence: timeoutFail, typed Schedule, retry.
- Made visible by the type: the transient/terminal error taxonomy and both required capabilities:
IHasHttpfor the request andIHasClockfor retry delays and the two-second timeout. The underlying request needs onlyIHasHttp; the composed operation needs an environment implementing both interfaces. - Enforced by the runtime: only predicate-accepted typed failures are retried; the timeout reaches sleeps and the in-flight request through the ambient cancellation token.
- Still the application's responsibility: the operation must be safe to repeat, and the adapter must not hide a clock or other effect.
3. Parallel dashboard fan-out
Load account, orders, and recommendations concurrently. Account and orders are mandatory; recommendations fall back to an empty list on their typed failure; a mandatory failure interrupts the still-running sibling.
Read the dashboard comparison source.
let recommended: Flow<DashboardEnv, PageError, Recommendation list> =
flow {
let! recommendations = Flow.envWith _.Recommendations
let! token = Flow.cancellationToken
let! loaded = recommendations.For(accountId, token) |> Bind.mapError OrdersUnavailable
return loaded
}
|> Flow.orElse (Flow.succeed []) // recover ONLY this branch; mandatory errors stay typed
Flow.zipPar (Flow.zipPar account recent) recommended
|> Flow.map (fun ((account, recent), recommended) ->
{ Account = account; Orders = recent; Recommendations = recommended })
|> Activity.traceOn activitySource "dashboard.load"The ordinary Task.WhenAll version must cancel siblings by hand through a linked token source, and two simultaneous
failures surface as whichever exception WhenAll publishes first. With zipPar the runtime does the interruption:
the test's slow sibling awaits Task.Delay on its runtime token and asserts that it observed the resulting
OperationCanceledException. Concurrent failures merge as Cause.Both. First-success semantics
are a different contract, so they appear as a separate Flow.race example rather than a subtle change to this one.
ZIO correspondence: zipPar, typed catchAll (here orElse), race.
- Made visible by the type: which branch may fail silently (none: the fallback is explicit at the composition).
- Enforced by the runtime: loser interruption and cause merging.
- Still the application's responsibility: Flow cannot prove an arbitrary adapter honours cancellation, and an adapter that registers its own extra observer on the runtime token, instead of catching the cancellation that the operation it awaits throws, can miss the interrupt; see Task and Async Interop.
4. Scoped temporary workspace
Create a temporary directory through Axial.FileSystem, perform fallible
steps, remove the directory exactly once on every exit shape.
Read the workspace comparison source.
The ordinary comparison includes importBatchLeaky: construction succeeds, then a setup check
throws before ownership transfers into try/finally. The test proves the directory survives. In the Flow version
there is no such gap: Flow.scopeAcquireRelease owns the resource from the instant acquisition succeeds, and
Flow.scoped keeps the failing gate inside the resource's lifetime:
let acquire: Flow<WorkspaceEnv, ImportError, string> =
flow {
let workspace = Path.Combine(Path.GetTempPath(), "axial-workspace-" + Guid.NewGuid().ToString "N")
do! FileSystem.createDirectory workspace |> Flow.mapError (FileSystemError.describe >> UnreadableBatch)
return workspace
}
Flow.scoped (
Flow.scopeAcquireRelease
acquire
(fun workspace -> Flow.fromBlocking (fun _ -> Directory.Delete(workspace, recursive = true)))
|> Flow.bind (fun workspace ->
flow {
// Every step here — including this gate that fails before any file exists —
// is inside the resource's lifetime.
do! if List.isEmpty records then Flow.fail NoRecords else Flow.succeed ()
do!
FileSystem.writeAllLines (Path.Combine(workspace, "batch.csv")) records
|> Flow.mapError (FileSystemError.describe >> UnreadableBatch)
return records.Length
}))ZIO correspondence: Scope and ZIO.acquireRelease; for resources that should live as long as a provided layer,
Axial has Flow.scopeAcquireRelease and Layer.acquireRelease. One difference: a forked fiber in Axial owns its own
scope, like ZIO.scoped(effect).fork, so use Flow.forkResource where ZIO code would fork an acquireRelease and
join it later (see the Scope reference).
- Made visible by the type: acquisition and finalization form one construct with one signature.
- Enforced by the runtime: the finalizer runs on success, typed failure, defect, and interruption, and a finalizer failure is preserved in the cause rather than replacing the primary outcome.
- Still the application's responsibility: choosing the correct scope; keeping finalizers idempotent.
5. Application wiring that cannot omit a capability
A daily report needs a clock, a filesystem, a console, and a report store. The ordinary version threads four
constructor parameters through every caller, and callers can instead read
DateTimeOffset.UtcNow directly.
Read the report-wiring comparison source.
The Flow version declares the capability set once as an environment record implementing one contract per
capability, and business code names only what it uses through the package operations
(Clock.now, FileSystem.readAllText,
Console.writeLine):
let writeDailyReport (sourcePath: string) : Flow<ReportEnv, ReportError, string> =
flow {
let! now = Clock.now
let name = $"""daily-{now.UtcDateTime.ToString "yyyy-MM-dd"}.txt"""
let! body =
FileSystem.readAllText sourcePath
|> Flow.mapError (FileSystemError.describe >> StoreRejected)
let! store = Flow.envWith _.Store
do! store.Save(name, body) |> Flow.fromResult |> Flow.mapError StoreRejected
do! Console.writeLine $"wrote {name}"
return name
}
/// Production wiring: live platform services, merged into the one environment record at the
/// application edge. Tests build the same record from Clock.fromValue and in-memory doubles.
let liveLayer (store: IReportStore) : Layer<unit, Never, ReportEnv> =
Layer.merge (Layer.merge Clock.layer (Layer.succeed FileSystem.live)) (Layer.succeed Console.live)
|> Layer.map (fun ((clock, fileSystem), console) ->
{ Clock = clock
FileSystem = fileSystem
Console = console
Store = store })The test builds the same record from Clock.fromValue and in-memory doubles and asserts a deterministic report
name, with no time-mocking framework or service locator. Reading the clock without declaring it does not compile.
ZIO correspondence: environment requirements and ZLayer.
- Made visible by the type: the full capability set; a missing capability is a compile error at the edge.
- Enforced by the runtime: nothing, because the compiler already checked it.
- Still the application's responsibility: the type proves presence and shape, not configuration quality.
6. Producer/consumer pipeline with backpressure and interruption
Stream records (or live process output), transform them, persist them, and stop the producer promptly when the consumer fails.
Read the output-pipeline comparison source.
The ordinary version combines a bounded Channel, a background producer task, a linked token source, and manual
observation of the producer's exception. Without linked.Cancel(), a consumer failure can return while the producer keeps writing.
A FlowStream is cold and pull-based: when persistence fails, the consumer stops pulling. The test
streams from an instrumented infinite sequence and asserts almost nothing was produced past the failing element.
The process variant uses Process.streamStdoutLines, which streams decoded stdout lines from a live
process through the same pipeline shape:
let streamProcessOutput<'env when 'env :> IHasProcess and 'env :> IHasClock>
(specification: ProcessSpec)
(persist: string -> Result<unit, RecordError>)
: Flow<'env, RecordError, int> =
let lines =
specification
|> Process.streamStdoutLines
|> ProcessStream.toFlowStream
|> FlowStream.mapError (fun error -> BadRecord(ProcessError.describe error))
processLines lines persistWhere a producer must genuinely run ahead, Flow.fork returns a Fiber the caller owns and must join or
interrupt. ZIO correspondence: ZStream, scoped fibers, interruption.
- Made visible by the type: stream failure and environment requirements stay typed through the pipeline; a forked producer is a value someone must own.
- Enforced by the runtime: pull-based evaluation stops the producer with the consumer; fiber interruption reaches sleeps.
- Still the application's responsibility: the buffering policy (
Process.streamStdoutLinesdelivery is bounded; document your own), and never detaching a fiber without an owner.
7. Atomic inventory reservation under contention
Two checkouts race for the last unit; a reservation waits for replenishment or falls back to an alternative warehouse, with no locks in the business logic.
Read the inventory STM comparison source.
The ordinary version is a lock, a counting semaphore for wakeups, and a hand-maintained invariant that stock and
reservation counts change together; the comment in the example marks exactly where swapping the semaphore for a
Monitor pulse introduces a missed wakeup.
let reserve (inventory: Inventory) : Flow<'env, 'error, Warehouse> =
STM.atomically (
STM.orElse
(reserveFrom inventory.LocalStock Local inventory)
(reserveFrom inventory.RegionalStock Regional inventory)The transaction either commits both TRef changes or neither; STM.retry suspends without blocking a thread and
re-runs from a coherent snapshot when any participating TRef changes; orElse chooses the alternative warehouse
inside the same transaction. The tests race two fibers for one unit per warehouse and assert nothing oversells, and
park a reservation on empty stock until a replenishment commits. ZIO correspondence: TRef, STM.retry, orElse.
- Made visible by the type:
STM<'value>is a transaction value, separate from effects, composed before commit. - Enforced by the runtime: all-or-nothing commit; coherent-snapshot retry.
- Still the application's responsibility: the guarantee covers STM-managed memory only; payment, database, HTTP, and logging effects stay outside the transaction. Axial's current STM serializes transactions through one lock; it favours correctness over throughput under high contention.
Running the comparisons
dotnet test tests/Axial.Comparisons.Tests --nologo
Each comparison source file defines the shared
domain types, then the Ordinary and WithFlow modules. Each implementation states its full return type.

