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.JavaScript

Why Flow?

Flow<'env, 'error, 'value> is a type for application work that runs asynchronously, can fail in expected ways, can be cancelled, and uses services. The examples below show eight differences from Task.

The examples share these types:

1. Async work and expected errors in one type

With Task, an operation that can fail returns Task<Result<_, _>>, and every step has to match the result before the next step can run:

Show page setup
type Cart = { Id: int; Total: decimal }
type Reservation = { CartId: int }
type Payment = { Reference: string }
type Receipt = { Reservation: Reservation; Payment: Payment }

type OrderError =
    | CartNotFound
    | OutOfStock
    | CardDeclined

let receipt reservation payment = { Reservation = reservation; Payment = payment }
Verified
let loadCartTask (id: int) : Task<Result<Cart, OrderError>> =
    Task.FromResult(Ok { Id = id; Total = 40m })

let reserveTask (cart: Cart) : Task<Result<Reservation, OrderError>> =
    Task.FromResult(Ok { CartId = cart.Id })

let chargeTask (cart: Cart) : Task<Result<Payment, OrderError>> =
    Task.FromResult(Ok { Reference = "p-1" })

let checkoutTask (id: int) : Task<Result<Receipt, OrderError>> =
    task {
        match! loadCartTask id with
        | Error error -> return Error error
        | Ok cart ->
            match! reserveTask cart with
            | Error error -> return Error error
            | Ok reservation ->
                match! chargeTask cart with
                | Error error -> return Error error
                | Ok payment -> return Ok(receipt reservation payment)
    }

In flow { }, let! waits for the work and stops at the first expected error:

Verified
let loadCart (id: int) : Flow<OrderError, Cart> = Flow.ok { Id = id; Total = 40m }
let reserve (cart: Cart) : Flow<OrderError, Reservation> = Flow.ok { CartId = cart.Id }
let charge (cart: Cart) : Flow<OrderError, Payment> = Flow.ok { Reference = "p-1" }

let checkout (id: int) : Flow<OrderError, Receipt> =
    flow {
        let! cart = loadCart id
        let! reservation = reserve cart
        let! payment = charge cart
        return receipt reservation payment
    }

An exception thrown inside the work does not escape as a faulted task. It becomes a defect in the flow's outcome, kept apart from the expected errors in OrderError.

FsToolkit.ErrorHandling's taskResult { } removes the same nesting, and flow { } reads the same way. The result is still Task code, though: cancellation tokens, failures in parallel work, and cleanup are still handled by hand at every call site. The FsToolkit.ErrorHandling comparison shows how to use the two together.

2. Cancellation is handled the same way everywhere

With Task, cancellation is a CancellationToken argument that every function must accept and pass on. Running two loads in parallel with a time limit needs a linked token source, and a failure in one load does not stop the other:

Verified
type Profile = { Name: string }
type Order = { Number: int }

type DashboardError =
    | ProfileUnavailable
    | DashboardTimedOut

let loadProfileTask (user: int) (token: CancellationToken) : Task<Profile> =
    task {
        do! Task.Delay(10, token)
        return { Name = "Ada" }
    }

let loadOrdersTask (user: int) (token: CancellationToken) : Task<Order list> =
    task {
        do! Task.Delay(10, token)
        return [ { Number = 1 } ]
    }

let dashboardTask (user: int) (token: CancellationToken) : Task<Profile * Order list> =
    task {
        use limit = CancellationTokenSource.CreateLinkedTokenSource token
        limit.CancelAfter(TimeSpan.FromSeconds 2.0)
        let profile = loadProfileTask user limit.Token
        let orders = loadOrdersTask user limit.Token
        let! _ = Task.WhenAll(profile :> Task, orders :> Task)
        return profile.Result, orders.Result
    }

A flow takes no token. The runtime passes cancellation to all the work it starts:

Verified
let loadProfile (user: int) : Flow<ClockEnvironment, DashboardError, Profile> =
    Flow.sleep (TimeSpan.FromMilliseconds 10.0) |> Flow.map (fun () -> { Name = "Ada" })

