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.HttpClient
open Axial.PlatformService
open Axial.Process
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScriptTask vs Flow, Seven Scenarios
Effect syntax in isolation proves nothing. This page walks through seven realistic programs implemented twice in
examples/Axial.Comparisons —
once with Task, exceptions, cancellation tokens, and manually passed services, and once with
Flow<'env, 'error, 'value> — over identical domain types and service interfaces, so the only variable is the
workflow model.
Flow does not make side effects pure and does not prevent all defects. The gain, scenario by scenario, is that
expected failure, dependencies, cancellation, resource lifetime, and composition policy become visible in the
signature and testable at the composition point. Every guarantee claimed below has a test in
tests/Axial.Comparisons.Tests
that fails if the guarantee is removed.
Each scenario ends with three lists: what the type makes visible, what the runtime enforces, and what remains the application's responsibility. The design family is shared with ZIO; correspondences are noted where they help, and omitted where Axial has no honest counterpart.
1. Checkout orchestration with compensation
Reserve stock, charge a card, create a shipment; release the reservation when charging or shipment creation fails.
The ordinary version is a Task<CheckoutReceipt> whose expected failures leave the signature as exceptions, and
whose compensation lives in a catch block someone must remember. The comparison includes checkoutBuggy, the common
real-world edit — a failure branch added outside the try — and a test proving the reservation leaks.
// Flow<CheckoutEnv, CheckoutError, CheckoutReceipt>
Flow.acquireReleaseWith
reserve // acquire: typed InventoryError -> CheckoutError
(fun (reservation, inventory) _ -> inventory.Release reservation) // release: attached to the resource
(fun (reservation, _) -> fulfil reservation) // use: charge + ship, each error mapped at its bind- 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.
The ordinary version interleaves a retry loop, CancellationTokenSource.CancelAfter, Task.Delay, and exception
classification in one function — and one overly broad with _ -> away from retrying a NullReferenceException.
request // Flow<'env, RateError, Rate>, cold
|> Flow.Runtime.retry transientOnly // RetryPolicy<RateError>: ShouldRetry = Transport only
|> Flow.Runtime.timeout (TimeSpan.FromSeconds 2.0) TimedOut- Made visible by the type: the transient/terminal error taxonomy and the
IHasHttprequirement. - 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.
let applicationActivitySource = new System.Diagnostics.ActivitySource("Dashboard.Application")
let recommended = loadRecommendations |> Flow.orElse (Flow.succeed []) // recover ONLY this branch
Flow.zipPar (Flow.zipPar account recent) recommended
|> Flow.map (fun ((account, recent), recommended) -> { ... })
|> Activity.traceOn applicationActivitySource "dashboard.load" // application-owned span- 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 the operation it's already awaiting throws — can silently 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.
The ordinary comparison includes importBatchLeaky, the classic leak: 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.acquireReleaseWith owns the resource from the instant acquisition succeeds, and the
failing gate lives inside the resource's lifetime:
Flow.acquireReleaseWith
acquire // FileSystem.createDirectory, typed errors
(fun workspace _ -> Task.Run(fun () -> Directory.Delete(workspace, recursive = true)))
(fun workspace -> flow {
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
})- 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 nothing stops a hurried edit from reading
DateTimeOffset.UtcNow directly.
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! body = FileSystem.readAllText sourcePath |> Flow.mapError (FileSystemError.describe >> StoreRejected)
...
}
// Production edge: live services merged into the record once.
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 })- Made visible by the type: the full capability set; a missing capability is a compile error at the edge.
- Enforced by the runtime: nothing at runtime needs to fail — the proof happened at compilation.
- 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.
The ordinary version combines a bounded Channel, a background producer task, a linked token source, and manual
observation of the producer's exception — and its classic bug (returning after a consumer failure while the
producer keeps writing) is one forgotten linked.Cancel() away.
A FlowStream is cold and pull-based: when persistence fails, the stream is simply never pulled again. The test
streams from an instrumented infinite sequence and asserts almost nothing was produced past the failing element.
The process variant uses Process.stream — typed ProcessEvents from a live
process through the same pipeline shape:
Process.stream specification
|> FlowStream.mapError (ProcessError.describe >> BadRecord)
|> FlowStream.choose (function
| ProcessEvent.Output output when output.Channel = OutputChannel.StdOut -> Some(output.Text.TrimEnd('\r', '\n'))
| _ -> None)
|> ... // same consumer as the in-memory variant- 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.streamdelivery 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 — without locks leaking into business logic.
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.
STM.atomically (
STM.orElse
(reserveFrom inventory.LocalStock Local inventory) // STM.retry when empty
(reserveFrom inventory.RegionalStock Regional inventory))- 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 source file in examples/Axial.Comparisons is self-contained: shared domain types at the top, the
Ordinary module, then the WithFlow module, with the full return type stated above each implementation.

