Intermediate
Guide 7 — Repository Port as F# Function Type Alias + Npgsql Adapter Behind It
Why It Matters
A repository port is the seam that separates your application layer from the database. Every time you wire a database call directly inside an application service, you lose two things: the ability to swap the database for tests, and the ability to reason about the service's behavior without a running PostgreSQL instance. In procurement-platform-be the repository port is declared in the Application/ layer of each context — as a record-of-functions type alias in F#, a defprotocol in Clojure, an interface in TypeScript, or a record-of-effectful-functions in Haskell. The production adapter — Npgsql/Dapper in F#, next.jdbc in Clojure, pg (node-postgres) in TypeScript, or postgresql-simple with a connection pool in Haskell — satisfies that port in Infrastructure/. Nothing in the application layer knows whether PostgreSQL or an in-memory substitute is behind the port.
Standard Library First
Each language provides a way to express a function contract without introducing a production database dependency. The standard library gives you the full type system but no I/O primitive for PostgreSQL — you fall back to a raw connection handle and raw SQL strings, which leaks the infrastructure concern into the application layer:
// Standard library: repository port as a bare function alias over System.Data
module ProcurementPlatform.Contexts.Purchasing.Application.Ports
open System.Data
// => System.Data is the BCL's database abstraction — provider-agnostic interfaces
// => No NuGet dependency: ships with every .NET runtime
open ProcurementPlatform.Contexts.Purchasing.Domain
// => Domain types are the port's language — no database type crosses this boundary
// Read port — synchronous BCL style
type FindPurchaseOrder = PurchaseOrderId -> IDbConnection -> Result<PurchaseOrder option, exn>
// => IDbConnection: BCL abstract connection — Npgsql satisfies it at runtime
// => exn: stdlib catch-all error — loses semantic information about the failure cause
// => Synchronous return: BCL commands block the thread — no async support without workarounds
// Write port — synchronous BCL style
type SavePurchaseOrder = PurchaseOrder -> IDbConnection -> Result<unit, exn>
// => Result<unit, exn>: success or an opaque exception
// => The caller cannot distinguish constraint violation from connection failure without typeof checks
// => IDbConnection threading is manual — caller must open, pass, and close the connectionLimitation for production: IDbConnection threading is manual and error-prone. exn as the error type is untyped. Async is absent — synchronous DB calls block ASP.NET Core's thread pool under load.
Production Framework
The Npgsql stack in procurement-platform-be replaces raw IDbConnection threading with a per-request connection factory. The port record stays in the application layer with no Npgsql import — the adapter in Infrastructure/ owns the Npgsql dependency:
flowchart LR
port["App/Ports.fs\nPurchaseOrderRepository record"]:::orange
npgsql["Infra/NpgsqlPORepository.fs\nnpgsqlPORepository"]:::teal
mem["Infra/InMemoryPORepository.fs\ninMemoryPORepository"]:::purple
app["App/SubmitPurchaseOrder.fs\nsubmitPurchaseOrder (repo)"]:::blue
port -->|"satisfied by"| npgsql
port -->|"satisfied by in tests"| mem
app -->|"calls"| port
classDef orange fill:#DE8F05,color:#fff,stroke:#DE8F05
classDef teal fill:#029E73,color:#fff,stroke:#029E73
classDef purple fill:#CC78BC,color:#fff,stroke:#CC78BC
classDef blue fill:#0173B2,color:#fff,stroke:#0173B2
The application layer port record:
// Production port record — application layer, no Npgsql import
// src/ProcurementPlatform/Contexts/Purchasing/Application/Ports.fs
module ProcurementPlatform.Contexts.Purchasing.Application.Ports
open ProcurementPlatform.Contexts.Purchasing.Domain
// => Only domain types — no Npgsql, no EF Core, no Microsoft.EntityFrameworkCore
// => This is the isolation invariant: application layer has zero infrastructure imports
type RepositoryError =
| NotFound of PurchaseOrderId
// => Read-side: a missing PO is not a DB error — it is a domain outcome
| UniqueConstraintViolation
// => Write-side: Npgsql raises a 23505 PostgreSQL error code on duplicate primary key
| ConnectionFailure of exn
// => Infrastructure failure: carry the raw exception for logging; callers return HTTP 500
// Repository port as a record of functions — canonical shape used across all guides
type PurchaseOrderRepository =
{ FindPurchaseOrder: PurchaseOrderId -> Async<Result<PurchaseOrder option, RepositoryError>>
// => Async: the Npgsql adapter performs network I/O — never block the thread pool
// => option: a missing row is a valid domain outcome, not an error
SavePurchaseOrder: PurchaseOrder -> Async<Result<unit, RepositoryError>>
// => unit success: the caller does not re-read after a successful save
// => Adapter wraps NpgsqlException into RepositoryError at the seam
}
// => Record-of-functions: the application service receives one value, not two parameters
// => The Npgsql adapter and the in-memory test stub both satisfy this record shapeThe Npgsql adapter in the infrastructure layer satisfies the port record. It opens a connection from the pool and translates database exceptions:
// Production Npgsql adapter — infrastructure layer only
// src/ProcurementPlatform/Contexts/Purchasing/Infrastructure/NpgsqlPurchaseOrderRepository.fs
module ProcurementPlatform.Contexts.Purchasing.Infrastructure.NpgsqlPurchaseOrderRepository
// => Infrastructure layer: the only layer that may import Npgsql and Dapper
open Npgsql
// => Npgsql confined to infrastructure — the seam absorbs the framework dependency
open Dapper
// => Dapper: lightweight micro-ORM for mapping SQL result rows to F# records
open ProcurementPlatform.Contexts.Purchasing.Application.Ports
// => Import the port record — the adapter must satisfy PurchaseOrderRepository exactly
open ProcurementPlatform.Contexts.Purchasing.Domain
// => Domain types: PurchaseOrder, PurchaseOrderId — the adapter translates PurchaseOrderRow ↔ PurchaseOrder
// Npgsql adapter satisfying PurchaseOrderRepository
let npgsqlPurchaseOrderRepository (connStr: string) : PurchaseOrderRepository =
// => connStr: injected by the composition root at startup from AppConfig
// => Returns the record literal — each field is a function closed over connStr
{ SavePurchaseOrder =
// => SavePurchaseOrder: satisfies the write side of PurchaseOrderRepository
// => record literal field: assigned a function value — closed over connStr at construction time
fun po ->
// => fun po: the per-call argument — connStr is already bound
async {
// => async { }: F# computation expression — execution is lazy until Async.RunSynchronously or Async.StartImmediately
use conn = new NpgsqlConnection(connStr)
// => use: IDisposable — returns connection to the pool when the binding exits
// => NpgsqlConnection pool is managed by Npgsql; no explicit pool initialization needed
try
// => try/with: wraps Npgsql I/O — exceptions are translated at the seam boundary
let! _ =
conn.ExecuteAsync(
"INSERT INTO purchasing.purchase_orders (po_id, supplier_id, total_amount, currency, status, created_at) VALUES (@PoId, @SupplierId, @TotalAmount, @Currency, @Status, @CreatedAt)",
{ PoId = let (PurchaseOrderId id) = po.Id in id
// => Unwrap the single-case DU to get the raw Guid for the SQL parameter
SupplierId = let (SupplierId id) = po.SupplierId in id
// => Same pattern: SupplierId DU → raw Guid
TotalAmount = po.TotalAmount.Amount
// => Decimal amount extracted from the Money value object
Currency = po.TotalAmount.Currency
// => ISO 4217 currency code stored separately from the amount
Status = sprintf "%A" po.Status
// => Status DU serialized to string — stored as varchar in the DB
CreatedAt = po.CreatedAt })
// => UTC timestamp — the DB column is timestamptz
|> Async.AwaitTask
// => ExecuteAsync: issues the INSERT via Npgsql — actual network I/O here
// => Async.AwaitTask: bridges .NET Task to F# Async without thread blocking
return Ok ()
// => Ok (): the INSERT committed — callers do not re-read after a successful save
with
| :? PostgresException as ex when ex.SqlState = "23505" ->
// => SqlState "23505" is PostgreSQL's unique_violation error code
// => Translate to typed RepositoryError — application layer never sees the raw exception
return Error UniqueConstraintViolation
// => UniqueConstraintViolation: caller can return HTTP 409 without inspecting exceptions
| ex ->
// => All other exceptions: connection timeout, other constraint failures
return Error (ConnectionFailure ex)
// => Carry the raw exception for logging — the caller logs and returns HTTP 500
}
FindPurchaseOrder =
// => FindPurchaseOrder: satisfies the read side of PurchaseOrderRepository
fun (PurchaseOrderId poId) ->
// => Destructure the single-case DU — poId is the raw Guid
async {
// => async { }: same pattern as SavePurchaseOrder — all DB I/O is async
use conn = new NpgsqlConnection(connStr)
// => Fresh connection per call — pool manages the underlying socket
try
// => try/with: catches Npgsql I/O failures — translates to ConnectionFailure
let! row =
conn.QueryFirstOrDefaultAsync<PurchaseOrderRow>(
"SELECT * FROM purchasing.purchase_orders WHERE po_id = @PoId",
{| PoId = poId |})
|> Async.AwaitTask
// => |> Async.AwaitTask: bridges the .NET Task returned by Dapper to F# Async
// => QueryFirstOrDefaultAsync: returns null if no row found — mapped to None below
// => Anonymous record {| PoId = poId |}: Dapper maps this to the @PoId SQL parameter
match box row with
// => box: wraps F# value in obj to enable null check — F# records are non-nullable
| null -> return Ok None
// => No row found: valid domain outcome — caller decides what to do
// => Ok None rather than Error: absence of a row is not an infrastructure failure
| _ ->
return Ok (Some (purchaseOrderRowToDomain row))
// => purchaseOrderRowToDomain: maps the DB row record to the PurchaseOrder domain aggregate
// => Some: row was found — wrap in option to match the port contract
with ex ->
return Error (ConnectionFailure ex)
// => Any database error becomes ConnectionFailure — the caller logs and returns 500
// => Includes connection timeouts, SSL errors, and unexpected PostgreSQL errors
}
}Trade-offs: Dapper maps result rows to F# records efficiently without a full ORM change-tracker overhead. For write-heavy aggregates, Dapper's explicit SQL gives you fine-grained control over the INSERT shape. For read-heavy workloads, the lack of a change-tracker means no accidental N+1 queries. Npgsql-specific error codes (SqlState) are stable within PostgreSQL major versions — test your error handling against the target server version.
Guide 8 — In-Memory Repository Adapter for Integration Tests
Why It Matters
An integration test that hits a real PostgreSQL database is slow, requires Docker to be running, and cannot be cached. A test that uses an in-memory adapter runs in milliseconds, requires no infrastructure, and is safe to run in parallel. The seam from Guide 7 — the port contract, expressed as a record-of-functions type alias in F#, a defprotocol in Clojure, an interface in TypeScript, or a record-of-effectful-functions in Haskell — is exactly what makes this swap possible. Providing an in-memory adapter is not a testing trick; it is the proof that your port design is sound. If swapping the adapter requires changing the application service, the port has leaked infrastructure concerns upward.
Standard Library First
A mutable map in a global cell gives you an in-memory store with no external dependencies, but at the cost of type safety and test isolation:
// Standard library: in-memory store using a mutable Dictionary
open System.Collections.Generic
// => System.Collections.Generic.Dictionary is the BCL's hash map
// => No NuGet dependency — ships with every .NET runtime
let private store = Dictionary<System.Guid, string>()
// => Global mutable state — not thread-safe without a lock
// => string serialization loses domain type safety
// => Dictionary<Guid, string> is not typed to PurchaseOrder — drift risk
let inMemorySave (po: obj) =
// => obj parameter: no type safety — the compiler cannot prevent storing the wrong type
store.[System.Guid.NewGuid()] <- po.ToString()
// => ToString() serialization: round-trip fidelity not guaranteed
Ok ()Limitation for production: global mutable state fails under parallel test execution. Untyped storage introduces silent type mismatch bugs. The adapter does not satisfy the PurchaseOrderRepository record — a different shape means a different seam, not the same seam with a different implementation.
Production Framework
The in-memory adapter satisfies the same PurchaseOrderRepository port contract as the production database adapter. Each language wraps an immutable map in a thread-safe mutable cell — giving each test an isolated store without a shared global:
// In-memory adapter satisfying the port record
// src/ProcurementPlatform/Contexts/Purchasing/Infrastructure/InMemoryPurchaseOrderRepository.fs
module ProcurementPlatform.Contexts.Purchasing.Infrastructure.InMemoryPurchaseOrderRepository
open ProcurementPlatform.Contexts.Purchasing.Application.Ports
// => Import the port record — the adapter must satisfy PurchaseOrderRepository exactly
// => If the record shape changes, the compiler flags this module immediately
open ProcurementPlatform.Contexts.Purchasing.Domain
// => Domain types: PurchaseOrder, PurchaseOrderId
// Thread-safe in-memory store
let makeStore () =
ref Map.empty<PurchaseOrderId, PurchaseOrder>
// => ref wraps an immutable F# Map in a mutable cell
// => Map.empty is the zero value — no pre-populated state between tests
// => Calling makeStore() in each test gives a fresh, isolated store
// => No global state: parallel test execution is safe
// In-memory adapter satisfying PurchaseOrderRepository
let inMemoryPurchaseOrderRepository (store: Map<PurchaseOrderId, PurchaseOrder> ref) : PurchaseOrderRepository =
// => store is a ref cell — the adapter closes over it per test instance
// => Returns the record literal — must match PurchaseOrderRepository exactly
{ SavePurchaseOrder =
fun po ->
async {
match Map.tryFind po.Id !store with
// => !store dereferences the ref cell — reads the current Map
// => Map.tryFind: O(log n) lookup — checks for duplicate before insert
| Some _ ->
return Error UniqueConstraintViolation
// => Mirror the Npgsql adapter's behavior exactly
// => Tests that rely on duplicate detection work identically
| None ->
store := Map.add po.Id po !store
// => := updates the ref cell with a new immutable Map
// => Map.add is non-destructive — the old Map is not mutated
return Ok ()
// => Success: the PO is stored for subsequent FindPurchaseOrder calls
}
FindPurchaseOrder =
fun poId ->
// => Same store ref cell — reads whatever SavePurchaseOrder has written
async {
return Ok (Map.tryFind poId !store)
// => Map.tryFind returns Some PurchaseOrder or None
// => Wrapped in Ok: a missing PO is a valid outcome, not an error
// => Identical semantics to the Npgsql adapter's FindPurchaseOrder
}
}A test wires the in-memory adapter at the application service seam:
// Integration test using the in-memory adapter — no Docker, no PostgreSQL
// Tests/Purchasing/SubmitPurchaseOrderTests.fs
module ProcurementPlatform.Tests.Purchasing.SubmitPurchaseOrderTests
// => Test module: sits in the Tests/ folder, never imported by production code
open Xunit
// => xUnit: test framework — discovers [<Fact>] methods and reports pass/fail
open ProcurementPlatform.Contexts.Purchasing.Infrastructure.InMemoryPurchaseOrderRepository
// => Brings makeStore and inMemoryPurchaseOrderRepository into scope
open ProcurementPlatform.Contexts.Purchasing.Application.SubmitPurchaseOrder
// => Import the application service function under test
open ProcurementPlatform.Contexts.Purchasing.Domain
// => Domain types needed for the smart constructor call
[<Fact>]
// => [<Fact>]: xUnit attribute — marks a parameterless test method for the test runner
let ``submitPurchaseOrder stores a valid PO`` () =
// => xUnit Fact: parameterless test — runs once
async {
let store = makeStore ()
// => Fresh in-memory store: isolated from all other tests
let repo = inMemoryPurchaseOrderRepository store
// => PurchaseOrderRepository record — wired directly, no DI container
// Build a valid PO via domain smart constructors
let money = createMoney 500m "USD" |> Result.defaultWith failwith
// => Smart constructor: validates amount and currency — returns Money if valid
// => defaultWith failwith: fails the test if the Money is invalid
let po =
{ Id = PurchaseOrderId (System.Guid.NewGuid())
// => New Guid wrapped in single-case DU — uniquely identifies this PO
SupplierId = SupplierId (System.Guid.NewGuid())
// => Supplier identity — irrelevant for this test; any valid Guid works
TotalAmount = money
// => Validated Money value — 500 USD passed the smart constructor
Status = Draft
// => Initial state: all POs start as Draft before submission
CreatedAt = System.DateTimeOffset.UtcNow }
// => Domain aggregate constructed from validated value objects
let nullPub = { Publish = fun _ -> async { return Ok () } }
// => Null event publisher: does not need a real broker for this test
let clock = fun () -> System.DateTimeOffset.UtcNow
// => Real clock — frozen clock is shown in advanced guides
let! result = submitPurchaseOrder repo nullPub po
// => submitPurchaseOrder: application service from Guide 4
// => Called with the in-memory adapter — no Npgsql, no Docker required
match result with
// => Exhaustive match: the compiler enforces handling both Ok and Error branches
| Ok saved ->
Assert.Equal(po.Id, saved.Id)
// => The saved aggregate's ID matches the input — no mutation occurred
let! found = repo.FindPurchaseOrder po.Id
// => Verify the adapter actually persisted the PO in the store
Assert.Equal(Ok (Some saved), found)
// => The stored PO is retrievable by its ID
| Error e ->
Assert.Fail(sprintf "Expected Ok, got Error: %A" e)
// => Test fails with a descriptive message — never swallow errors silently
} |> Async.RunSynchronously
// => RunSynchronously: xUnit test runner expects synchronous completionTrade-offs: the in-memory adapter faithfully mirrors the Npgsql adapter's semantics only as far as you code it. If the Npgsql adapter introduces a new RepositoryError variant (e.g., SerializationFailure), the in-memory adapter must be updated too. Use the compiler: both adapters satisfy the same record type, so adding a new RepositoryError variant causes a compile error in both. That is the intended effect — the compiler enforces adapter parity.
Guide 9 — Domain Event Publisher Port: Record-of-Functions Style
Why It Matters
A domain event publisher port solves the same problem as a repository port, but for the outbound event stream. Every time the application service raises a domain event by calling a framework-specific message bus directly, the application layer acquires an infrastructure dependency. In procurement-platform-be, the publisher port is defined in each context's Application/ layer using the same port idiom as the repository — a record-of-functions type alias in F#, a defprotocol in Clojure, a single-method interface in TypeScript, or a record-of-effectful-functions in Haskell. The application service receives this value as a parameter and never imports the messaging or outbox library directly. Grouping publish operations into one value avoids parameter explosion when a context raises several event types.
Standard Library First
Every language provides an in-process pub/sub primitive — an event object, an atom of subscribers, an EventEmitter, or an IORef of callbacks. These work within a single process but provide no persistence, no retry, and no cross-process delivery:
// Standard library: in-process event using F# Event<_>
module ProcurementPlatform.Contexts.Purchasing.Domain.Events
// Domain event type — a plain discriminated union
type PurchasingEvent =
| PurchaseOrderSubmitted of poId: System.Guid * supplierId: System.Guid
// => Carries only primitive types — safe to serialize, safe to log
// => No domain aggregate reference — events are immutable facts, not live objects
| PurchaseOrderIssued of poId: System.Guid
// => Issued events carry the ID — downstream contexts (receiving, supplier-notifier) consume this
// In-process publisher using F# Event
let private publisher = Event<PurchasingEvent>()
// => F# Event<_>: in-process publish/subscribe — no persistence, no delivery guarantee
// => Single-process only: a second process cannot subscribe to this event
let publish (event: PurchasingEvent) =
publisher.Trigger(event)
// => Trigger: fire-and-forget — all subscribers called synchronously
// => If a subscriber throws, the publisher's call stack unwinds
// => No retry, no dead-letter queue, no outbox guaranteeLimitation for production: in-process events die with the process. If the application crashes after saving the aggregate but before publishing the event, the event is lost. The at-least-once delivery guarantee requires an outbox.
Production Framework
The record-of-functions port groups all publisher operations into one injected value. The application service receives the record and calls whichever fields apply to the current operation:
flowchart LR
port["App/Ports.fs\nEventPublisher record"]:::orange
mem["Infra/InMemoryEventPublisher\ninMemoryPublisher"]:::purple
outbox["Infra/OutboxEventPublisher\noutboxPublisher"]:::teal
svc["App/SubmitPurchaseOrder.fs\nsubmitPurchaseOrder (pub)"]:::blue
port -->|"satisfied by"| mem
port -->|"satisfied by in prod"| outbox
svc -->|"calls"| port
classDef orange fill:#DE8F05,color:#fff,stroke:#DE8F05
classDef teal fill:#029E73,color:#fff,stroke:#029E73
classDef purple fill:#CC78BC,color:#fff,stroke:#CC78BC
classDef blue fill:#0173B2,color:#fff,stroke:#0173B2
// Domain event publisher port — record-of-functions style
// src/ProcurementPlatform/Contexts/Purchasing/Application/Ports.fs (extended)
module ProcurementPlatform.Contexts.Purchasing.Application.Ports
open ProcurementPlatform.Contexts.Purchasing.Domain
// => Only domain types — no messaging library imported here
// Domain event discriminated union — plain F# stdlib types
type DomainEvent =
| PurchaseOrderSubmitted of PurchaseOrderSubmittedPayload
// => Carries the structured payload — the outbox adapter serializes it
| PurchaseOrderIssued of PurchaseOrderIssuedPayload
// => Issued events carry PO ID and supplier ID — consumed by receiving and supplier-notifier contexts
| PurchaseOrderCancelled of PurchaseOrderCancelledPayload
// => Cancelled carries the reason — consumed by accounting and supplier-notifier
// Record-of-functions publisher port
type EventPublisher =
{ Publish: DomainEvent -> Async<Result<unit, string>> }
// => Single Publish field: dispatches any DomainEvent case
// => Async<Result>: the outbox adapter writes to DB — async I/O, typed error
// => The in-memory adapter makes this effectively synchronous for tests
// => Adding a new event type: add a DU case and handle it in both adapters
// => The compiler flags every Publish call that pattern-matches on DomainEvent
// Approval router port — routes approval request to the appropriate manager
type ApprovalRouterPort =
{ RouteApproval: PurchaseOrderId -> ApprovalLevel -> Async<Result<unit, string>> }
// => RouteApproval: given a PO ID and computed approval level, dispatch to workflow engine
// => ApprovalLevel (L1/L2/L3) determines which manager receives the request
// => Adapter: workflow engine in production; stub in testsTrade-offs: the single-field record pattern is concise for contexts with one to three event types. For contexts with many event types where the Publish dispatcher grows large, consider separate record fields per event type — the compiler then enforces that all fields are supplied when constructing the record. For two to four event types, a single dispatching Publish function keeps the call sites readable and the application service free of event-specific ceremony.
Guide 10 — In-Memory Event Publisher Adapter and Outbox Adapter
Why It Matters
Two adapters satisfy the EventPublisher port from Guide 9: an in-memory adapter for tests (fast, zero infrastructure) and an outbox adapter for production (durable, survives process crashes). The outbox pattern writes the event to the same database transaction as the aggregate save — if the transaction commits, the event is guaranteed to be delivered eventually. Without an outbox, you face a dual-write hazard: the aggregate commits but the message bus call fails, and the event is silently lost. In procurement-platform-be, the outbox adapter writes event rows into an outbox_events table inside the same transaction as the aggregate — using Npgsql/Dapper in F#, next.jdbc inside a with-transaction block in Clojure, pg with a shared client in TypeScript, or postgresql-simple inside a withTransaction wrapper in Haskell.
Standard Library First
A global mutable list captures events in memory for test assertions, but at the cost of type safety and inter-test isolation:
// Standard library: capture events in a ResizeArray for test assertions
open System.Collections.Generic
let private captured = ResizeArray<obj>()
// => ResizeArray<obj>: mutable, untyped list — loses event type information
// => obj: the compiler cannot enforce that only DomainEvent values are stored
// => Not thread-safe: parallel test runs corrupt the shared list
let captureEvent (e: obj) =
captured.Add(e)
// => Append to the global list — no isolation between tests
// => Test A's events are visible to test B if both run in the same processLimitation for production: global mutable state breaks parallel test execution. Untyped storage makes assertion code fragile. The outbox pattern requires transactional writes — the stdlib has no transactional in-memory store.
Production Framework
In-memory adapter (for tests):
// In-memory event publisher adapter — typed, per-test-instance isolation
// src/ProcurementPlatform/Contexts/Purchasing/Infrastructure/InMemoryEventPublisher.fs
module ProcurementPlatform.Contexts.Purchasing.Infrastructure.InMemoryEventPublisher
open ProcurementPlatform.Contexts.Purchasing.Application.Ports
// => Import port record: EventPublisher, DomainEvent
// Per-test-instance event capture store
let makeInMemoryPublisher () =
// => Factory function: each test calls this to get an isolated publisher + captured list
// => No global state — parallel tests each hold their own ref cell
let captured = ref ([] : DomainEvent list)
// => Immutable F# list wrapped in a ref cell — same thread-safe pattern as InMemoryRepository
let publisher : EventPublisher =
// => Record literal: must supply all fields — compiler enforces the EventPublisher shape
{ Publish =
fun event ->
async {
captured := event :: !captured
// => Prepend to the immutable list via ref update — O(1) append
// => Tests inspect !captured after the application service call
return Ok ()
// => Ok (): satisfies the Async<Result<unit, string>> contract
}
}
(publisher, captured)
// => Tuple return: caller destructures with let (pub, captured) = makeInMemoryPublisher ()
// => Return both the publisher (to inject into the service) and the ref (for assertions)
// => Tests pattern-match on !captured to verify the correct events were raisedOutbox adapter (for production):
// Outbox event publisher adapter — writes event rows in the same Npgsql transaction
// src/ProcurementPlatform/Contexts/Purchasing/Infrastructure/OutboxEventPublisher.fs
module ProcurementPlatform.Contexts.Purchasing.Infrastructure.OutboxEventPublisher
// => Infrastructure layer: holds the Npgsql dependency — application layer never imports this
open System.Text.Json
// => System.Text.Json: serialize the event payload to a JSON string for the outbox row
open Npgsql
// => Npgsql: direct SQL INSERT for the outbox row — same connection as the aggregate save
open ProcurementPlatform.Contexts.Purchasing.Application.Ports
// => Port types: EventPublisher, DomainEvent
// Outbox row shape — persisted in purchasing.outbox_events
type OutboxRow =
// => CLIMutable not needed: Dapper uses the record field names directly for parameterized INSERT
{ id: System.Guid
// => UUID primary key — generated at publish time, not by the DB
event_type: string
// => DomainEvent case name as a string — used by the relay worker to dispatch
payload: string
// => JSON serialization of the event payload — relay worker deserializes with the same schema
created_at: System.DateTimeOffset
// => UTC timestamp: relay worker uses this for ordering and age-based alerting
processed_at: System.DateTimeOffset option }
// => Nullable: null until the relay worker has delivered the event
// Outbox publisher satisfying EventPublisher
let makeOutboxPublisher (connStr: string) : EventPublisher =
// => connStr: injected by the composition root — same schema as the purchasing.purchase_orders table
{ Publish =
// => Record literal: satisfies the EventPublisher port — must match the record shape exactly
fun event ->
// => event: the DomainEvent DU case dispatched by the application service
async {
// => async { }: F# computation expression — .NET Task under the hood
use conn = new NpgsqlConnection(connStr)
// => Separate connection for simplicity; in production, share the transaction
// => for atomic aggregate + outbox commit, use NpgsqlTransaction across both INSERTs
let row =
// => Construct the outbox row from the event — no domain logic here
{ id = System.Guid.NewGuid()
// => New UUID per event — idempotency key for the relay worker
event_type = event.GetType().Name
// => Type name: "PurchaseOrderSubmitted", "PurchaseOrderIssued", etc.
payload = JsonSerializer.Serialize event
// => Serialize the full DomainEvent DU — relay worker deserializes with the same schema
created_at = System.DateTimeOffset.UtcNow
// => UTC timestamp — always UTC in storage; convert to local time at display
processed_at = None }
// => None: outbox row starts unprocessed — relay worker sets this after delivery
let! _ =
conn.ExecuteAsync(
"INSERT INTO purchasing.outbox_events (id, event_type, payload, created_at, processed_at) VALUES (@id, @event_type, @payload, @created_at, @processed_at)",
row)
|> Async.AwaitTask
// => ExecuteAsync: actual network I/O — inserts the outbox row into the database
// => Async.AwaitTask: bridges .NET Task to F# Async
return Ok ()
// => Ok (): the publisher contract is fire-and-confirm, not fire-and-forget
}
}Trade-offs: the outbox pattern guarantees at-least-once delivery — the relay worker may deliver an event more than once if it crashes between delivery and marking processed_at. Consumers must be idempotent. The relay worker itself (polling the outbox_events table and forwarding to consumers) is covered in Guide 19. For contexts that emit events at low volume (< 100/s), a simple polling relay suffices. High-throughput contexts benefit from a CDC-based relay (e.g., Debezium) that reads the PostgreSQL WAL instead of polling.
Guide 11 — Giraffe Handler: Full DTO → Command → Aggregate → Response Pipeline
Why It Matters
Guide 6 showed the HTTP handler concept using a sketch of a domain-backed adapter. This guide goes deeper: every step of the translation pipeline — binding the request DTO, calling the smart constructor, dispatching to the application service, pattern-matching on the domain result, and emitting the response DTO — has an exact location in the hexagonal layout, and each location has a rule about what it may and may not import. Getting these rules wrong is the most common way any codebase silently collapses the hexagonal boundary — whether the adapter is a Giraffe HttpHandler in F#, a Ring/Reitit route function in Clojure, a Hono route callback in TypeScript, or a Servant handler in Haskell.
Standard Library First
Each language's stdlib HTTP layer handles binding and responses in a flat function, but validation logic is duplicated at every route, status codes are magic numbers, and business logic leaks into the handler:
// Standard library: ASP.NET Core Minimal API — no Giraffe
open Microsoft.AspNetCore.Builder
// => WebApplication.Create() and MapPost extension method
open Microsoft.AspNetCore.Http
// => HttpContext, ReadFromJsonAsync, WriteAsJsonAsync extensions
let app = WebApplication.Create()
// => Minimal API host — simpler than the builder pattern in Program.fs
app.MapPost("/api/v1/purchase-orders", fun (ctx: HttpContext) ->
// => MapPost: route registration — handler closure captures the HttpContext per request
task {
// => task { }: C#-style async computation — required by MapPost's delegate signature
let! dto = ctx.Request.ReadFromJsonAsync<{| supplierId: string; totalAmount: decimal; currency: string |}>()
// => Deserialize with BCL's HttpContext extension — no BindJsonAsync helper
// => Anonymous record DTO: no generated contract types, no CLIMutable attribute
if dto.totalAmount <= 0m then
// => Validation inline in the handler — duplicated at every endpoint
ctx.Response.StatusCode <- 400
// => Magic number 400: no typed RequestErrors combinator — repeated at every endpoint
do! ctx.Response.WriteAsJsonAsync({| error = "totalAmount must be positive" |})
// => Manual validation: every endpoint duplicates this pattern
else
ctx.Response.StatusCode <- 201
// => Magic number 201: Minimal API has no Successful.CREATED equivalent
do! ctx.Response.WriteAsJsonAsync({| id = System.Guid.NewGuid(); currency = dto.currency |})
// => Business logic (ID generation) leaks into the handler — no application service boundary
}) |> ignore
// => |> ignore: MapPost returns RouteHandlerBuilder — ignore discards it; the route is already registeredLimitation for production: validation logic duplicated across every MapPost lambda. Business logic in the handler. No typed error discrimination — status codes are magic numbers. The flat closure cannot compose with Giraffe middleware.
Production Framework
The production handler pipeline enforces a strict translation discipline: bind the DTO, validate through smart constructors, dispatch to the application service, and pattern-match on the typed result to emit the response:
// Full Giraffe handler pipeline — DTO → smart constructors → service → response DTO
// src/ProcurementPlatform/Contexts/Purchasing/Presentation/PurchasingHandlers.fs
module ProcurementPlatform.Contexts.Purchasing.Presentation.PurchasingHandlers
// => Presentation layer: the only layer allowed to import both domain and HTTP concerns
open Giraffe
// => Giraffe: HttpHandler, BindJsonAsync, RequestErrors, Successful, ServerErrors
open ProcurementPlatform.Contexts.Purchasing.Domain
// => Domain: PurchaseOrder types, smart constructors, value objects
open ProcurementPlatform.Contexts.Purchasing.Application.Ports
// => Ports: PurchaseOrderRepository, EventPublisher, RepositoryError
open ProcurementPlatform.Contexts.Purchasing.Application.SubmitPurchaseOrder
// => Application service: submitPurchaseOrder, SubmitPurchaseOrderError
// => Four imports only: no Npgsql, no System.Text.Json — handler is a pure adapter
// Request DTO — deserialized from JSON
[<CLIMutable>]
// => CLIMutable: reflection-based setters required by Giraffe's BindJsonAsync
type SubmitPurchaseOrderRequest =
{ SupplierId: System.Guid
// => Guid: no strongly-typed wrapper at the boundary — the smart constructor wraps it
TotalAmount: decimal
// => Raw decimal: validated by createMoney before entering the domain
Currency: string
// => ISO 4217 currency code — validated by createMoney smart constructor
Notes: string option }
// => Optional free-text note — not used in domain logic; stored for audit trail
// Response DTO — serialized to JSON
type SubmitPurchaseOrderResponse =
// => Record type: serialized to JSON by Giraffe's System.Text.Json serializer
{ PurchaseOrderId: System.Guid
// => Unwrapped Guid: clients do not need the strongly-typed DU
Status: string
// => String projection of the Status DU — human-readable, stable across versions
ApprovalLevel: string }
// => Response DTO: only the fields the client needs — not the full domain aggregate
// DTO → response DTO mapping (lives in Presentation layer, not Domain or Application)
let private toResponse (po: PurchaseOrder) : SubmitPurchaseOrderResponse =
// => private: this mapping function is not visible outside the Presentation module
{ PurchaseOrderId = (let (PurchaseOrderId id) = po.Id in id)
// => Unwrap the strongly-typed PurchaseOrderId to a Guid for the response
Status = sprintf "%A" po.Status
// => sprintf "%A": F# pretty-print of the DU case — "Draft", "Submitted", etc.
ApprovalLevel = sprintf "%A" po.ApprovalLevel }
// => toResponse knows both the domain type and the response DTO shape
// => Domain and Application layers never import response DTO types
// Handler factory: returns an HttpHandler with the ports partially applied
let handleSubmit
(repo: PurchaseOrderRepository)
// => Injected at composition root — Npgsql adapter in production, in-memory in tests
(pub: EventPublisher)
// => Outbox publisher in production — stores event row in the same DB as the aggregate
(clock: Clock)
// => Clock port: DateTimeOffset.UtcNow in production, frozen in tests
: HttpHandler =
fun next ctx ->
// => HttpHandler: Giraffe's function type — (HttpFunc -> HttpContext -> Task<HttpContext option>)
task {
// => task { }: C#-compatible async computation — required by Giraffe's middleware chain
let! dto = ctx.BindJsonAsync<SubmitPurchaseOrderRequest>()
// => Giraffe BindJsonAsync: deserializes the request body into the CLIMutable DTO
// Step 1: DTO → domain value objects via smart constructors
match createMoney dto.TotalAmount dto.Currency with
// => createMoney validates amount > 0 and currency is a known ISO 4217 code
| Error msg ->
return! RequestErrors.BAD_REQUEST msg next ctx
// => HTTP 400: domain validation failed — translate at the adapter boundary
| Ok money ->
// => money: validated Money value — smart constructor confirmed amount > 0 and valid currency
let po =
// => Assemble the domain aggregate from all validated value objects
{ Id = PurchaseOrderId (System.Guid.NewGuid())
// => New UUID per request — the client does not supply the PO ID
SupplierId = SupplierId dto.SupplierId
// => Wrap the raw Guid in the strongly-typed DU
TotalAmount = money
Status = Draft
// => Initial status is always Draft — state machine starts here
ApprovalLevel = computeApprovalLevel money
// => computeApprovalLevel: pure domain function — no I/O, no DB call
CreatedAt = clock () }
// => Domain aggregate assembled from validated value objects
// Step 2: aggregate → application service → domain result
match! submitPurchaseOrder repo pub po with
| Error (DuplicatePurchaseOrder id) ->
return! RequestErrors.CONFLICT (sprintf "PurchaseOrder %A already exists" id) next ctx
// => HTTP 409: typed pattern match — no magic status number in handler code
| Error (InvalidPurchaseOrder msg) ->
return! RequestErrors.BAD_REQUEST msg next ctx
// => HTTP 400: domain rule violation surfaced from application service
| Error (RepositoryFailure ex) ->
eprintfn "Repository failure: %A" ex
// => Log the raw exception for diagnostics — never send details to the client
return! ServerErrors.INTERNAL_ERROR "Repository unavailable" next ctx
// => HTTP 500: infrastructure failure — client receives a safe error message
| Ok saved ->
// => Step 3: domain aggregate → response DTO → HTTP 201
return! Successful.CREATED (toResponse saved) next ctx
// => HTTP 201 Created: body is the response DTO serialized to JSON
}Trade-offs: the four-step pipeline (bind → construct → service → respond) adds three translation functions compared to a flat Minimal API handler. For CRUD endpoints that map directly to database rows, the overhead feels disproportionate. The payoff appears when domain invariants are non-trivial: the smart constructor enforces them once, and every downstream component receives only valid aggregates.
Guide 12 — Handler Consuming Generated Contract Types
Why It Matters
The HTTP handler in Guide 11 references hand-authored request and response DTO types. In a production team, those DTO types should be generated from an OpenAPI spec rather than hand-authored — hand-authored DTOs drift from the spec, and drift causes integration failures that the compiler or runtime cannot catch. procurement-platform-be uses a codegen pipeline: an OpenAPI 3.1 spec at specs/apps/procurement-platform/ drives nx run procurement-platform-be:codegen. Each stack generates spec-authoritative types: F# CLIMutable DTO records via a .NET codegen tool, Clojure spec definitions via malli schema generation, TypeScript interfaces via openapi-typescript, and Haskell records via openapi3 codegen. This guide shows how to wire generated contract types into the HTTP handler so it stays in sync with the spec at build time.
Standard Library First
Without codegen, the team writes DTO types by hand and keeps them in sync with the spec manually. Any field rename in the spec produces no compile error — only a silent runtime gap:
// Standard library: hand-authored CLIMutable DTO matching the OpenAPI spec manually
module ProcurementPlatform.Contracts
// Hand-authored request DTO
[<CLIMutable>]
// => CLIMutable: enables reflection-based setters required by Giraffe's BindJsonAsync
type SubmitPurchaseOrderRequest =
{ SupplierId: System.Guid
// => Property name must match the JSON field name exactly — no codegen contract
TotalAmount: decimal
// => Hand-authored: adding a new field here does not update the spec automatically
// => A field present in the spec but absent here produces a silent deserialization gap
Currency: string
Notes: string option }
// => All fields maintained by hand — drift is invisible until runtimeLimitation for production: manual synchronization between spec and DTOs is error-prone at scale. A field rename in the spec produces no compile error — only a runtime JSON deserialization failure.
Production Framework
Each stack conditionally includes generated contract types produced by the Nx codegen target. The F# project uses a conditional <Compile> element:
<!-- ProcurementPlatform.fsproj: conditional include of generated contract types -->
<Compile Include="..\..\generated-contracts\OpenAPI\src\ProcurementPlatform.Contracts\SubmitPurchaseOrderRequest.fs"
Condition="Exists('..\..\generated-contracts\OpenAPI\src\ProcurementPlatform.Contracts\SubmitPurchaseOrderRequest.fs')" />
<!-- => Condition="Exists(...)": the file is gitignored; the build compiles without it if codegen has not run -->
<!-- => Generated from the OpenAPI spec via "nx run procurement-platform-be:codegen" -->
<!-- => Adding a new DTO: add a schema to the OpenAPI spec, re-run codegen, the new .fs file appears -->
<!-- => CLIMutable and property names are generated — no hand-authoring, no drift -->
<Compile Include="..\..\generated-contracts\OpenAPI\src\ProcurementPlatform.Contracts\SubmitPurchaseOrderResponse.fs"
Condition="Exists('..\..\generated-contracts\OpenAPI\src\ProcurementPlatform.Contracts\SubmitPurchaseOrderResponse.fs')" />
<!-- => Same conditional pattern — both request and response types generated atomically -->A handler consuming a generated type looks identical to Guide 11 — the import changes, not the handler logic:
// Handler consuming a generated contract type
module ProcurementPlatform.Contexts.Purchasing.Presentation.PurchasingHandlers
open Giraffe
// => Giraffe: HttpHandler, json combinator
open ProcurementPlatform.Contracts
// => Generated types: SubmitPurchaseOrderRequest, SubmitPurchaseOrderResponse — produced by codegen
let handleHealth : HttpHandler =
// => Handler is a value, not a function — no dependencies to inject
fun next ctx ->
let response : HealthResponse = { Status = "healthy" }
// => HealthResponse: generated from the OpenAPI schema — field names are spec-authoritative
// => Changing the spec field name re-generates the type; this line then fails to compile
// => The compile error is the intended mechanism — it surfaces spec drift at build time
json response next ctx
// => json: Giraffe combinator serializes the generated type and sets Content-Type: application/jsonTrade-offs: codegen introduces a build-time step (nx run procurement-platform-be:codegen) that must run before dotnet build. Teams must run codegen as part of their onboarding script. The payoff: adding a new response field to the OpenAPI spec and running codegen produces a compile error at every handler that constructs the response type without the new field — zero drift, enforced by the compiler.
Guide 13 — Cross-Context Integration via Anti-Corruption Layer
Why It Matters
The receiving context needs summary information about a purchase order when creating a GoodsReceiptNote. A direct import of purchasing's domain types into receiving's domain layer creates coupling: a rename in purchasing breaks receiving silently. The Anti-Corruption Layer (ACL) pattern places an adapter in receiving's infrastructure layer that translates purchasing's types into receiving's own domain types. receiving's domain layer never imports anything from purchasing — regardless of stack. In F# this means receiving never opens purchasing modules; in Clojure, never :requires purchasing domain namespaces; in TypeScript, never imports from purchasing/domain; and in Haskell, module import discipline enforces the same boundary — Receiving.Domain never imports Purchasing.Domain. In procurement-platform-be, the PurchaseOrderIssued domain event is the primary integration channel, but a query path via ACL also exists for cases where receiving needs to fetch PO metadata on demand.
Standard Library First
Without an ACL, receiving opens purchasing domain types directly:
// No ACL: receiving domain opens purchasing domain directly
module ProcurementPlatform.Contexts.Receiving.Domain
open ProcurementPlatform.Contexts.Purchasing.Domain
// => Direct cross-context import — coupling the two domain layers
// => A rename of PurchaseOrder.TotalAmount to PurchaseOrder.Amount in purchasing breaks this module
// => The two contexts cannot evolve their domain models independently
let createGoodsReceiptNote (po: PurchaseOrder) (receivedQty: int) =
// => Takes purchasing's PurchaseOrder type directly — no translation boundary
()Limitation for production: direct domain coupling means that refactoring one context requires simultaneous changes to all consuming contexts. In a large team, this creates merge-conflict pressure and prevents independent deployment.
Production Framework
The ACL adapter lives in receiving's infrastructure layer. It imports the purchasing application-layer query port and translates into receiving's own domain types:
flowchart LR
purdom["purchasing\nDomain/PurchaseOrder"]:::blue
purport["purchasing\nApp/FindPurchaseOrder"]:::orange
acl["receiving\nInfra/PurchasingAcl.fs"]:::teal
recport["receiving\nApplication/POSummaryPort"]:::orange
recdom["receiving\nDomain/PurchaseOrderSummary"]:::purple
purport -->|"queried by"| acl
purdom -->|"translated by"| acl
acl -->|"returns"| recdom
acl -->|"satisfies"| recport
classDef blue fill:#0173B2,color:#fff,stroke:#0173B2
classDef orange fill:#DE8F05,color:#fff,stroke:#DE8F05
classDef teal fill:#029E73,color:#fff,stroke:#029E73
classDef purple fill:#CC78BC,color:#fff,stroke:#CC78BC
// receiving domain: its own type for PO information — no cross-context import
// src/ProcurementPlatform/Contexts/Receiving/Domain/ReceivingTypes.fs
module ProcurementPlatform.Contexts.Receiving.Domain
// receiving's view of a purchase order — independent of purchasing's domain types
type PurchaseOrderSummary =
{ PurchaseOrderId: System.Guid
// => Plain Guid — receiving does not need the PurchaseOrderId DU from purchasing
SupplierId: System.Guid
// => Supplier identifier — receiving needs this to route GRN to the correct supplier
ExpectedTotalAmount: decimal }
// => Expected total — receiving compares against goods actually received
// => PurchaseOrderSummary: the receiving context's own type for PO metadata
// => Adding a field to PurchaseOrder in purchasing does not affect this type// receiving application layer: port for fetching PO summaries
// src/ProcurementPlatform/Contexts/Receiving/Application/Ports.fs
module ProcurementPlatform.Contexts.Receiving.Application.Ports
open ProcurementPlatform.Contexts.Receiving.Domain
// => Only receiving domain types — no purchasing import in application layer
type PurchaseOrderSummaryPort = System.Guid -> Async<Result<PurchaseOrderSummary option, string>>
// => receiving asks for a single PO summary by its Guid (opaque, not strongly typed)
// => The ACL adapter satisfies this port — receiving never knows where the data comes from
// GoodsReceiptRepository port — save and load GRNs
type GoodsReceiptRepository =
{ SaveGoodsReceipt: GoodsReceiptNote -> Async<Result<unit, string>>
// => Persist a GRN — called after goods are verified at the receiving dock
FindGoodsReceipt: GoodsReceiptNoteId -> Async<Result<GoodsReceiptNote option, string>> }
// => Load by identity — used for three-way match lookup in invoicing context// ACL adapter in receiving infrastructure: translates purchasing types
// src/ProcurementPlatform/Contexts/Receiving/Infrastructure/PurchasingAcl.fs
module ProcurementPlatform.Contexts.Receiving.Infrastructure.PurchasingAcl
// => ACL lives in receiving's Infrastructure layer — it is an adapter, not a domain concern
open ProcurementPlatform.Contexts.Purchasing.Application.Ports
// => ACL imports the purchasing APPLICATION port (not the domain) — query model only
open ProcurementPlatform.Contexts.Receiving.Domain
// => receiving domain types for the translation output
open ProcurementPlatform.Contexts.Receiving.Application.Ports
// => Port type alias the ACL must satisfy
// ACL adapter factory
let makePurchasingAcl (findPO: PurchaseOrderRepository) : PurchaseOrderSummaryPort =
// => findPO: the purchasing PurchaseOrderRepository — injected by the composition root
// => Returns a PurchaseOrderSummaryPort function — satisfies receiving's application layer port
fun poId ->
// => Guid input: the receiving context passes a raw Guid; the ACL wraps it in PurchaseOrderId
async {
// => async { }: I/O is required to call findPO.FindPurchaseOrder — must be async
let! result = findPO.FindPurchaseOrder (PurchaseOrderId poId)
// => Wrap the raw Guid in purchasing's PurchaseOrderId DU — the ACL owns this translation
match result with
| Ok (Some po) ->
// => PO found: translate from purchasing's domain type to receiving's summary type
return Ok (Some
{ PurchaseOrderId = poId
// => Pass the Guid through — receiving's PurchaseOrderSummary uses plain Guid
SupplierId = let (SupplierId sid) = po.SupplierId in sid
// => Unwrap SupplierId value object — receiving only needs the Guid
ExpectedTotalAmount = po.TotalAmount.Amount })
// => Map purchasing's Money value object to a decimal — receiving's needs are simpler
| Ok None ->
return Ok None
// => No PO found: return Ok None — not an error; receiving decides how to handle absence
| Error e ->
return Error (sprintf "ACL translation failure: %A" e)
// => Translate the RepositoryError into receiving's error string
// => receiving's application layer never sees purchasing's RepositoryError type
}Trade-offs: the ACL adapter adds a translation step and an additional port. For contexts that share a large read model, the translation code is verbose. Use a shared read model (a separate query module both contexts import from a SharedKernel library) when the translation is purely structural with no semantic difference. Reserve the full ACL for cases where the two contexts genuinely use different ubiquitous language — which is the case for receiving and purchasing in procurement-platform-be.
Guide 14 — Composition Root Program.fs: Wiring All Ports
Why It Matters
The composition root is the single place in the application where adapter implementations are bound to port contracts and injected into application services and handlers. In procurement-platform-be, the composition root is Composition/Program.fs in F#, the system's entry-point namespace in Clojure, composition/program.ts in TypeScript, and Composition/Main.hs in Haskell. Each wires the database adapter (Npgsql/Dapper, next.jdbc, pg, or postgresql-simple), registers the HTTP framework (Giraffe, Ring/Reitit, Hono, or Servant), and sets up the routing table. As new bounded contexts add ports and adapters, every new wire goes into the composition root — nowhere else. This guide shows what that growth looks like using the purchasing, supplier, and receiving contexts.
Standard Library First
Without a DI container or explicit composition, each function creates its own dependencies — the poor man's composition:
// Standard library: inline dependency construction (poor man's DI)
// Each call site constructs its own adapter — no composition root
let handleRequest () =
let connStr = System.Environment.GetEnvironmentVariable("DATABASE_URL")
// => Connection string read at call time — not from a typed config record
let repo = NpgsqlPurchaseOrderRepository.npgsqlPurchaseOrderRepository connStr
// => Adapter constructed inline — connection pool not shared
submitPurchaseOrder repo nullPub po
// => New adapter per call — pool efficiency lostLimitation for production: inline construction creates adapter instances per call, bypassing connection pool sharing. Adding a new adapter requires touching all call sites.
Production Framework
Each language's composition root demonstrates the same correct pattern: adapters are constructed once at startup and injected via partial application or record construction:
// Program.fs: composition root wiring all context ports
// src/ProcurementPlatform/Composition/Program.fs
module ProcurementPlatform.Composition.Program
// => Composition layer: wires all adapters to ports — the only layer that imports both domain and infrastructure
open System
// => System: Environment, DateTimeOffset — used by startup and config helpers
open Microsoft.AspNetCore.Builder
// => WebApplicationBuilder, WebApplication — ASP.NET Core hosting primitives
open Microsoft.Extensions.DependencyInjection
// => AddGiraffe, IServiceCollection — registers services with the DI container
open Giraffe
// => HttpHandler, choose, GET, POST, routef, RequestErrors — routing combinators
type Marker = class end
// => Empty type: used by WebApplicationFactory<Marker> in integration tests
// => Marker gives the test harness an assembly anchor without exposing Program internals
[<EntryPoint>]
// => EntryPoint: .NET convention — exactly one function in the assembly can carry this attribute
let main _ =
// => _ : ignores CLI args — config comes from environment variables and appsettings.json
let builder = WebApplication.CreateBuilder()
// => CreateBuilder: initializes the host with default config sources
builder.Services.AddGiraffe() |> ignore
// => AddGiraffe: registers Giraffe's middleware and JSON serializer
let cfg = builder.Configuration
// => builder.Configuration: reads env vars, appsettings.json, and appsettings.{env}.json
let connStr = cfg.["DATABASE_URL"]
// => DATABASE_URL: injected as env var in Kubernetes (Guide 23) or docker-compose
let clock : Clock = fun () -> DateTimeOffset.UtcNow
// => Clock port: returns current UTC time — frozen in tests (Guide 5 pattern)
// Purchasing context — wires PurchaseOrderRepository and EventPublisher
let poRepo =
NpgsqlPurchaseOrderRepository.npgsqlPurchaseOrderRepository connStr
// => Single PurchaseOrderRepository record shared per process — adapters are stateless
let eventPub =
OutboxEventPublisher.makeOutboxPublisher connStr
// => OutboxEventPublisher: writes event rows to purchasing.outbox_events
// Supplier context — wires SupplierRepository and ApprovalRouter
let supplierRepo =
Supplier.Infrastructure.NpgsqlSupplierRepository.make connStr
// => SupplierRepository record for the supplier context
// => Adapter is stateless: shared per process; no per-request allocation
let approvalRouter =
Supplier.Infrastructure.WorkflowApprovalRouter.make cfg
// => ApprovalRouterPort: routes PO approval requests to the workflow engine
// Receiving context — wires GoodsReceiptRepository and PurchasingAcl
let grnRepo =
Receiving.Infrastructure.NpgsqlGoodsReceiptRepository.make connStr
// => GoodsReceiptRepository for the receiving context
// => GoodsReceiptNote persisted in the receiving context's schema — not the purchasing schema
let purchasingAcl =
Receiving.Infrastructure.PurchasingAcl.makePurchasingAcl poRepo
// => ACL adapter: wires purchasing's PurchaseOrderRepository into receiving's PurchaseOrderSummaryPort
// => The ACL is a read-only adapter — it never writes to purchasing's data
// Routing: bind all context handlers to URL paths
let webApp =
// => webApp: the top-level HttpHandler — Giraffe evaluates handlers in sequence until one matches
choose
// => choose: tries each handler in turn; first match wins
[ GET >=> route "/api/v1/health"
>=> json {| status = "healthy" |}
// => Health check: no port needed — simple inline response
GET >=> route "/api/v1/readiness"
>=> Purchasing.Presentation.ReadinessHandlers.handle poRepo
// => Readiness: checks that the DB is reachable via poRepo
POST >=> route "/api/v1/purchase-orders"
>=> Purchasing.Presentation.PurchasingHandlers.handleSubmit poRepo eventPub clock
// => Submit PO: wires both repository and event publisher
GET >=> routef "/api/v1/purchase-orders/%O"
(Purchasing.Presentation.PurchasingHandlers.handleGet poRepo)
// => Get PO by ID: routef extracts the Guid from the URL path segment
POST >=> routef "/api/v1/purchase-orders/%O/approve"
(Purchasing.Presentation.PurchasingHandlers.handleApprove poRepo eventPub approvalRouter clock)
// => Approve PO: wires repo, publisher, and approval router
POST >=> route "/api/v1/goods-receipts"
>=> Receiving.Presentation.ReceivingHandlers.handleCreateGrn grnRepo purchasingAcl eventPub
// => Create GRN: cross-context — receiving reads from purchasing via ACL
RequestErrors.NOT_FOUND "Not Found" ]
// => NOT_FOUND: catch-all for unmatched routes — returns HTTP 404
// => All routes declared in one place — audit what runs in production here
let app = builder.Build()
// => Build: validates the DI container and creates the WebApplication
app.UseGiraffe webApp
// => UseGiraffe: registers the webApp HttpHandler with ASP.NET Core middleware
app.Run()
// => Run: starts the Kestrel HTTP server; blocks until shutdown signal
// => Blocking call: returns only on SIGTERM, SIGINT, or explicit app.StopAsync()
0
// => Exit code 0: convention for successful process completionTrade-offs: the composition root grows linearly with the number of bounded contexts. For a codebase with ten or more contexts, the single Program.fs approach produces a large file. Mitigate by extracting per-context wiring into a wire function in each context's Infrastructure/ module and calling it from Program.fs. The key invariant is that Program.fs remains the single place where adapter implementations are selected — never split the composition root across multiple files that each bind ports to implementations.
Last updated May 15, 2026