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
Make the safe path the easy path.
Flow is one type for application work that runs asynchronously, can fail, can be cancelled, and uses services. Axial's runtime cancels, cleans up, and retries that work by the same rules everywhere, and the type says what the work needs.
Async work and expected errors in one type
With Task<Result<_, _>>, every step matches the result before the next can run. In flow { }, let! stops at the first expected error, and an exception becomes a defect in the outcome instead of escaping. FsToolkit.ErrorHandling's taskResult { } removes the same nesting, but the result is still Task code, with cancellation and cleanup handled by hand. The next points cover those.
task {
match! loadCart id with
| Error e -> return Error e
| Ok cart ->
match! charge cart with
| Error e -> return Error e
| Ok payment ->
return Ok(receipt cart payment)
}flow {
let! cart = loadCart id
let! payment = charge cart
return receipt cart payment
}Cancellation is handled the same way everywhere
A flow takes no cancellation token. When one of two parallel loads fails, the other is interrupted. When the time limit passes, both are, and what they acquired is released before the timeout returns. Forked work belongs to the flow that started it, so nothing outlives a cancelled request.
use cts =
CancellationTokenSource
.CreateLinkedTokenSource token
cts.CancelAfter(TimeSpan.FromSeconds 2.0)
let profile = loadProfile user cts.Token
let orders = loadOrders user cts.Token
// one failing does not stop the other
do! Task.WhenAll(profile :> Task, orders)Flow.zipPar
(loadProfile user)
(loadOrders user)
|> Flow.timeout
(TimeSpan.FromSeconds 2.0)
DashboardTimedOutDependencies in the signature mark the boundaries
A flow's first type parameter names the services it uses. reserveStock can reach inventory and nothing else, so the compiler shows where one area of the application calls into another, and a test supplies only what the flow names.
// Which services does it use?
// The signature does not say.
val checkout:
Cart -> CancellationToken
-> Task<Result<Receipt, OrderError>>val reserveStock: Cart ->
Flow<Inventory, OrderError, Reservation>
val chargeCard: Cart ->
Flow<Billing, OrderError, Payment>
val placeOrder: Cart ->
Flow<Shop, OrderError, Receipt>Time, randomness, and IDs are services too
Reading the clock or making a GUID adds that service to the flow's type, and the requirement carries up to every caller. The application supplies the live services; a test supplies fixed ones and gets the same result every run.
// A different result on every run,
// and nothing in the type says why.
{ Id = Guid.NewGuid()
PlacedAt = DateTimeOffset.UtcNow }flow {
let! placedAt = Clock.now
let! id = Guid.newGuid
return { Id = id; PlacedAt = placedAt }
}
// needs: 'env :> IHasClock and IHasGuidResources belong to their scope
A helper can open a connection and return it. It closes when the caller's scope ends, in reverse order, however the scope ends.
flow {
let! orders = connect "orders"
let! billing = connect "billing"
return summarise orders billing
}
|> Flow.scopedStreams keep the same rules
Four workers at a time; once ten pages arrive, the rest are interrupted and their resources released.
FlowStream.fromSeq pageIds
|> FlowStream.mapFlowPar
(Parallelism.bounded 4) fetchPage
|> FlowStream.take 10
|> FlowStream.runCollectYou can see what is running
Every fiber has an id, a parent, and a name. A registry lists the live ones; telemetry turns them into spans and metrics.
poll
|> Flow.forkNamed "outbox-poller"
|> Flow.annotate "tenant" "acme"
|> Flow.withFiberRegistry registry
// registry.DumpAt(clock) lists live fibersFewer states to reason about, for people and for LLMs
Every flow ends in one of three ways. Concurrency follows fixed rules instead of per-call-site choices, and the build reports the shortcuts the types cannot rule out. A reviewer, or an LLM coding assistant, has fewer cases to consider and gets told about the usual mistakes.
- Forked work belongs to the scope that started it.
- Cancellation always arrives as an interruption.
- Resources are released in reverse order, however the scope ends.
- Axial.Guardrails reports direct clock and randomness calls, exceptions raised in
flow { }, and dropped cancellation tokens.
A workflow names the environment it needs. The caller supplies the implementation when it runs the workflow.
Built on Axial
Process
Compose external commands and pipelines, stream output, and handle cancellation and failures through Flow.
Read the Process documentation →
HTTP
Build typed requests, handle responses, and apply reliability policies through the same workflow model.