Skip to content
AyoKoding

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:

  1. 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)
  2. No higher-kinded types (HKT). Rust cannot abstract over Result or Option without a concrete type argument. There is no generic Functor or Monad trait, 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)
  3. ? operator is sugar, not monadic bind. F# Result.bind is a generic monadic operation; Rust's ? expands to a specific early-return match after From::from(e). Validation pipelines (Result.bind validate >> Result.bind normalize >> Result.bind persist) become validate(raw)?; normalize(input)?; persist(input)? — same Railway-Oriented Programming engineering effect, different conceptual machinery. (Source: Rust By Example — ? operator)
  4. async / Future is not a monad. F#'s async { } is a monadic computation expression; asyncResult { } from FsToolkit composes async and Result monadically. Rust's async fn desugars to a Future state machine sequenced by the compiler, not by monadic bind. Workflows with async repository calls compose via .await plus ?, not via a generic computation expression.
  5. No persistent immutable shared structures by default. F# lists / maps / sets share structure via GC. Aggregates with collection fields (PurchaseOrder with line items) in Rust use Vec<Line> (owned), &[Line] (borrowed slice), or Arc<[Line]> (shared immutable) — each with different ownership consequences. The im crate provides persistent structures when GC-style sharing is required.
  6. Traits ≈ typeclasses minus HKT. Smart-constructor traits, Display, From/Into, Iterator map cleanly. Multi-type-parameter or kind-polymorphic abstractions (Functor for Result, 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:

  1. Brief Explanation: What concept the example demonstrates (2–3 sentences).
  2. Optional Diagram: A Mermaid diagram when concept relationships involve state transitions, pipelines, or bounded-context maps. Skipped for straightforward type or function definitions.
  3. 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.
  4. Key Takeaway: The single most important principle from this example (1–2 sentences).
  5. 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 the PurchaseOrder state 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 receiving and invoicing contexts.

Examples by Level

Beginner (Examples 1–25)

Intermediate (Examples 26–55)

Advanced (Examples 56–80)

Last updated May 8, 2026

Command Palette

Search for a command to run...