Why Category Theory? (And Why You Don't Need to Learn It)
You will never write a single line of category theory to use Weltenwanderer. You write .ddd files. The compiler handles the rest.
But if you’ve ever wondered why the compiler can catch things that test suites miss, or why certain error messages are so precise, the answer traces back to six concepts from category theory that the compiler uses under the hood.
This guide explains each concept by starting with something you already know from Event Sourcing, then naming the concept after you already understand the idea. No math symbols. No Greek letters. Just code and emoji.
1. Folding Events is the Whole Game 🍃
The problem
Your ShoppingCart has an event stream:
[CartOpened, ItemAdded, ItemAdded, DiscountApplied]You need to compute the current state. How?
The concrete example
Here are the evolve clauses from the ShoppingCart decider:
evolve(Empty, CartOpened) -> Active { items: [], discountApplied: false }evolve(Active, ItemAdded) -> Active { items: items + item }evolve(Active, DiscountApplied) -> Active { discountApplied: true, total: total - savedAmount }Now walk through the event stream step by step:
| Step | Event | State Before | State After |
|---|---|---|---|
| Start | — | 📭 Empty | 📭 Empty |
| 1 | CartOpened | 📭 Empty | 🛒 Active(items: [], discount: false) |
| 2 | ItemAdded (🧦) | 🛒 Active(items: []) | 🛒 Active(items: [🧦]) |
| 3 | ItemAdded (📚) | 🛒 Active(items: [🧦]) | 🛒 Active(items: [🧦, 📚]) |
| 4 | DiscountApplied | 🛒 Active(discount: false) | 🛒 Active(discount: true) |
This is a left-fold. You start with Empty, and for each event in order, you call the matching evolve to get the next state.
What the compiler does
The key insight: there is exactly one way to fold this stream. Given the same events and the same evolve clauses, you always arrive at the same state. No ambiguity. No choices. No branching.
Because the fold is unique and deterministic, the compiler can reason about all possible event streams — not just the ones in your tests. It doesn’t need to execute the code. It reads the evolve clauses and derives what every fold must produce.
This has a name: Catamorphism + F-Algebra
A unique fold over a structure is called a catamorphism. The pair of (state type + evolve function) is called an F-algebra.
You don’t need to remember these words. What matters:
The compiler can check properties of your
evolveclauses precisely because the fold is unique. Twoevolvedefinitions that produce the same state transitions are provably identical. The compiler knows this without running a single test.
2. Two Folds, One Pass 🍌
The problem
You have a ShoppingCart. You need two things from it:
- Current state — for the decider to make decisions
- Item count over time — for an analytics dashboard
Both read the same event stream. Both fold it. If you write them separately, you process the same events twice.
The concrete example
Fold 1 — current state projection:
evolve(Empty, CartOpened) -> Active { items: [], discountApplied: false }evolve(Active, ItemAdded) -> Active { items: items + item }evolve(Active, ItemRemoved) -> Active { items: items.remove(itemId) }Fold 2 — analytics projection (conceptual):
evolve(0, ItemAdded) -> count + 1evolve(0, ItemRemoved) -> count - 1Naive approach: run both folds sequentially. Process [CartOpened, ItemAdded, ItemAdded, ItemRemoved] once for fold 1, then again for fold 2.
What the compiler does
The compiler recognizes that two independent folds over the same event stream can always be merged into a single fold that produces both results simultaneously. One pass through the event stream. Two outputs.
Before (two passes):
Events → Fold 1 → 🛒 Active StateEvents → Fold 2 → 📊 Item CountAfter (one pass):
Events → Combined Fold → (🛒 Active State, 📊 Item Count)The compiler can only do this if the two folds are truly independent — neither one uses the output of the other as input. The compiler verifies this before merging.
This has a name: Banana-Split Theorem
This optimization is called the banana-split theorem. The name comes from the notation used in the original paper, where the brackets look like 🍌🍌 bananas. (Yes, really.)
What matters: the compiler can automatically detect and merge independent projections. Your analytics projection and your state projection don’t need to be hand-optimized into a single pass. The compiler does it.
3. Checking Guards Without Running Code 🔍
The problem
Your decide(AddItem, Active) has two guards:
decide(AddItem, Active) { require items.length < 50 else reject "Cart is full" require productId not in items.map(.productId) else reject "Product already in cart" -> [ItemAdded { cartId, productId, quantity }]}The compiler needs to answer three questions — without running the code:
- Can both guards be true at the same time? (Yes: cart has 10 items, product is new)
- Can both guards be false at the same time? (Yes: cart has 50 items, product already there)
- Is there any state where the AddItem logic is unreachable? (Should never happen here, but the compiler checks)
The concrete example
Think of guards as filters over the set of all possible Active states.
Imagine you have a basket of produce. Each guard is a rule that accepts some items and rejects others:
| Guard | Accepts | Rejects |
|---|---|---|
| ”is a fruit” | 🍎 🍐 🍊 🍓 | 🥕 🥦 🥔 |
| “is red” | 🍎 🍓 🥕 | 🍐 🍊 🥦 🥔 |
The compiler needs to know:
- Overlap (both guards true): 🍎 🍓 — these exist, so AddItem can proceed
- Gap (neither guard true): 🥦 🥔 — wait, there’s no guard covering these! If your state could be “a vegetable,” you’d have an unchecked path
- Contradiction (guards can never both pass): would mean no item can ever be added
For the ShoppingCart, the compiler maps items.length < 50 and productId not in items.map(.productId) to abstract descriptions of the Active state space. It checks overlaps and gaps without instantiating any actual cart.
What the compiler does
The compiler works with guard expressions as abstract descriptions of state sets — not concrete states. It can:
- Detect contradictory guards (guards that can never both be satisfied → dead code 💀)
- Detect gaps in guard coverage (states where no guard applies → silent failures 🤫)
- Compute which guards can coexist
This works because guards are logical predicates, and predicates compose in well-defined ways. The compiler reasons about the predicates, not about individual states.
This has a name: Galois Connection
The relationship between abstract predicate descriptions and the concrete state sets they describe is called a Galois connection. It’s a precise correspondence: every abstract description maps to exactly one set of concrete states, and vice versa.
What matters: the compiler can reason about your guards symbolically and catch contradictions or gaps at compile time. No test suite needed for this — the compiler proves it.
4. Collecting All Errors, Not Just the First 📋
The problem
You have three mistakes in your .ddd file:
evolve(Active, CartCheckedOut)is missing — evolve totality failure- A guard in
decide(ApplyDiscount, Active)is unsatisfiable — guard contradiction decide(Checkout, CheckedOut)is missing — exhaustiveness failure
With a naive compiler: stop at mistake 1, report it, fix it, run again, stop at mistake 2… Three round-trips. 😤
With a smarter compiler: run all three checks, report all three errors in one pass. Fix everything at once. ✅
But here’s the catch: some checks depend on others. You can’t meaningfully check postconditions if evolve totality already failed — postconditions are derived from evolve, so if evolve is broken, the postcondition check has nothing valid to work with.
The concrete example
Think of it like ordering at a restaurant:
- 🍕 Pizza order and 🍺 beer order are independent — send both to the kitchen at once, they don’t affect each other
- 🍰 Dessert order depends on finishing dinner — you can’t order dessert before the main course arrives
The compiler has two modes:
Independent checks → run in parallel, collect all errors:
Exhaustiveness check ──────┐Evolve totality check ──────┼──► Error list: [err1, err2, err3]Guard consistency ──────┘Dependent checks → run in sequence, stop if prerequisite fails:
Evolve totality check ──► PASS ──► Postcondition check ──► PASS ──► Done ✅Evolve totality check ──► FAIL ──► Postcondition check SKIPPED (no point)What the compiler does
For each check, the compiler decides: “Does this check depend on another check passing first?” If yes, it chains them. If no, it runs them in parallel and merges the error lists.
This means you get every error the compiler can possibly find in one compilation run — but you never get misleading secondary errors caused by a prerequisite that failed.
This has names: Applicative Functor + Monad
The parallel “collect all errors” mode is an applicative functor pattern. The sequential “stop on failure” mode is a monad pattern.
What matters: the compiler uses this distinction to run every independent check simultaneously and give you a complete error report. You fix your
.dddfile once, not three times.
5. Auto-Completing Missing Cases 🧩
The problem
You wrote four decide clauses for your ShoppingCart, but forgot two:
decide(OpenCart, Empty) -> [CartOpened]decide(AddItem, Active) { ... -> [ItemAdded] }decide(Checkout, Active) { ... -> [CartCheckedOut] }decide(ApplyDiscount, Active) { ... -> [DiscountApplied] }
// Missing:// decide(AddItem, Empty) — what happens if you try to add to an unopened cart?// decide(Checkout, Empty) — what happens if you try to check out an empty cart?The compiler reports both missing cases. But it can do something more useful: it can suggest the correct completion.
The concrete example
For decide(AddItem, Empty):
- The command is
AddItem - The state is
Empty(cart not opened yet) - Looking at the pattern: every command applied to a “wrong” state in this decider is a rejection
- The compiler suggests:
decide(AddItem, Empty) { reject "Cart not opened"}This isn’t a guess. The compiler derives it from the structure of your existing clauses. It computes the simplest valid completion — the minimum addition that makes your decider exhaustive.
Similarly, if you have a complete decider and want to extract a general pattern (for refactoring or documentation), the compiler can compute the most general description of what your clauses do across all cases.
What the compiler does
Given a partial decide definition (some command-state pairs covered, some missing), the compiler:
- Identifies the uncovered pairs
- Looks at the covered pairs for structural patterns
- Derives the minimal valid completion that matches the patterns
This is not heuristic. The completion is mathematically the simplest one — any simpler and it wouldn’t be valid; any more complex and it would over-constrain.
This has a name: Kan Extension
Computing the minimal completion from a partial definition is called a left Kan extension. Computing the most general description from a complete definition is a right Kan extension.
What matters: the LSP suggestions for missing cases aren’t guesses — they’re the mathematically simplest valid completions. When the compiler suggests
reject "Cart not opened", it’s not pattern-matching on the word “reject” in your other clauses. It’s deriving the minimal valid entry.
6. Why decide Can Fail — And Why That Matters for Flows ⛓️
The problem
A normal function always produces output: cook(🥚) → 🍳. Given valid input, you always get a result.
But decide can fail:
decide(AddItem, Active) { require items.length < 50 else reject "Cart is full" -> [ItemAdded { cartId, productId, quantity }]}If the cart has 50 items, decide doesn’t return events. It returns an error. The output type is not just “events” — it’s “events OR rejection error”:
decide: Command + State → Result<Events | RejectionError>This matters most when you chain decisions across deciders in a flow. Each step might fail. If step 2 fails, step 3 must never run (no events were produced, so there’s nothing to react to).
The concrete example
Imagine a three-step order fulfilment flow:
Customer submits order → [OrderPlaced] → decide(ReserveInventory, Warehouse) → Result<[InventoryReserved] | "Out of stock 😞"> → decide(ChargeCreditCard, Payments) → Result<[PaymentCharged] | "Card declined 💳"> → decide(ScheduleDelivery, Logistics) → Result<[DeliveryScheduled] | "No slots available 📅">The chain: 📦 reserve → 💳 charge → 🚚 schedule
If ReserveInventory is rejected (“Out of stock”), the chain stops immediately. ChargeCreditCard is never invoked — there’s nothing to charge for. ScheduleDelivery is never invoked — there’s nothing to deliver.
The compiler needs to verify:
- Every step handles failure explicitly (no silent drops)
- The chain terminates (no infinite retry loops hiding in the flow)
- The event types produced by step N match the command types expected by step N+1
What the compiler does
When analyzing flows across deciders, the compiler models each decide as a function that might fail, wrapped in a Result. Chaining two such functions means: “run the first, and only if it succeeds, run the second with the output.”
The compiler checks that every step in the chain either handles failures or explicitly propagates them, and that no chain can loop infinitely.
This has a name: Kleisli Arrow
A function that might fail, where the result is wrapped in Result<Success, Error>, is called a Kleisli arrow. Chaining two Kleisli arrows (where the output of one becomes the input of the next) is called Kleisli composition.
What matters: when the compiler analyzes flows across deciders, it uses this structure to verify that failure is handled at every step and that event type compatibility is checked across the chain. Type safety doesn’t stop at the decider boundary.
Summary
Here’s everything in one place:
| What the compiler does | Why it works | CT concept | Status |
|---|---|---|---|
| Verify state transitions across all possible event streams | The evolve fold is unique — no ambiguity | Catamorphism / F-Algebra | ✅ Implemented |
| Merge independent projections into a single pass | Two independent folds always combine | Banana-Split Theorem | 🔄 Architecture ready |
| Detect contradictory or incomplete guards | Guards map to abstract state sets with precise overlap/gap semantics | Galois Connection | ✅ Implemented (Phase 3) |
| Report all independent errors in one pass | Independent checks compose in parallel; dependent checks chain | Applicative Functor + Monad | 🗓️ Planned (Phase 3+) |
| Suggest minimal valid completions for missing cases | The simplest valid completion is mathematically derivable | Kan Extension | 🗓️ Planned (Phase 5+) |
| Verify failure handling across multi-decider flows | decide is a Kleisli arrow; flows are Kleisli composition | Kleisli Arrow | 🗓️ Planned (Phase 3+) |
Legend: ✅ Implemented — 🔄 Architecture ready — 🗓️ Planned
Architecture Decisions
The category theory concepts discussed above are formalized in these ADRs:
- ADR-005: Evolve as F-algebra with catamorphism fold — Section 1 (Folding Events)
- ADR-006: Guard analysis via Galois connection — Section 3 (Checking Guards)
- ADR-007: Validation error accumulation via applicative functor — Section 4 (Collecting All Errors)
- ADR-014: AST-to-state-machine extraction via free/forgetful adjunction — Extraction soundness
- ADR-015: Abstract interpretation for guard satisfiability — Guard solver choice
Further Reading
- Event Sourcing Primer — If you want to revisit the core
decide → evolveloop before going deeper - Your First Bounded Context — A hands-on walkthrough of the ShoppingCart decider
- Decider Reference — Full syntax for
decide,evolve,require, andensure - Exhaustiveness Check — How the compiler finds missing command-state pairs
- Guard Consistency — How the compiler detects contradictory and incomplete guards
- Formal Foundations — The Allium behavioral specification for category theory mapping and theoretical foundations
For category theory itself, Category Theory for Programmers by Bartosz Milewski is the standard starting point for developers. The book is free online and goes at a programmer’s pace. You don’t need it to use Weltenwanderer — but if this guide left you curious, that’s where to go next.