Skip to content
AyoKoding

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 termHexagonal term
Functional coreDomain core
Imperative shellAdapters
Effect-free functionDomain function
Side-effecting functionAdapter function
Partial application of effectsDependency 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:

  1. 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)
  2. No higher-kinded types (HKT). F# / Haskell can express "any effect monad implementing this port" abstractly; Rust cannot. Rust's async fn in 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)
  3. ? operator is sugar, not monadic bind. Hexagonal application services that chain port calls in F# use Result.bind or asyncResult { }; in Rust they use .await? repeatedly. Same engineering effect, different machinery. (Source: Rust By Example — ? operator)
  4. async / Future is not a monad. F# Async<Result<>> composition uses computation expressions; Rust uses async fn + .await + ? and concrete Future state machines. There is no asyncResult { } equivalent as a generic monadic abstraction. Adapter implementations that need parallel composition use tokio::try_join! or futures::future::try_join_all — ad-hoc combinators, not monadic algebra.
  5. 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.
  6. 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 accept Arc<dyn Repository> or generic R: 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 let bindings, function definitions, modules, discriminated unions, and record types. Result<'a, 'e> and Async<'a> familiarity required; several examples use asyncResult { } from FsToolkit.ErrorHandling.
  • Clojure tab: comfortable with namespaces, maps, protocols, and -> / ->> threading macros. Familiarity with clojure.spec or malli is helpful for the type-grounding examples.
  • TypeScript tab: comfortable with interfaces, generics, async/await, and a basic Result type (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:

  1. Brief Explanation: What hexagonal concept the example demonstrates (2–3 sentences).
  2. Optional Diagram: A Mermaid diagram when concept relationships involve zones, port/adapter boundaries, or flow across layers. Skipped for straightforward type or function definitions.
  3. 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.
  4. Key Takeaway: The single most important principle from this example (1–2 sentences).
  5. 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 purchasing bounded context.
  • Intermediate (Examples 26–55) — Composition root, adapter swapping, integration test seams with stub adapters, dependency rejection, event publishing, the supplier context, 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, and payments, anti-corruption layer at port boundaries, retry adapter wrapping, BankingPort, SupplierNotifierPort, Observability, and a full production reference.

Examples by Level

Beginner (Examples 1–25)

Intermediate (Examples 26–55)

Advanced (Examples 56–75)

Last updated May 14, 2026

Command Palette

Search for a command to run...