let loadOrders (user: int) : Flow<ClockEnvironment, DashboardError, Order list> =
    Flow.sleep (TimeSpan.FromMilliseconds 10.0) |> Flow.map (fun () -> [ { Number = 1 } ])

let dashboard (user: int) : Flow<ClockEnvironment, DashboardError, Profile * Order list> =
    Flow.zipPar (loadProfile user) (loadOrders user)
    |> Flow.timeout (TimeSpan.FromSeconds 2.0) DashboardTimedOut

If loadOrders fails, loadProfile is interrupted. If the time limit passes, both are interrupted, and whatever they acquired has been released before dashboard returns DashboardTimedOut. The same holds for work started with Flow.fork: it runs inside the scope of the flow that started it, and it is interrupted and awaited when that flow ends. No work outlives the operation that started it, so a request that is cancelled or times out leaves nothing running.

Retries and timeouts are functions applied to a flow, such as Flow.retry (Schedule.recurs 3). Because a flow is a description of work rather than work that has started, a retry runs it again from the beginning.

3. Dependencies in the signature mark the boundaries

With Task, services usually arrive through a constructor or a dependency-injection container. The signature of checkoutTask above does not say that it reserves stock or charges a card.

A flow's first type parameter names the services it uses. Each area of the application declares its own:

Verified
type Inventory = { Reserve: Cart -> Result<Reservation, OrderError> }
type Billing = { Charge: Cart -> Result<Payment, OrderError> }
type Shop = { Inventory: Inventory; Billing: Billing }

let reserveStock (cart: Cart) : Flow<Inventory, OrderError, Reservation> =
    Flow.envWith (fun inventory -> inventory.Reserve cart) |> Flow.bind Flow.fromResult

let chargeCard (cart: Cart) : Flow<Billing, OrderError, Payment> =
    Flow.envWith (fun billing -> billing.Charge cart) |> Flow.bind Flow.fromResult

let placeOrder (cart: Cart) : Flow<Shop, OrderError, Receipt> =
    flow {
        let! reservation = reserveStock cart |> Flow.localEnv _.Inventory
        let! payment = chargeCard cart |> Flow.localEnv _.Billing
        return receipt reservation payment
    }

reserveStock can only reach Inventory, and the compiler rejects a call to billing from inside it. placeOrder is where the two areas meet, and Flow.localEnv marks each crossing. A test of reserveStock supplies an Inventory and nothing else.

4. Time, randomness, and IDs are services too

Code that reads DateTimeOffset.UtcNow or calls Guid.NewGuid() gives a different result on every run, and nothing in its signature says so. Testing it means waiting, or replacing a static.

In a flow, the clock and the GUID generator are services like any other. Reading them adds their requirement to the flow's type:

Verified
type PlacedOrder = { Id: Guid; PlacedAt: DateTimeOffset }

let stamp<'env when 'env :> IHasClock and 'env :> IHasGuid> : Flow<'env, Never, PlacedOrder> =
    flow {
        let! placedAt = Clock.now
        let! id = Guid.newGuid
        return { Id = id; PlacedAt = placedAt }
    }

Clock.now requires an environment that has a clock, and the requirement carries up to every flow that calls stamp, whether or not their types are written out. The application supplies the live services; a test supplies fixed ones and gets the same result every time:

Verified
type Fixed =
    { Clock: IClock
      Guid: IGuid }
    interface IHasClock with
        member this.Clock = this.Clock
    interface IHasGuid with
        member this.Guid = this.Guid

let fixedServices =
    { Clock = Clock.fromValue (DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero))
      Guid = Guid.fromValue Guid.Empty }

let placed = stamp |> Flow.run fixedServices

With the fixed clock and GUID implementations above, repeated runs produce the same stamp. A live service can read changing external state or hold mutable state, so reusing an environment alone does not guarantee identical results. With Axial.Guardrails installed, the build also warns when code reads the clock, randomness, or environment variables directly instead of through a service.

5. Resources belong to the flow that acquired them

With Task, use releases a resource when the function that opened it returns. A helper that opens a connection cannot hand it to its caller and still guarantee it is closed, and a resource opened by background work has no owner.

