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

Service Contracts

A record works because you own both sides: the workflow names AppEnv, and you supply an AppEnv. A package author cannot do that. Axial.Console is compiled long before your AppEnv exists, so Console.writeLine cannot mention it.

A package declares an ordinary interface named IHasFoo with a single member Foo:

The examples on this page use these application types:

Show page setup
// Setup for the checked examples on this page.
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
/// 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
type Order = { Id: int }
type CheckoutError = AlreadySaved of int

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

type IEmailSender =
    abstract SendConfirmation: Order -> unit
Verified
type IHasOrders =
    abstract Orders: IOrderRepository

The helper constrains its environment to that interface instead of naming a type:

Verified
[<RequireQualifiedAccess>]
module Orders =
    let service<'env, 'error when 'env :> IHasOrders> : Flow<'env, 'error, IOrderRepository> =
        Flow.envWith _.Orders

let save order : Flow<#IHasOrders, CheckoutError, unit> =
    flow {
        let! orders = Orders.service
        do! orders.Save order
    }

Read Flow<#IHasOrders, …> as "any environment that can give me an IOrderRepository". This is still just Flow.envWith; the interface only says which member it may read.

Supplying one

Your record implements the contracts it satisfies; its fields can retain their existing names:

Verified
type IHasEmail =
    abstract Email: IEmailSender

[<RequireQualifiedAccess>]
module Email =
    let service<'env, 'error when 'env :> IHasEmail> : Flow<'env, 'error, IEmailSender> =
        Flow.envWith _.Email

type AppEnv =
    { Orders: IOrderRepository
      Email: IEmailSender }

    interface IHasOrders with member this.Orders = this.Orders
    interface IHasEmail with member this.Email = this.Email

member this.Orders = this.Orders is not recursive. F# interface implementations are always explicit, so the interface member is not in scope on the record itself and the right-hand side resolves to the field. A record with no such field fails to compile rather than looping.

Needing more than one

Contracts are distinct interfaces, so a workflow can require several and the constraints merge on their own:

Verified
let submit<'env when 'env :> IHasOrders and 'env :> IHasEmail> (order: Order) : Flow<'env, CheckoutError, unit> =
    flow {
        let! orders = Orders.service
        let! email = Email.service
        do! orders.Save order
        email.SendConfirmation order
    }

AppEnv satisfies both constraints, so it runs save and submit alike:

Verified
let gate = obj()
let mutable saved = []
let mutable sent = []

let env =
    { Orders =
        { new IOrderRepository with
            member _.Save order =
                lock gate (fun () ->
                    if List.contains order.Id saved then
                        Error(AlreadySaved order.Id)
                    else
                        saved <- order.Id :: saved
                        Ok()) }
      Email = { new IEmailSender with member _.SendConfirmation order = lock gate (fun () -> sent <- order.Id :: sent) } }

submit { Id = 1 } |> Flow.run env |> shouldEqual (Exit.Success())
save { Id = 1 } |> Flow.run env |> shouldEqual (Exit.Failure(Cause.Fail(AlreadySaved 1)))
lock gate (fun () -> List.rev sent) |> shouldEqual [ 1 ]

When a combination recurs, name it and a single constraint covers it:

Verified
type ICheckoutEnv =
    inherit IHasOrders
    inherit IHasEmail

let submitOrder (order: Order) : Flow<#ICheckoutEnv, CheckoutError, unit> = submit order

#IHasOrders carries exactly one constraint, which is why the aggregate exists. Each occurrence of #T is a separate type variable, so when an environment appears more than once in a signature, use the explicit <'env when 'env :> … and 'env :> …> form. The troubleshooting types page shows the exact rule and where it bites.

When to declare one

For application code, use the record described in the environment. It needs no interface.

Declare a contract when you are publishing a helper whose callers you will never see, such as a shared library or a package like Axial.FileSystem. A record cannot cover that case, because the helper cannot know the caller's record type.

Writing a package that ships services is covered in providing services from a package.