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

Tutorial: Layers

Layers construct environments before workflows run.

Use a layer when you need to:

  • build an environment from other services or config
  • fail during provisioning before the workflow starts
  • own resources that must be cleaned up exactly once
  • compose several independent startup steps in parallel

1. The Workflow Still Targets An Environment

Show page setup
// Setup for the checked examples on this page.
open System
open System.Threading.Tasks
open Axial
open Axial.Layers
/// Fails the docs test when an example's result differs from the value shown.
let shouldEqual expected actual =
    if actual <> expected then failwithf "Expected %A but got %A" expected actual
Verified
open System
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers

type IOrders =
    abstract Save : string -> Task<unit>

type IClock =
    abstract UtcNow : unit -> DateTimeOffset

type AppEnv =
    { Orders: IOrders
      Clock: IClock }

let saveOrder (orderId: string) : Flow<AppEnv, string, string> =
    flow {
        let! env = Flow.env
        do!
            ColdTask(fun _ ->
                task {
                    do! env.Orders.Save orderId
                    return ()
                })
        let today = env.Clock.UtcNow().ToString "yyyy-MM-dd"
        return $"saved {orderId} at {today}"
    }
Requires & types
Requires: open Axial; open System; open System.Threading.Tasks (packages: Axial)
val saveOrder: string -> Flow<AppEnv,string,string>

The workflow still depends on AppEnv. Layers only change how AppEnv gets built.

2. Build Small Layers

Verified
let persisted = ResizeArray<string>()

let ordersLayer : Layer<unit, string, IOrders> =
    Layer.succeed
        { new IOrders with
            member _.Save orderId =
                task {
                    // The real dependency goes here: open a connection, run a transaction.
                    persisted.Add orderId
                } }

let clockLayer : Layer<unit, string, IClock> =
    Layer.succeed
        { new IClock with
            member _.UtcNow() = DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) }
Requires & types
Requires: open Axial.Layers; open System (packages: Axial.Layers)
val persisted: ResizeArray<string>
val ordersLayer: Layer<unit,string,IOrders>
val clockLayer: Layer<unit,string,IClock>

3. Merge Them Into An App Layer

Verified
let appLayer : Layer<unit, string, AppEnv> =
    layer {
        let! orders = ordersLayer
        and! clock = clockLayer

        return
            { Orders = orders
              Clock = clock }
    }
Requires & types
Requires: open Axial.Layers (packages: Axial.Layers)
val appLayer: Layer<unit,string,AppEnv>

Use plain let! when one provisioning step depends on another. Use sibling and! when the steps are independent.

4. Provision Failure Happens Before Business Logic

Verified
let failingOrdersLayer : Layer<unit, string, IOrders> =
    Layer.fromFlow (Flow.fail "database connection string missing")
Requires & types
Requires: open Axial; open Axial.Layers (packages: Axial, Axial.Layers)
val failingOrdersLayer: Layer<unit,string,IOrders>
Verified
persisted.Clear()

let failingAppLayer : Layer<unit, string, AppEnv> =
    layer {
        let! orders = failingOrdersLayer
        and! clock = clockLayer
        return { Orders = orders; Clock = clock }
    }

saveOrder "A-100" |> Layer.provide failingAppLayer |> Flow.run () |> shouldEqual (Exit.Failure(Cause.Fail "database connection string missing"))
persisted.Count |> shouldEqual 0
Requires & types
Requires: open Axial; open Axial.Layers (packages: Axial, Axial.Layers)
val failingAppLayer: Layer<unit,string,AppEnv>

If provisioning fails, Layer.provide never runs the downstream workflow: nothing was persisted.

5. Resource Ownership

Verified
type FakeConnection() =
    member val Disposed = false with get, set

    interface IAsyncDisposable with
        member this.DisposeAsync() =
            this.Disposed <- true
            ValueTask(Task.CompletedTask)

let connections = ResizeArray<FakeConnection>()

let connectionLayer : Layer<unit, string, FakeConnection> =
    Layer.fromFlow (
        flow {
            let connection = new FakeConnection()
            connections.Add connection
            do! Flow.scopeAsyncDisposable connection
            return connection
        }
    )
Requires & types
Requires: open Axial; open Axial.Layers; open System; open System.Threading.Tasks (packages: Axial, Axial.Layers)
val connections: ResizeArray<FakeConnection>
val connectionLayer: Layer<unit,string,FakeConnection>
Verified
connections.Clear()

for _ in 1..2 do
    Flow.envWith (fun (open': FakeConnection) -> open'.Disposed)
    |> Layer.provide connectionLayer
    |> Flow.run ()
    |> shouldEqual (Exit.Success false)

connections.Count |> shouldEqual 2
List.ofSeq connections |> List.map (fun connection -> connection.Disposed) |> shouldEqual [ true; true ]
Requires & types
Requires: open Axial; open Axial.Layers (packages: Axial, Axial.Layers)

Acquisition happens inside the layer, so each Layer.provide run builds a fresh connection and registers its release in the provisioning scope. While each workflow ran, its connection was open; once Layer.provide finished, that connection was disposed.

Layer.fromFlow runs its flow in the provisioning scope, so whatever it registers there (Flow.scopeAsyncDisposable here, or Flow.scopeAcquireRelease) is released when the provided workflow completes, fails, or is interrupted.

6. Run Through Layer.provide

Verified
persisted.Clear()

saveOrder "A-100"
|> Layer.provide appLayer
|> Flow.run ()
|> shouldEqual (Exit.Success "saved A-100 at 2026-01-01")

List.ofSeq persisted |> shouldEqual [ "A-100" ]
Requires & types
Requires: open Axial; open Axial.Layers (packages: Axial, Axial.Layers)

Feature entry points provide the layer once and run the workflow without opening or closing startup resources themselves.