This series is dedicated to Christian Smith, with gratitude for all the insightful conversations that shaped the ideas in these posts.
Series: Your Clean Architecture Has a Dirty Secret
This is Part 4 of a 6-part series on separating intent from process in real-world C#.
- Your Clean Architecture Has a Dirty Secret
- The Algebra of Intent
- Intent You Can See (and Optimize)
- Two Sides of the Same Coin ← you are here
- Standing on the Shoulders of Giants
- The Strangler Fig
Two Sides of the Same Coin
Over the last two posts, we built two very different solutions to the same problem.
In Post 2, programs are functions. Define an algebra (interface), write a generic program against it, and swap interpreters. Testing, auditing, documentation — all come for free from different interpreters.
In Post 3, programs are data. Define an instruction set (records), build a program as a tree, and walk the tree to interpret, optimize, or transform it. Batching, deduplication, cost estimation, saga compensation — all come from the program being a data structure.
Both describe the same five-step order flow. Both separate intent from process. Both eliminate mock hell. Both give you pluggable interpreters.
How can two such different-looking approaches do the same thing?
Side by Side
| Post 2 (Tagless Final) | Post 3 (Free Monad) | |
|---|---|---|
| Program is… | A generic function IOrderAlgebra<F> → F<T> |
A data structure OrderProgram<T> |
| Operations are… | Interface methods | Record subtypes of OrderStep<T> |
| Interpretation is… | Method dispatch | Pattern matching / recursive fold |
| Adding a new interpreter | Easy — implement the interface | Easy — write a new fold |
| Adding a new operation | Easy — add a method (default impl) | Hard — update OrderStep + all interpreters |
| Inspecting the program | ❌ Opaque | ✅ Walk the data |
| Optimizing before execution | ❌ Already “running” | ✅ Transform the AST |
| Performance | Direct dispatch, no allocation | Allocates AST nodes |
The left column feels like interfaces and DI — because it is. The right column feels like building an AST and interpreting it — because it is.
Same problem. Same five operations. Same interpreters. Two opposite representations.
The Mathematics of Duality
A note before we begin. Posts 1–3 gave you everything you need to use these patterns. The comparison table above and the “when to choose” guide below give you everything you need to decide between them. What follows is the why — the mathematical structure underneath. If you’re not curious about that, skip ahead to The Expression Problem and you’ll lose nothing practical. But if you’ve ever wondered why these patterns feel so symmetrical, why swapping interpreters just works, or why a 1954 theorem gives us confidence that our C# code is correct — read on. This is the payoff.
Everything we’ve built is an instance of a duality that mathematicians have studied since the 1940s. Understanding it gives you a tool for thinking that applies far beyond this series.
Categories — the Language of Structure
A category has three things:
- Objects — things
- Arrows (morphisms) between objects
- Composition — if $f: A \to B$ and $g: B \to C$, then $g \circ f: A \to C$
- Every object has an identity arrow: $\text{id}_A: A \to A$
You already live in one: the category of C# types. Objects are types (int, string, OrderResult). Arrows are functions (Func<int, string>). Composition is function composition (. in F#, chained calls in C#). Identity is x => x.
That’s it. No mystery. A category is just “things with composable arrows.” But this simple definition lets us talk precisely about structure — and structure is what this whole series is about.
Functors — Structure-Preserving Maps
A functor maps one category to another while preserving composition and identity. In C# terms: a generic type constructor with a lawful Select/Map method.
// Task<T> is a functor: if f : A → B, then Task<A>.Select(f) : Task<B>
// IEnumerable<T> is a functor: items.Select(f) maps f over every element
// OrderStep<T> from Post 3 is a functor: it maps over the result type
The effect functor is the heart of this entire story. OrderStep<T> says “I’m a domain operation that produces a T.” The functor structure lets you transform what you’ll do with the result without knowing which operation you’re transforming. That’s exactly the separation of intent from process — at the most fundamental level.
Formally: a functor $F$ maps types to types and functions to functions, preserving identity ($F(\text{id}A) = \text{id}{F(A)}$) and composition ($F(g \circ f) = F(g) \circ F(f)$).
Natural Transformations — Maps Between Functors
A natural transformation $\eta: F \Rightarrow G$ maps one functor to another, transforming $F(A) \to G(A)$ for every type $A$, such that the transformation commutes with mapping:
\[G(f) \circ \eta_A = \eta_B \circ F(f)\]In English: “it doesn’t matter whether you transform then map, or map then transform.”
Our interpreters are natural transformations. The production interpreter transforms OrderStep<T> → Task<T> for every T. The test interpreter transforms OrderStep<T> → Id<T> for every T. The naturality condition says: transforming the result of CheckStock and then mapping is the same as mapping then transforming. That’s exactly the property that makes interpreters composable and interchangeable.
This is why “swap the interpreter” works. It’s not a trick — it’s naturality. The math guarantees it.
F-Algebras and F-Coalgebras
Here’s where the duality becomes formal.
An F-algebra for a functor $F$ is a type $A$ together with a function $\alpha: F(A) \to A$ — a way to collapse one layer of structure into a value. Interpreting a Free Monad is folding with an F-algebra: at each Then node, evaluate the effect and continue.
An F-coalgebra is the dual: a type $A$ together with a function $\beta: A \to F(A)$ — a way to observe a value by producing one layer of structure. Tagless Final programs are coalgebraic: given a program state, each algebra method produces an effect in the target functor.
| Direction | Construction | Our code | Analogy |
|---|---|---|---|
| $F(A) \to A$ | F-algebra | Interpreter: OrderStep<Task<T>> → Task<T> |
Folding a tree |
| $A \to F(A)$ | F-coalgebra | Program: State → OrderStep<State> |
Unfolding a stream |
The initial F-algebra is the most general data representation — it’s the Free Monad. It represents all possible programs as syntax, with nothing added, nothing interpreted.
The final F-coalgebra is the most general observation-based representation — it’s the Tagless Final encoding. It represents programs by all possible ways of observing them, missing nothing.
A deep theorem (Lambek’s Lemma + initiality/finality): for well-behaved functors, the initial algebra and final coalgebra are isomorphic. They contain the same information, expressed differently. One builds up (constructors), the other tears down (observations). They are dual — and equal in expressive power.
This is why Posts 2 and 3 describe the same programs. It’s not a coincidence. It’s a theorem.
The Simplest Example: Natural Numbers
See the duality in the simplest possible setting:
| Initial (F-algebra) | Final (F-coalgebra) | |
|---|---|---|
| Natural numbers | Zero \| Succ(n) — data you build up, then fold |
“Anything supporting +, *, ==” — defined by what you can do with it |
| Your order flow | Done \| Then(CheckStock, k) — AST you interpret |
IOrderAlgebra<F> — defined by its behaviors |
| The insight | “Here’s the structure” | “Here’s what it means” |
Two views. Same number. Same program. Same information. Constructors vs. observations. Building up vs. tearing down.
The Connecting Concept: the Effect
Both approaches have the same three parts:
| Part | Free Monad (Post 3) | Tagless Final (Post 2) |
|---|---|---|
| Return | Done(value) |
alg.Done(result) |
| Bind / sequence | Then(step, continue) |
alg.Then(first, next) |
| The Effect | OrderStep<T> — the DU of operations |
IOrderAlgebra<F> — the interface methods |
The effect is the same conceptual thing — “the domain-specific operations that constitute intent” — represented as data (constructors) in one world and as abstraction (methods) in the other.
Generalizing across this blog’s history:
| Domain | Effect (the “what”) | As data (Free) | As abstraction (Tagless Final) |
|---|---|---|---|
| Stack-safe recursion | Suspend/resume | Suspend(thunk) — Trampoline |
(not used) |
| Error handling | Short-circuit on failure | Error(e) — ErrorChecked |
(not used) |
| Order processing | Business operations | CheckStock, ChargePayment records |
IOrderAlgebra methods |
| Game DSL | Game actions | (not used) | FrogInterpreter record — Frog series |
Every time we’ve separated intent from process on this blog — whether we called it “the Trampoline,” “ErrorChecked,” “Tagless Final,” or “Free Monad” — we’ve been working with the same pattern: define a set of effects, then choose an interpretation strategy.
The Expression Problem
The duality has a practical consequence that computer scientists call the Expression Problem:
| Add a new interpreter | Add a new operation | |
|---|---|---|
| Free Monad | Easy — write a new fold | Hard — change the DU, update all folds |
| Tagless Final | Easy — implement the interface | Easy — add a method with a default impl |
Tagless Final wins on extensibility. Free wins on inspectability. Neither is strictly “better” — they’re dual. The right choice depends on what you need.
Sidebar (F#): The duality is visible in about 5 lines. A discriminated union (initial) and a module signature (final) for the same algebra:
// Initial encoding — data type OrderStep<'t> = | CheckStock of Item list | ChargePayment of PaymentMethod * decimal | ... // Final encoding — abstraction type IOrderAlgebra<'f> = abstract CheckStock : Item list -> 'f abstract ChargePayment : PaymentMethod -> decimal -> 'f ...F# makes the symmetry visible because both discriminated unions and abstract types are first-class language constructs. In C#, interfaces and records serve the same role — they’re just more verbose.
When to Choose Which
Choose Tagless Final when:
- You need many interpreters (test, prod, audit, etc.) and easy extension
- You want zero-cost abstraction — no intermediate data structure, direct dispatch
- Operations evolve frequently — adding
FraudCheckshouldn’t break 5 interpreters - You want interpreter composition — logging + execution, timing + auditing, etc.
- Performance matters — hot paths, tight loops, no GC pressure from AST nodes
- Your team thinks in interfaces and DI (most C# teams)
- You don’t need to inspect or optimize the program before running
Choose Free Monad when:
- You need to inspect, transform, or optimize the program before running
- Batching, deduplication, cost estimation, execution planning
- You need to serialize the program (send it over a wire, store it, replay it)
- You want optimization passes that rewrite the program: coalesce DB calls, parallelize independent steps, minimize LLM invocations
- Stack safety for deep recursion (the Trampoline)
- You want SQL
EXPLAINfor your business logic - The set of operations is stable — you’re not adding new ones every sprint
Choose Both
The most powerful option: write your programs against an algebra (Tagless Final), but have one of your interpreters produce a Free Monad AST that you can then optimize and interpret in a second pass.
// Day-to-day development: Tagless Final
// Clean, extensible, familiar DI
var result = PlaceOrder(new ProductionOrder(services), request);
var testResult = PlaceOrder(new TestOrder(...), request);
// When you need optimization: one interpreter produces an AST
var program = PlaceOrder(new ToFreeMonad(), request); // ← Tagless Final → Free Monad
// Now optimize and interpret the AST
var optimized = BatchPayments(Parallelize(program));
var result = await Run(optimized);
This is the “have your cake and eat it too” approach:
- Tagless Final for day-to-day development: extensibility, ergonomics, zero overhead, interpreter composition
- Free Monad for the optimization pipeline: inspectability, transformation, batching, cost estimation
The ToFreeMonad interpreter is itself a natural transformation — it maps the algebra’s methods to AST constructors. The math guarantees that the resulting AST represents the same program as the one you’d get from writing the Free Monad directly. You’re not losing information; you’re changing representation.
This works because the two encodings are isomorphic. The theorem we met earlier — initial algebra ≅ final coalgebra — isn’t just theoretical elegance. It’s the reason you can freely convert between representations without losing meaning.
Two seemingly different techniques — interfaces vs data structures — solving the same problem from opposite ends. That’s not a coincidence. It reflects deep mathematical structure that’s been studied for decades.
In the final post, we’ll pull back the curtain all the way: monads, free constructions, the Yoneda lemma, and algebraic effects. We’ll see why everything we’ve built actually works, connect it to the entire history of this blog, and look at where the field is going.
Sidebar (Haskell): The duality is strikingly visible in Haskell. A type class (final) and a GADT (initial) for the same algebra:
-- Final encoding — the program IS its interpretations class Monad m => OrderAlgebra m where checkStock :: [Item] -> m StockResult chargePayment :: PaymentMethod -> Double -> m ChargeResult -- ... (type class methods) -- Initial encoding — the program IS its syntax tree data OrderStepF next where CheckStockF :: [Item] -> (StockResult -> next) -> OrderStepF next ChargePaymentF :: PaymentMethod -> Double -> (ChargeResult -> next) -> OrderStepF next -- ... (GADT constructors) type OrderProgram = Free OrderStepFBoth are first-class language constructs. Both produce the same
placeOrderwith do-notation. The symmetry is immediate — no squinting required. See the Haskell companion code where both encodings coexist.
Companion code: Both encodings — Tagless Final and Free Monad — are fully implemented with tests in the companion repository. Available in C# (45 tests), F# (27 tests), and Haskell (29 tests).
Next: Standing on the Shoulders of Giants — the foundations: monads, free constructions, Yoneda, algebraic effects, and why a half-century of mathematics gives us confidence that our C# code is correct.
Not interested in category theory? Skip straight to The Strangler Fig — the Monday morning migration plan for getting your legacy codebase from here to there, one service at a time.
This is Part 4 of the series “Your Clean Architecture Has a Dirty Secret.” The full series explores separating intent from process using techniques from functional programming — Tagless Final, Free Monads, and the mathematical foundations that make them trustworthy.