A flow registers each resource with its scope. A helper can acquire one and return it, and the resource stays open until the scope that ran the helper closes:

Verified
type Connection = { Name: string }

let closed = ResizeArray<string>()

let connect (name: string) : Flow<Connection> =
    Flow.scopeAcquireRelease (Flow.ok { Name = name }) (fun connection ->
        Flow.delay (fun () ->
            closed.Add connection.Name
            Flow.ok ()))

let report : Flow<string> =
    flow {
        let! orders = connect "orders"
        let! billing = connect "billing"
        return $"{orders.Name} and {billing.Name} are open"
    }
    |> Flow.scoped

Flow.scoped closes both connections when report ends, in reverse order, whether it succeeds, fails, or is interrupted. Work forked inside the scope is interrupted and awaited before its resources are released. The scopes guide compares this with use in more detail.

6. Streams keep the same rules

FlowStream is a stream of values produced by flows. Its operators run under the same cancellation and scope rules, so a pipeline with parallel workers stops all of them, and releases what they acquired, as soon as the consumer has enough:

Verified
let fetchPage (id: int) : Flow<ClockEnvironment, Never, string> =
    Flow.sleep (TimeSpan.FromMilliseconds 5.0) |> Flow.map (fun () -> $"page {id}")

let firstTen : Flow<ClockEnvironment, Never, string list> =
    FlowStream.fromSeq [ 1..1000 ]
    |> FlowStream.mapFlowPar (Parallelism.bounded 4) fetchPage
    |> FlowStream.take 10
    |> FlowStream.runCollect

At most four pages are fetched at a time, and once ten have arrived the remaining fetches are interrupted. The same pipeline written with IAsyncEnumerable and SemaphoreSlim has to cancel and await its own workers. The streams guide covers batching, time-based operators, and connecting streams to queues.

7. You can see what is running

A Task does not know which operation started it, and .NET cannot list the tasks that are still running. Every forked flow is a fiber with an id, a parent, and an optional name. A FiberRegistry lists the live ones as a tree, and annotations travel to every fiber the flow starts:

Verified
let registry = FiberRegistry(100)

let pollerSnapshot : Flow<ClockEnvironment, Never, string> =
    flow {
        let! clock = Flow.envWith (fun (env: ClockEnvironment) -> env.Clock)
        let! _ = Flow.sleep (TimeSpan.FromMinutes 1.0) |> Flow.forkNamed "outbox-poller"
        return registry.DumpAt(clock)
    }
    |> Flow.annotate "tenant" "acme"
    |> Flow.withFiberRegistry registry

The snapshot is taken before the parent flow ends; when it ends, it interrupts and awaits the poller. registry.DumpAt(clock) prints each live fiber with its name, status, age, and annotations, which is what a diagnostics endpoint or a stuck shutdown needs. Axial.Telemetry turns the same information into OpenTelemetry spans and metrics, including a count of failures in background work that nothing awaited. See observability.

8. Fewer states to reason about

A flow ends in one of three ways: its value, one of the errors named in its type, or an unexpected outcome (a defect or an interruption). There is no fourth case such as an exception that escapes past a Result, or a task still running after its caller has returned.

Concurrency follows fixed rules instead of per-call-site choices. Every forked fiber belongs to the scope that started it. Cancellation always arrives as an interruption, never as an error value that one layer handles and the next ignores. A scope releases its resources in reverse order however it ends. An interrupted take from a queue never loses a value. Code that uses these pieces does not need to be checked for each interleaving by hand, and the torture tests check that the rules hold under random interruption.

The build enforces the conventions the types cannot. Axial.Guardrails reports direct calls to ambient effects, exceptions raised inside flow { }, task adapters that drop their cancellation token, and formatting that breaks under NativeAOT; the diagnostics reference lists what each AXG code checks.

When to use something else

Write validation and other pure transformations as ordinary functions returning Result. Return Flow from application operations: the ones that call services, can be cancelled, need a timeout or retry, own a resource, or start background work. A Task-based service can be called from a flow directly, so you can adopt Axial one module at a time; see Add Axial to an existing Task application.