Tutorial: App Record
Move from explicit dependency parameters to a reusable environment record.
This tutorial starts where Explicit Dependencies First leaves off.
The problem is not that explicit parameters are wrong. The problem is repetition:
- every helper has to thread the same dependencies
- adding one more dependency means touching many signatures
- the execution boundary gets noisier as the feature grows
An app record solves that by bundling dependencies once at the boundary while keeping the workflow code explicit.
1. Start With The Same Interfaces
open System
open System.Threading.Tasks
open FsFlow
type OrderId = OrderId of Guid
type Order =
{ Id: OrderId
Email: string
Total: decimal }
type PlaceOrderError =
| InvalidEmail
| OrderRejected of string
| AuditWriteFailed
type IOrderRepository =
abstract Save : Order -> Task<Result<unit, string>>
type IEmailSender =
abstract SendConfirmation : Order -> Task
type IAuditLog =
abstract Write : string -> Task<Result<unit, unit>>
2. Bundle Them Once
type AppEnv =
{ Orders: IOrderRepository
Email: IEmailSender
Audit: IAuditLog }
Adding a third dependency is now an additive change to the environment record rather than a rewrite of every helper signature.
3. Compose Several Flows
let validateOrder (order: Order) : Result<Order, PlaceOrderError> =
if String.IsNullOrWhiteSpace order.Email then
Error InvalidEmail
else
Ok order
let saveOrder (order: Order) : Flow<AppEnv, PlaceOrderError, Order> =
flow {
let! orders = Flow.read _.Orders
let! saveResult = orders.Save order
match saveResult with
| Ok () -> return order
| Error reason -> return! Flow.fail (OrderRejected reason)
}
let sendConfirmation (order: Order) : Flow<AppEnv, PlaceOrderError, unit> =
flow {
let! email = Flow.read _.Email
do! email.SendConfirmation order
}
let writeAudit (message: string) : Flow<AppEnv, PlaceOrderError, unit> =
flow {
let! audit = Flow.read _.Audit
let! result = audit.Write message
match result with
| Ok () -> return ()
| Error () -> return! Flow.fail AuditWriteFailed
}
let placeOrder (order: Order) : Flow<AppEnv, PlaceOrderError, OrderId> =
flow {
let! validOrder = validateOrder order
let! savedOrder = saveOrder validOrder
do! sendConfirmation savedOrder
do! writeAudit $"Placed {savedOrder.Email} for {savedOrder.Total}"
return savedOrder.Id
}
This is the main win of an app record:
- helper functions stop carrying dependency parameters
- helper functions still say exactly which fields they read
- adding another dependency does not force you to redesign the whole feature
4. Real Implementations
type SqlOrderRepository() =
interface IOrderRepository with
member _.Save order =
task {
// Imagine the real dependency here: database transaction, ORM, etc.
return Ok ()
}
type SmtpEmailSender() =
interface IEmailSender with
member _.SendConfirmation order =
task {
// Imagine the real dependency here: SMTP or email API client.
}
type FileAuditLog() =
interface IAuditLog with
member _.Write message =
task {
// Imagine the real dependency here: file append, structured logger, queue, etc.
return Ok ()
}
5. Test Implementations
type RecordingOrders(saved: ResizeArray<Order>) =
interface IOrderRepository with
member _.Save order =
task {
saved.Add order
return Ok ()
}
type RecordingEmails(sent: ResizeArray<string>) =
interface IEmailSender with
member _.SendConfirmation order =
task {
sent.Add order.Email
}
type RecordingAudit(entries: ResizeArray<string>) =
interface IAuditLog with
member _.Write message =
task {
entries.Add message
return Ok ()
}
6. Run The Workflow
let run () = task {
let env =
{ Orders = SqlOrderRepository() :> IOrderRepository
Email = SmtpEmailSender() :> IEmailSender
Audit = FileAuditLog() :> IAuditLog }
let order =
{ Id = OrderId(Guid.NewGuid())
Email = "ada@example.com"
Total = 125m }
let! exit = (placeOrder order).ToTask(env)
match exit with
| Exit.Success orderId ->
printfn "Placed %A" orderId
| Exit.Failure cause ->
printfn "%s" (Cause.prettyPrint (function
| InvalidEmail -> "invalid email"
| OrderRejected reason -> reason
| AuditWriteFailed -> "audit write failed") cause)
}
When To Stop Here
An app record is enough for a lot of applications.
Move beyond it when:
- you want reusable helpers to depend on named contracts instead of record field names
- you want startup-time provisioning with failure handling
- you want scope-owned resources and cleanup
Continue with Tutorial: Creating Reusable Services and then Tutorial: Layers.