Overview
Want to model complex business domains so that illegal states are literally unrepresentable at compile time? This tutorial teaches Domain-Driven Design through a functional programming lens, using four languages — F# (canonical), Clojure, TypeScript, and Haskell — and the backend of a Procure-to-Pay (P2P) procurement platform as the running domain. Each example presents all four languages as parallel tabs; F# carries the deepest annotations and the framing prose, while Clojure, TypeScript, and Haskell are first-class variants that show the same domain patterns in their respective idioms.
What This Tutorial Covers
This tutorial explores three interlocking ideas that make functional programming an unusually powerful DDD tool. Each idea is shown in F# (canonical), Clojure, TypeScript, and Haskell:
Type-driven design — F# discriminated unions and record types, Clojure spec-validated maps, TypeScript union types + Zod schemas, and Haskell ADTs with smart constructors all let you encode business rules at the type/validation boundary. An UnvalidatedRequisition and a ValidatedRequisition are different types (or shapes); the language prevents you from accidentally treating one as the other. The domain model documents itself regardless of which language you use.
Railway-Oriented Programming (ROP) — Error handling becomes a first-class design concern. F# uses Result<'a, 'e> with Result.bind; Clojure uses either monads or threading macros with tagged maps; TypeScript uses a Result type or neverthrow; Haskell uses Either e a with monadic >>= and do-notation. Multiple fallible steps compose cleanly into pipelines in all four.
Workflow pipelines — Business workflows are modelled as plain functions. Dependencies are injected via partial application (F#), higher-order functions (Clojure), constructor injection (TypeScript), or partial application and records-of-functions (Haskell). Effects are pushed to the edges, and the domain core stays pure and easily testable in all four languages.
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 (the Rust Reference explicitly calls it "analogous to a data constructor declaration in Haskell" — Blandy & Orendorff, Programming Rust, Ch. 10), match is exhaustive pattern matching, traits cover much of the territory typeclasses cover, and Result<T, E> is the Railway-Oriented Programming primitive built into stdlib. DDD patterns that translate cleanly — value objects as records with smart constructors, aggregates as state-machine ADTs, domain events as enum variants, workflows as fn(input) -> Result<output, Error> — appear without adjustment. Patterns where ownership is the modelling force (aggregate move semantics, repository owned vs borrowed handles) live in the in-procedural-by-example track.
Six concept adjustments when you read this DDD-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. Workflow steps that produce a new aggregate state typically consume the old state (
fn submit(self) -> Result<Submitted, Error>) rather than copy it. The pure-transition framing this track teaches (State -> Event -> State) is preserved, but it is enforced at the ownership layer rather than only at the type layer. (Source: without.boats — Ownership) - No higher-kinded types (HKT). Rust cannot abstract over
ResultorOptionwithout a concrete type argument. There is no genericFunctororMonadtrait, so domain-level "lift this validation into the workflow effect" patterns do not generalise as they do in Haskell or F#. GATs (Rust 1.65) approach but do not reach HKT. (Source: GAT stabilisation — Matsakis & Huey) ?operator is sugar, not monadic bind. F#Result.bindis a generic monadic operation; Rust's?expands to a specific early-returnmatchafterFrom::from(e). Validation pipelines (Result.bind validate >> Result.bind normalize >> Result.bind persist) becomevalidate(raw)?; normalize(input)?; persist(input)?— same Railway-Oriented Programming engineering effect, different conceptual machinery. (Source: Rust By Example —?operator)async/Futureis not a monad. F#'sasync { }is a monadic computation expression;asyncResult { }from FsToolkit composes async and Result monadically. Rust'sasync fndesugars to aFuturestate machine sequenced by the compiler, not by monadic bind. Workflows with async repository calls compose via.awaitplus?, not via a generic computation expression.- No persistent immutable shared structures by default. F# lists / maps / sets share structure via GC. Aggregates with collection fields (
PurchaseOrderwith line items) in Rust useVec<Line>(owned),&[Line](borrowed slice), orArc<[Line]>(shared immutable) — each with different ownership consequences. Theimcrate provides persistent structures when GC-style sharing is required. - Traits ≈ typeclasses minus HKT. Smart-constructor traits,
Display,From/Into,Iteratormap cleanly. Multi-type-parameter or kind-polymorphic abstractions (Functor forResult, Monad for any effect) do not. Repository-as-record-of-functions becomes Repository-as-trait-object (Box<dyn Repository>), with the type-level dispatch but without HKT-style abstraction.
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 aggregates, move-on-transition lifecycles, borrowed-vs-owned repository contracts), the example points to the procedural track instead.
Running Domain
All 80 examples use the same Procure-to-Pay (P2P) procurement platform — the backend service (procurement-platform-be) that employees use to request goods and services, managers use to approve them, suppliers use to fulfill them, and finance uses to reconcile and pay. The core workflow is:
UnvalidatedRequisition → SubmitRequisition → RequisitionSubmitted
→ ApprovePO → PurchaseOrderIssued
→ ReceiveGoods → GoodsReceived
→ MatchInvoice → InvoiceMatched
Using a single running domain across all examples lets you see how individual pieces — a Money value object, a Result.bind chain, a repository modelled as a function type — fit together into a coherent procurement system.
Structure of Each Example
Every example follows a consistent five-part format:
- Brief Explanation: What concept the example demonstrates (2–3 sentences).
- Optional Diagram: A Mermaid diagram when concept relationships involve state transitions, pipelines, or bounded-context maps. Skipped for straightforward type or function definitions.
- Heavily Annotated Code: Parallel tabs showing F# (canonical), Clojure, TypeScript, and Haskell. Each tab is a single, self-contained code block. Annotations use
// =>notation (or;;in Clojure,-- =>in Haskell) to show values, types, states, and effects 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 pattern matters in production systems and how it connects to type-driven DDD (50–100 words).
Learning Path
- Beginner (Examples 1–25) — Types as the design. Covers ubiquitous language, bounded contexts, record and union types, smart constructors, and the full set of value types used by the purchasing context (
PurchaseRequisition,Money,SkuCode,Quantity,RequisitionId). - Intermediate (Examples 26–55) — Pipelines, Railway-Oriented Programming, effects, and dependency injection. Covers function composition,
Result,Async, validation accumulation, workflow signatures, domain events (PurchaseOrderIssued,RequisitionApproved), and thePurchaseOrderstate machine. - Advanced (Examples 56–80) — Persistence, serialization, CQRS, cross-context Anti-Corruption Layers, factory functions, repository as function-type alias, dependency rejection, and testing strategies across the
receivingandinvoicingcontexts.
Examples by Level
Beginner (Examples 1–25)
- Example 1: Ubiquitous Language as F# Type Aliases
- Example 2: Domain Event Named in Past Tense
- Example 3: Bounded Context as F# Module
- Example 4: AND Type — Record
- Example 5: OR Type — Discriminated Union
- Example 6: Workflow Expressed as a Function Type
- Example 7: Single-Case Discriminated Union Wrapper
- Example 8: Smart Constructor Returning Result
- Example 9: Pattern Matching on a Discriminated Union
- Example 10: Exhaustive Match — Compiler-Enforced
- Example 11: Option Type Replacing Null
- Example 12: Constrained String — SkuCode
- Example 13: Quantity as a Smart-Constructed Value Object
- Example 14: Money Record with Currency
- Example 15: Lifecycle States as a Discriminated Union
- Example 16: State Machine Encoded Purely by Type Transitions
- Example 17: Domain Primitive Wrapping Decimal — Unit Price
- Example 18: Units of Measure
- Example 19: Email Value via Regex Validation
- Example 20: ProductCode as a Union of Two Subtypes
- Example 21: PurchaseRequisitionLine Record — Composing Value Objects
- Example 22: PurchaseRequisition Aggregate Record
- Example 23: UnvalidatedRequisition DTO-Shaped Record
- Example 24: Approval Level Derived from Requisition Total
- Example 25: Workflow Type Alias — Full SubmitRequisition Signature
Intermediate (Examples 26–55)
- Example 26: Function Composition with >>
- Example 27: Pipe Operator |>
- Example 28: Currying — Every F# Function is One-Arg
- Example 29: Workflow Expressed as Function Composition
- Example 30: Result Type — Ok and Error
- Example 31: Result.bind — Chaining Fallible Steps
- Example 32: Result.map — Transforming the Success Value
- Example 33: Validation Accumulation with List of Errors
- Example 34: Computation Expression for Result
- Example 35: Async Result — Effects at the Edges
- Example 36: Domain Error DU — Every Failure Mode Named
- Example 37: PurchaseOrder Aggregate — Full State Machine
- Example 38: Domain Events from State Transitions
- Example 39: Supplier Aggregate — Lifecycle States
- Example 40: Aggregate Boundary — What Goes Inside
- Example 41: Refactor Primitive Obsession — Typed Wrapper
- Example 42: ValidatedPurchaseOrder Type — Emitted by Validation Step
- Example 43: IssuedPurchaseOrder Type — Emitted by Issue Step
- Example 44: ApprovePO Workflow Signature with Dependencies
- Example 45: IssuePO Workflow Signature with Dependencies
- Example 46: AcknowledgePO Workflow Signature
- Example 47: Pipeline Composition — Wiring Three Workflow Steps
- Example 48: Domain Error DU — Every Purchasing Failure Named
- Example 49: Mapping Domain Error to API Error at the Boundary
- Example 50: Pushing Effects to the Edges
- Example 51: Pure Core Wrapping at the Edge
- Example 52: Dependency Injection via Partial Application
- Example 53: Persistence Interface as a Record of Functions
- Example 54: Approval Level Enforcement — Invariant in the Domain
- Example 55: Cancellation Workflow — Off-Ramp from Any Pre-Paid State
Advanced (Examples 56–80)
- Example 56: Serialization — JSON via DTO Boundary
- Example 57: Date/Time as a Domain Concept
- Example 58: GoodsReceiptNote Aggregate — Receiving Context
- Example 59: Invoice Aggregate — Three-Way Matching
- Example 60: EventStore vs Repository — Trade-offs
- Example 61: Bounded Context Boundary as Module + Signature
- Example 62: ACL as a Translation Function Between Contexts
- Example 63: Published Language — DU of Public Events
- Example 64: Factory Function for PurchaseOrder
- Example 65: Repository as Function-Type Alias
- Example 66: Dependency Rejection — No Optional Dependencies
- Example 67: Cross-Context Consistency — Eventual vs Strong
- Example 68: Property-Based Test for an Invariant — FsCheck
- Example 69: Compile-Time vs Runtime Check — Comparison
- Example 70: Workflow Testing Without Mocks
- Example 71: Evolution Scenario 1 — Adding a Supplier Preferred Currency
- Example 72: Evolution Scenario 2 — Adding a Three-Way Match Tolerance Override
- Example 73: Evolution Scenario 3 — Murabaha Finance Context (Optional)
- Example 74: Bounded Context Integration Map
- Example 75: Long-Running Workflow — Approval Saga
- Example 76: Interop with C# Caller — Workflow Exposed as Task
- Example 77: CQRS — Separate Read and Write Models
- Example 78: Invoice Payment Workflow — Full Pipeline
- Example 79: Domain Model Evolution — Adding a New State
- Example 80: Full System Sketch — Procurement Platform End-to-End
Last updated May 8, 2026