Overview
Want to build systems where business logic is a pure function that cannot touch a database even if it tries? This tutorial teaches Hexagonal Architecture through a functional programming lens, using four languages — F# (canonical), Clojure, TypeScript, and Haskell. Each example presents all four as parallel tabs; F# carries the deepest annotations and the framing prose, while Clojure, TypeScript, and Haskell are first-class variants. The central observation is that functional programming and hexagonal architecture solve the same problem from different angles: both insist that the domain core is pure and that all effects live at the edges — and this insight holds equally in all four languages.
What This Tutorial Covers
Hexagonal Architecture in functional languages rests on three interlocking ideas that make the structural boundaries impossible to violate accidentally. Each idea is expressed differently across F#, Clojure, TypeScript, and Haskell, but the constraint is identical in all four:
Ports as function contracts — In F#, a port is a record type alias with named function fields. In Clojure, a port is a protocol or a plain map of functions. In TypeScript, a port is an interface or an object type with function properties. In Haskell, a port is a record of functions (the canonical "record-of-functions" idiom for runtime dependency injection). In all four, the compiler (or runtime) enforces substitutability without inheritance.
Adapters as function implementations — An adapter satisfies a port contract: a PostgreSQL adapter and an in-memory test adapter satisfy the same port. Swap adapters by passing different records / maps / objects at startup — no DI container or reflection needed in any of the four languages.
Dependency injection via function application — Application services take their port implementations as parameters. In F#, partial application bakes in the production adapters. In Clojure, higher-order functions or component systems do the same. In TypeScript, constructor injection or closure-based factories achieve the equivalent result. In Haskell, partial application or a Reader monad threading a record of functions through pure code provides the same compositional guarantee.
The Functional Core / Imperative Shell Connection
The functional core / imperative shell pattern and hexagonal architecture are the same insight expressed in different vocabularies:
| Functional term | Hexagonal term |
|---|---|
| Functional core | Domain core |
| Imperative shell | Adapters |
| Effect-free function | Domain function |
| Side-effecting function | Adapter function |
| Partial application of effects | Dependency injection of adapters |
Both demand that the centre is pure. Both push effects to the boundary. Both enable easy testing by substituting the effectful shell.
Rust as an FP-Adjacent Member — With Concept Adjustments
Rust shares deep DNA with the FP languages on this track: enum is a sum type (Blandy & Orendorff, Programming Rust, Ch. 10), traits cover much of the territory typeclasses cover, and Result<T, E> is the Railway-Oriented Programming primitive natively. Hexagonal idioms that translate cleanly — output ports as traits, adapter implementations satisfying those traits, in-memory test adapters, dependency injection via constructor arguments — appear without adjustment. Patterns where ownership is the design force (move-on-call ports, borrowed input adapters) live in the in-procedural-by-example track.
Six concept adjustments when you read this hexagonal-in-FP track through a Rust lens:
- Ownership / affine types — no F# equivalent. Every Rust value is used at most once before it is moved. Port methods that take an aggregate may consume it (
fn save(self, agg: PurchaseOrder)) or borrow it (fn save(&self, agg: &PurchaseOrder)); the choice is a port-design decision with no F# counterpart. Partial application as DI becomes "store the dependency in a struct field" with explicit ownership/borrowing rules. (Source: without.boats — Ownership) - No higher-kinded types (HKT). F# / Haskell can express "any effect monad implementing this port" abstractly; Rust cannot. Rust's
async fnin traits (stabilised in 1.75) closes part of the gap, but kind-polymorphic abstraction over "any port-shaped effect" is not available. (Source: GAT stabilisation — Matsakis & Huey) ?operator is sugar, not monadic bind. Hexagonal application services that chain port calls in F# useResult.bindorasyncResult { }; in Rust they use.await?repeatedly. Same engineering effect, different machinery. (Source: Rust By Example —?operator)async/Futureis not a monad. F#Async<Result<>>composition uses computation expressions; Rust usesasync fn+.await+?and concreteFuturestate machines. There is noasyncResult { }equivalent as a generic monadic abstraction. Adapter implementations that need parallel composition usetokio::try_join!orfutures::future::try_join_all— ad-hoc combinators, not monadic algebra.- No persistent immutable shared structures by default. Composition roots in F# typically wire records-of-functions captured by closures; in Rust, dependencies are held in
Arc<dyn Trait>for shared ownership across threads. The wiring shape transfers; the sharing mechanism changes. - Traits ≈ typeclasses minus HKT (but trait objects fill much of the gap). A port is a
trait Repository { async fn save(&self, po: &PurchaseOrder) -> Result<(), RepoError>; }. Adapters implement this trait. Application services acceptArc<dyn Repository>or genericR: Repository. This is type-level dispatch akin to typeclasses — minus the kind-polymorphism Haskell offers.
Where the FP idiom translates one-to-one, Rust appears as an additional language tab alongside F# / Clojure / TypeScript / Haskell. Where the idiom requires an ownership-driven re-formulation (typestate-encoded port contracts, owned-vs-borrowed adapter handles, Send + Sync bounds at the composition root), the example points to the procedural track instead.
Running Domain
All 75 examples use the same procurement-platform-be — the backend of a Procure-to-Pay (P2P) platform where employees request goods and services, managers approve, suppliers fulfil, and finance pays. The core workflow is:
Employee submits PurchaseOrder draft
→ AwaitingApproval (approval router port routes to manager)
→ Approved (L1/L2/L3 based on PO total)
→ Issued (supplier notifier port sends EDI/email)
→ Received (goods receipt note recorded)
→ Invoiced (three-way match: PO ↔ GRN ↔ Invoice)
→ Paid (banking port disburses funds)
This is the same domain used in the DDD By Example in FP tutorial, and the same domain shown in F#, Clojure, TypeScript, and Haskell tabs throughout both tutorials. The two tutorials complement each other: the DDD tutorial teaches how to model the domain; this tutorial teaches how to isolate it from infrastructure.
Prerequisites
Follow whichever language tab matches the stack you ship. For each language you plan to read:
- F# (canonical tab): comfortable with
letbindings, function definitions, modules, discriminated unions, and record types.Result<'a, 'e>andAsync<'a>familiarity required; several examples useasyncResult { }from FsToolkit.ErrorHandling. - Clojure tab: comfortable with namespaces, maps, protocols, and
->/->>threading macros. Familiarity withclojure.specormalliis helpful for the type-grounding examples. - TypeScript tab: comfortable with interfaces, generics,
async/await, and a basicResulttype (e.g.,neverthrow). Familiarity with Zod for runtime validation is helpful. - DDD FP tutorial helpful but not required: if you have read the DDD By Example in FP tutorial first, you will recognise the domain types and smart constructors used here across all three language tabs.
Structure of Each Example
Every example follows a consistent five-part format:
- Brief Explanation: What hexagonal concept the example demonstrates (2–3 sentences).
- Optional Diagram: A Mermaid diagram when concept relationships involve zones, port/adapter boundaries, or flow across layers. Skipped for straightforward type or function definitions.
- Heavily Annotated Code: Parallel tabs showing F# (canonical), Clojure, and TypeScript. Each tab is a single, self-contained code block. Annotations use
// =>notation to show values, types, zones, and flow at each step, targeting 1.0–2.25 comment lines per code line per tab. - Key Takeaway: The single most important principle from this example (1–2 sentences).
- Why It Matters: Real-world context — why this structural boundary matters in production systems (50–100 words).
Learning Path
- Beginner (Examples 1–25) — The three zones, ports as function types, adapters as function modules, the dependency rule, partial application as DI, in-memory adapters, and the full flow from HTTP to domain to repository — all within the
purchasingbounded context. - Intermediate (Examples 26–55) — Composition root, adapter swapping, integration test seams with stub adapters, dependency rejection, event publishing, the
suppliercontext,ApprovalRouterPort, multi-context wiring, cross-context event flow, conditional adapter selection, and Railway-Oriented Programming across port boundaries. - Advanced (Examples 56–75) — Multi-context wiring across
receiving,invoicing, andpayments, anti-corruption layer at port boundaries, retry adapter wrapping,BankingPort,SupplierNotifierPort,Observability, and a full production reference.
Examples by Level
Beginner (Examples 1–25)
- Example 1: The Hexagon Metaphor — Three Zones as F# Namespaces
- Example 2: Domain Isolation — A Pure Domain Function with No Infrastructure Imports
- Example 3: Input Port as a Function Type Alias
- Example 4: Output Port as a Record Type —
PurchaseOrderRepository - Example 5: The
ClockOutput Port — Injecting Time - Example 6: The Dependency Rule — Direction of Imports
- Example 7: Zone Boundaries as File Organisation
- Example 8: Output Port — The
PurchaseOrderRepositoryRecord Type - Example 9: Output Port — Minimal vs Full Signatures
- Example 10: Output Port — Async vs Sync Signatures
- Example 11: Output Port — Error Type Design
- Example 12: Input Port — Receiving from HTTP vs CLI vs Message Bus
- Example 13: Composing Multiple Output Ports
- Example 14: Port as a Named Record vs Curried Parameters
- Example 15: In-Memory Adapter — Satisfying
PurchaseOrderRepository - Example 16: Primary Adapter — HTTP Handler as a Function
- Example 17: The Composition Root — Wiring Adapters to Ports
- Example 18: Spy Adapter — Verifying Port Calls in Tests
- Example 19: Failing Adapter — Testing Error Paths
- Example 20: Partial Application as Dependency Injection
- Example 21: Domain Function vs Application Service vs Adapter — Three Responsibilities
- Example 22: Testing the Domain Without Infrastructure
- Example 23: Testing the Application Service with In-Memory Adapters
- Example 24: The Anti-Corruption Layer — Translating External DTOs
- Example 25: Full Hexagonal Flow — HTTP to Domain to Repository to Response
Intermediate (Examples 26–55)
- Example 26: Command Port vs Query Port — CQRS at the Port Boundary
- Example 27: Read Model vs Domain Model — Two Separate Output Ports
- Example 28: Async Output Port —
Async<Result<>>Composition - Example 29: Railway-Oriented Programming Across Async Port Calls
- Example 30: Error Union Across Port and Domain Layers
- Example 31: Repository Port as a Record of Functions
- Example 32: SupplierRepository Port — Cross-Context Dependency
- Example 33: EventPublisher Port — Domain Events as Output Port
- Example 34: ApprovalRouterPort — Routing Logic Behind a Port
- Example 35: The Composition Root — Wiring Adapters to Ports
- Example 36: Adapter Swapping for Tests — Same Application Service, Two Adapters
- Example 37: Integration Test Seam with Stub Adapter
- Example 38: Dependency Rejection — The Application Service Refuses Infrastructure
- Example 39: Two Bounded Contexts — Purchasing + Supplier in One Composition Root
- Example 40: Cross-Context Event Flow — SupplierApproved Consumed by Purchasing
- Example 41: Spy Adapter — Recording Port Calls for Test Assertions
- Example 42: Conditional Adapter Selection at the Composition Root
- Example 43: Full Flow — HTTP Request to Domain to Repository to Event Bus
- Example 44: Port Contract Testing — Verifying Every Adapter Satisfies the Same Spec
- Example 45: Approval Router Port — Routing Based on PO Total
- Example 46: Dependency Rejection — Refusing Infrastructure at the Domain Boundary
- Example 47: Port Versioning — Evolving a Port Without Breaking Adapters
- Example 48: Receiving Context —
GoodsReceiptNoteRepository Port - Example 49: Three-Way Match Port — Invoicing Context
- Example 50: Retry Adapter Wrapper — Adding Resilience Without Touching Application Services
- Example 51: Caching Adapter Wrapper — Read-Through Cache at the Port
- Example 52: Audit Log Adapter — Side-Effecting Wrapper
- Example 53: Input Port Multiplexer — Routing One Input to Multiple Handlers
- Example 54: Observability Port — Structured Metrics Without Infrastructure Imports
- Example 55: Composition Root for the Full Purchasing + Receiving Flow
Advanced (Examples 56–75)
- Example 56: Ports for the
receivingContext —GoodsReceiptRepository - Example 57: Ports for the
invoicingContext —InvoiceRepositoryand Three-Way Match - Example 58:
BankingPort— Initiating a Disbursement - Example 59:
SupplierNotifierPort— SMTP and EDI Fallback - Example 60:
ObservabilityPort — Emitting Metrics and Traces - Example 61: Multi-Context Composition Root — Wiring Four Contexts
- Example 62: Retry Adapter — Decorator over
BankingPort - Example 63: Circuit Breaker Adapter — Wrapping
BankingPort - Example 64: Anti-Corruption Layer at the
BankingPortBoundary - Example 65: Port Versioning at the Composition Root
- Example 66: Outbox Pattern at the Adapter Level
- Example 67: Cross-Context Event —
GoodsReceivedTriggers Invoicing - Example 68: Three-Way Match Across Context Ports
- Example 69:
PaymentScheduled— Payments Context ConsumesInvoiceMatched - Example 70: Full Port Suite Spy — Testing the Payments Application Service
- Example 71: Observability-Driven Testing — Asserting Metrics Were Emitted
- Example 72: Contract Test —
BankingPortAdapter Must Honour Domain Errors - Example 73: Property-Based Testing — Domain Invariants Across All Inputs
- Example 74: Adapter Replacement — Swapping
GoodsReceiptRepositoryfrom Postgres to S3 - Example 75: Complete Composition Root Wiring Verified by a Smoke Test
Last updated May 14, 2026