Skip to content

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:

StepEventState BeforeState After
Start📭 Empty📭 Empty
1CartOpened📭 Empty🛒 Active(items: [], discount: false)
2ItemAdded (🧦)🛒 Active(items: [])🛒 Active(items: [🧦])
3ItemAdded (📚)🛒 Active(items: [🧦])🛒 Active(items: [🧦, 📚])
4DiscountApplied🛒 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 evolve clauses precisely because the fold is unique. Two evolve definitions 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:

  1. Current state — for the decider to make decisions
  2. 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 + 1
evolve(0, ItemRemoved) -> count - 1

Naive 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 State
Events → Fold 2 → 📊 Item Count

After (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:

  1. Can both guards be true at the same time? (Yes: cart has 10 items, product is new)
  2. Can both guards be false at the same time? (Yes: cart has 50 items, product already there)
  3. 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:

GuardAcceptsRejects
”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:

  1. evolve(Active, CartCheckedOut) is missing — evolve totality failure
  2. A guard in decide(ApplyDiscount, Active) is unsatisfiable — guard contradiction
  3. 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 .ddd file 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:

  1. Identifies the uncovered pairs
  2. Looks at the covered pairs for structural patterns
  3. 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:

  1. Every step handles failure explicitly (no silent drops)
  2. The chain terminates (no infinite retry loops hiding in the flow)
  3. 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 doesWhy it worksCT conceptStatus
Verify state transitions across all possible event streamsThe evolve fold is unique — no ambiguityCatamorphism / F-Algebra✅ Implemented
Merge independent projections into a single passTwo independent folds always combineBanana-Split Theorem🔄 Architecture ready
Detect contradictory or incomplete guardsGuards map to abstract state sets with precise overlap/gap semanticsGalois Connection✅ Implemented (Phase 3)
Report all independent errors in one passIndependent checks compose in parallel; dependent checks chainApplicative Functor + Monad🗓️ Planned (Phase 3+)
Suggest minimal valid completions for missing casesThe simplest valid completion is mathematically derivableKan Extension🗓️ Planned (Phase 5+)
Verify failure handling across multi-decider flowsdecide is a Kleisli arrow; flows are Kleisli compositionKleisli Arrow🗓️ Planned (Phase 3+)

Legend: ✅ Implemented — 🔄 Architecture ready — 🗓️ Planned


Architecture Decisions

The category theory concepts discussed above are formalized in these ADRs:

Further Reading

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.