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

Providing Services from a Package

Application code owns both sides of its environment: the workflow names AppEnv, and the composition root supplies one. A package is compiled before its callers exist, so it cannot mention their types or require one record shape. This page covers the package side of service contracts.

The shape

Declare an interface, a contract (IHasFoo exposing one Foo member), and a module-level accessor. The reusable services tutorial walks through the complete exchange-rate example — the IExchangeRates interface, the IHasExchangeRates contract, the ExchangeRates.service accessor, a priceInAud helper, an app environment, a typed failure, and a test double. This page covers the rules that keep such packages composable.

Rules that keep contracts composable

One member per contract, named after the suffix. IHasFoo exposes Foo. This is what makes Flow.envWith _.Foo predictable and keeps a consumer's composition root readable when it implements six of them.

Never inherit a generic interface. F# rejects a type parameter constrained by two instantiations of the same generic interface, so a generic parent makes your contract impossible to combine with any other, including one from a different package. A contract inherits nothing, or inherits other plain contracts.

Fragment
type IHasRates = inherit IServiceContract<IExchangeRates>   // do not do this
type IHasRates = abstract ExchangeRates : IExchangeRates    // do this

Member names may collide freely. Two packages can both define IHasClient exposing Client, and one record can implement both. The record's implementations are explicit — each interface block names the contract it implements — so two contracts that expose the same member name do not clash and package authors do not need to coordinate names:

Show page setup
/// 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
// Two packages independently define contracts with the same member name.
type IHasClient =
    abstract Client : string

type IHasGateway =
    abstract Client : string

// One record implements both; each interface block names the contract it implements.
type ClientEnv =
    { ClientValue: string
      GatewayValue: string }
    interface IHasClient with
        member this.Client = this.ClientValue
    interface IHasGateway with
        member this.Client = this.GatewayValue
Verified
let clientEnv = { ClientValue = "http"; GatewayValue = "db" }
(clientEnv :> IHasClient).Client |> shouldEqual "http"
(clientEnv :> IHasGateway).Client |> shouldEqual "db"
Requires & types
val clientEnv: ClientEnv

Typed errors belong in the package

Publishing operations lets the package wrap the failure model once. Compare the raw interface call with what Axial.FileSystem publishes:

Fragment
fileSystem.ReadAllText path                 // string, throws
FileSystem.readAllText path                 // Flow<'env, FileSystemError, string>

FileSystem.readAllText uses Flow.catch to classify exceptions into a union the caller can match on. The custom-services tutorial builds the same pattern end to end: priceInAudOrFail wraps GetUsdToAud with Flow.attemptTask, classifies TimeoutException versus any other exception through ExchangeRateError.fromException, and runs three checked outcomes (success, ProviderTimedOut, and RateUnavailable).

Also expose the raw service

Publish the accessor (ExchangeRates.service) as part of the public API. Callers occasionally need the interface itself for interop, and without it there is no way to reach it once the environment is contract-based. Axial's own packages all do this.