githubEdit

diamond-half-strokeFree Monad

The Free Monad pattern improves upon Dependency Interpretation by separating instructions into domain-specific groups, making the codebase more scalable and maintainable. This is version 2 of our program computation expression.

What is a Free Monad?

circle-info

F# does not support a general definition of the free monad, but we can adapt the concept for our Program type.

A free monad adapted for our use case looks like this, where Instruction is a functor (hence its map function):

type Program<'a> =
    | Free of Instruction<Program<'a>>
    | Pure of 'a

module Program =
    let rec bind f = function
        | Free inst -> inst |> Instruction.map (bind f) |> Free
        | Pure x -> f x

Key changes from Version 1:

  • Pure replaces our Stop case (same concept, different name).

  • Free of Instruction gives us a way to separate instructions from the program.

  • We can now group instructions by domain.

Separating Instructions by Domain

Instead of one monolithic Program type with all instructions, we organize code by domain:

Folder Structure

Instruction Types and Map Functions

Each domain defines its own instruction type and map function:

Each instruction type:

  • Is generic over 'a (the continuation return type)

  • Has a corresponding map function that composes the continuation with the mapping function

The Improved Program Type

Now the Program type references domain-specific instruction types:

Benefits:

  • Instructions are organized by domain

  • Adding instructions to one domain doesn't affect others

  • The bind function delegates to domain-specific map functions

  • Better code organization and maintainability

Instruction Helpers

As with our V1 program, we define helpers that return a Program<_> to facilitate calling instructions when writing Domain Workflows:

Working with Domain Errors

Workflows typically return a Program<Result<xxx, Error>> where Error is a discriminated union:

Validation Error Helpers

To call domain type smart constructors within a program CE:

Query Helpers

When a query result is required (not optional), we need to handle the None case:

circle-info

💡 Tips

If your program doesn't compile and the error message isn't clear, try adding type annotations to identify where the unexpected type appears. Once fixed, you can remove unnecessary annotations.

Unit Testing Workflows

Testing a workflow is different from traditional object-oriented unit testing with mocks. We use a custom interpreter parameterized through hooks.

The Hook Types

Test Interpreter

The test interpreter executes the program using hook data instead of real dependencies:

Key points:

  • You only need to handle instructions used in your tests

  • Unused instructions can return failwith "not implemented"

  • Commands always return Ok() (limitation: can't simulate failures)

  • Queries return data from hooks.Data

  • Command calls are recorded in hooks.Calls

Test Helpers

Example Test

Limitations

Despite the improvements, this pattern still has limitations:

circle-exclamation

How can we improve this pattern to achieve complete domain isolation?

Last updated