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: Explicit Dependencies First

This tutorial starts one step before Flow<'env, 'error, 'value>. Define small interfaces, pass them explicitly, and compose a few operations before introducing an environment record.

Use this approach first when:

  • the workflow is still local to one feature
  • you want to prove the dependency boundaries before choosing an environment shape
  • you want direct tests without building an environment first

1. Define The Contract

Show page setup
// Setup for the checked examples on this page.
open System
open System.Threading
open System.Threading.Tasks
open Axial
/// 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.Tasks

type OrderId = OrderId of Guid

type Order =
    { Id: OrderId
      Email: string
      Total: decimal }

type PlaceOrderError =
    | InvalidEmail
    | OrderRejected of string

type IOrderRepository =
    abstract Save : Order -> Task<Result<unit, string>>

type IEmailSender =
    abstract SendConfirmation : Order -> Task
Requires & types
Requires: open System; open System.Threading.Tasks

These interfaces are intentionally narrow. They represent what the workflow needs, not the full database or mail client API.

PlaceOrderError deliberately has no timeout or cancellation case: this workflow owns no cancellation source or deadline, so a cancelled run surfaces as an interruption (matched with Cause.isInterrupted in step 5), and a timeout would only become a typed error if the workflow owned its deadline.

2. Compose Small Flows

Verified
let validateOrder (order: Order) : Result<Order, PlaceOrderError> =
    if String.IsNullOrWhiteSpace order.Email then
        Error InvalidEmail
    else
        Ok order

let saveOrder (orders: IOrderRepository) (order: Order) : Flow<unit, PlaceOrderError, Order> =
    flow {
        do!
            Flow.fromTaskResult(fun _ -> orders.Save order)
            |> Flow.mapError OrderRejected

        return order
    }

let sendConfirmation (email: IEmailSender) (order: Order) : Flow<unit, PlaceOrderError, unit> =
    flow {
        do!
            ColdTask(fun _ ->
                task {
                    do! email.SendConfirmation order
                    return ()
                })
    }

let placeOrder
    (orders: IOrderRepository)
    (email: IEmailSender)
    (order: Order)
    : Flow<unit, PlaceOrderError, OrderId> =
    flow {
        let! validOrder = validateOrder order
        let! savedOrder = saveOrder orders validOrder
        do! sendConfirmation email savedOrder
        return savedOrder.Id
    }
Requires & types
Requires: open Axial; open System (packages: Axial)
val validateOrder: Order -> Result<Order,PlaceOrderError>
val saveOrder: IOrderRepository -> Order -> Flow<unit,PlaceOrderError,Order>
val sendConfirmation: IEmailSender -> Order -> Flow<unit,PlaceOrderError,unit>
val placeOrder: IOrderRepository -> IEmailSender -> Order -> Flow<unit,PlaceOrderError,OrderId>

This approach keeps dependencies explicit:

  • pure validation stays in Result
  • each dependency is passed explicitly
  • Flow is only used where async work and typed execution outcomes matter

Flow.fromTaskResult and ColdTask are both cold task factories, but they bind different shapes. The repository returns a Task<Result<_, _>>, so Flow.fromTaskResult sends its Error case to the typed-error channel (mapped with Flow.mapError). The sender returns a bare Task, so ColdTask wraps it directly. Both discard the token here (fun _ ->) because the interfaces do not accept one. When the underlying API can be cancelled, accept the token and forward it, so cancelling the workflow stops the in-flight call:

Verified
type ICancellableSender =
    abstract SendConfirmation : Order * CancellationToken -> Task

let sendConfirmationCancellable (email: ICancellableSender) (order: Order) : Flow<unit, PlaceOrderError, unit> =
    flow {
        do!
            ColdTask(fun token ->
                task {
                    do! email.SendConfirmation(order, token)
                    return ()
                })
    }
Requires & types
Requires: open Axial; open System.Threading; open System.Threading.Tasks (packages: Axial)
val sendConfirmationCancellable: ICancellableSender -> Order -> Flow<unit,PlaceOrderError,unit>

3. Realistic Implementations

Verified
type SqlOrderRepository() =
    interface IOrderRepository with
        member _.Save order =
            task {
                // In an application, this could use DbConnection, EF Core, or Dapper.
                printfn "Saving %A to the database" order.Id
                return Ok ()
            }

type SmtpEmailSender() =
    interface IEmailSender with
        member _.SendConfirmation order =
            task {
                // In an application, this could use an SMTP client or SendGrid SDK.
                printfn "Sending order email to %s" order.Email
            }

4. Test Implementations

Verified
type RecordingOrderRepository(saved: ResizeArray<Order>) =
    interface IOrderRepository with
        member _.Save order =
            task {
                saved.Add order
                return Ok ()
            }

type RecordingEmailSender(sent: ResizeArray<string>) =
    interface IEmailSender with
        member _.SendConfirmation order =
            task {
                sent.Add order.Email
            }

If these doubles are awkward to use, reconsider the production dependency boundary.

5. Run The Flow

In production, placeOrder gets SqlOrderRepository() and SmtpEmailSender(). A test gives it the recording doubles and checks both the outcome and what each dependency saw:

Verified
let saved = ResizeArray<Order>()
let sent = ResizeArray<string>()
let orders = RecordingOrderRepository(saved)
let email = RecordingEmailSender(sent)

let order =
    { Id = OrderId(Guid.Parse "7d3c2e1a-0000-4000-8000-000000000001")
      Email = "ada@example.com"
      Total = 99.95m }

placeOrder orders email order |> Flow.run () |> shouldEqual (Exit.Success order.Id)
List.ofSeq sent |> shouldEqual [ "ada@example.com" ]

placeOrder orders email { order with Email = " " } |> Flow.run () |> shouldEqual (Exit.Failure(Cause.Fail InvalidEmail))
saved.Count |> shouldEqual 1
Requires & types
Requires: open Axial; open System (packages: Axial)
val saved: ResizeArray<Order>
val sent: ResizeArray<string>
val orders: RecordingOrderRepository
val email: RecordingEmailSender
val order: Order

The invalid order failed validation before either dependency was called: one order was saved and one email sent.

At the application edge, turn the Exit into whatever the caller needs:

Verified
let describe (exit: Exit<OrderId, PlaceOrderError>) =
    match exit with
    | Exit.Success _ -> "placed"
    | Exit.Failure(Cause.Fail InvalidEmail) -> "rejected before any dependency was called"
    | Exit.Failure(Cause.Fail(OrderRejected reason)) -> $"the repository rejected the order: {reason}"
    | Exit.Failure cause when Cause.isInterrupted cause -> "interrupted"
    | Exit.Failure _ -> "unexpected failure"
Requires & types
Requires: open Axial (packages: Axial)
val describe: Exit<OrderId,PlaceOrderError> -> string
Verified
Exit.Failure(Cause.Fail(OrderRejected "duplicate")) |> describe |> shouldEqual "the repository rejected the order: duplicate"
Requires & types
Requires: open Axial (packages: Axial)

6. Why This Stops Scaling

When several helpers pass the same dependencies through their parameters, use an environment record:

  • the workflow code still depends on the same interfaces
  • the execution boundary gets cleaner
  • adding a third dependency becomes additive instead of rewriting every call site

Continue with Tutorial: App Record.