Effectful Program
This page describes the Program V3 implementation, inspired by algebraic effects, which provides a flexible and type-safe approach to handling effectful computations in F#.
Implementation Context
This version of Program draws inspiration from algebraic effects implementations in F#. While F# lacks native algebraic effects support, we leverage generics and object-oriented capabilities to achieve similar benefits.
F# Algebraic Effects Libraries
Two notable libraries explore algebraic effects, using generics and object-oriented capabilities of F#:
Nick Palladinos' Eff (2017)
Difficult to use due to lack of documentation
Implementation is challenging to understand
Pioneering work, but not practical for production use
Brian Berns' AlgEff (2020)
π Benefits
Easier to understand and use than the Eff library
Based on the free monad, similarly to our
programV2Comprehensive with numerous programming tips
π Limitations
Overly complex for our needs: not a full algebraic effects library, but an improved
programimplementationRelies on class inheritance, which we prefer to avoid
Uses
andfor type definitions, breaking F#'s conventional top-down declaration order
π This serves as our primary reference.
Program V3 Design Guidelines
Algebraic effects in F# can only be implemented using generics and object-oriented features, but the implementation should balance simplicity with type safety.
Generics Philosophy
Generics can become complex, especially when dealing with constraints and multiple type parameters.
In this implementation, simplicity takes precedence over type safety. We favor clearer, more maintainable code over more complex generic constraints.
Design Principles
Interfaces are essential for abstracting instructions and achieving a truly generic program. We can maintain exhaustiveness checking for supported instructions by downcasting from the generic interface to the implementing union typeβa technique borrowed from the AlgEff library.
Interfaces form the cornerstone of robust object-oriented design. First, interface inheritance is safer than class hierarchies. Second, we apply the Interface Segregation Principle (ISP): favor short, highly cohesive interfaces focused on caller needs over larger, less focused ones. This OO approach closely resembles FP design, since a single-method interface is essentially a named type wrapping a function. For generics, ISP encourages decomposing I<T, U> into I1<T> and I2<U> when appropriate.
Type aliases serve as the second key building block, simplifying instruction definitions with respect to generic type parameters without relying on class inheritance.
Core Components
This version comprises more components than previous versions. Each component is as simple as possible, designed with a single responsibility to enhance understanding. The challenge lies in grasping how all components work together.
Whenever possible, related components are co-located and declared top-down, following F#'s conventional order.
Shopfoo code
The V3 program implementation resides in the Shopfoo.Effects project.
Here's a simplified view of the solution structure:
Program Type: Open to Any Effect
This version of Program is a free monad variant that handles any effect implementing the IProgramEffect<'a> generic interface as a functor:
Key points:
IProgramEffect<'a>: Interface that all effects must implementMapmethod: Makes effects functorial (see functor laws)Program<'ret>cases:Stop: Terminal case containing the returned valueEffect: Single program step containing an effect
Program Computation Expression
The ProgramBuilder class is almost unchanged from V2. Only the bind function needs to call the effect's Map method:
The Program.fs file in the Shopfoo application (source code) contains additional elements to facilitate program composition. These will be explored when analyzing characteristic workflows.
Effect Holding Instructions
To complement IProgramEffect<'a>, we define another interface that links an effect with a set of instructions:
Notes:
'unionis usually a union type, but it's not mandatory.Interpretable because it will be used while interpreting the program.
Instruction Class
Instructions are defined with a single sealed class Instruction replacing the V2 pattern of Instruction of Arg * cont: (Ret -> 'a):
Properties and methods:
Name: Descriptive label useful for logging and debuggingarg: Private argument(s) for this instructioncont: Continuation function that passes results to the next instructionMap: Functormapoperation that chains the continuation with a given functionRun: Invokesrunner: 'arg -> 'retto obtain the result (retvalue) and continues execution
The Shopfoo repository's Instruction also includes a RunAsync method for asynchronous scenariosβsee Prelude.fs#L84.
The signature of Map is not strict: a true map must preserve the type of the functor; only the type parameter (corresponding to the content of the functor) is mapped. This constraint cannot be written in F#, whereas it is possible in C#, except that it would make the code much more cumbersome. We must therefore accept this and be careful when implementing the method in order to comply with the definition of the functor.
Commands and Queries
Commands and queries are instructions but don't inherit from Instruction to avoid:
Class inheritance complexity
Complex 3 type parameters passing
Instead, they're defined through simple type aliases that work as constructors:
Type conventions:
Commands: Return
Result<unit, Error>(no output data, only success/error)Queries: Return
'ret option(whereNonemeans not found)QueryFailable: Return
Result<'ret, Error>(for queries that can fail with errors)
Workflows
Each domain workflow is implemented as a class in its own file. While uncommon in F#, this practice (standard in C#) provides the following benefits:
The file tree reveals the workflows supported by each domain, approaching "Screaming Architecture".
Each workflow class implements a marker interface identifying its domain, enabling the program interpreter (discussed below) to remain domain-specific and infer the list of supported instructions.
Let's analyze the interfaces that support this principle:
The design relies on three interfaces:
IDomain: Nearly a marker interface, requiring implementation of the domainNameproperty primarily for observability. We create a dedicated type for each domain, typically a single-case union liketype MyDomain = MyDomain, implementingIDomain(details follow).IDomainWorkflow<'dom>andIProgramWorkflow<'arg, 'ret>: Both must be implemented by each domain workflow. They're separated to follow the Interface Segregation Principle, as described in the design guidelines.IDomainWorkflow<'dom>: Another marker interface indicating the underlyingDomainIProgramWorkflow<'arg, 'ret>: Introduces theRunmethod that each workflow must implement using theprogramcomputation expression, resulting in the return typeProgram<Result<'ret, Error>>
Note
This design uses a single Error type across the entire solution. To achieve domain-specific error types, consider replacing Result with the Fault Report pattern, an elegant F# design combining FP and OOP principles, described by Paul Blasucci.
Interpreter
The Interpreter is a sealed class with a single instance per domain. While an F# module could have been used, the class-based design provides these advantages:
Better alignment with Safe Clean Architecture and Dependency Injection, particularly when additional dependencies (e.g., loggers) are required
Domain-specific "closure" behavior that wouldn't be achievable with an F# module
Let's review the code:
The Interpreter class provides two categories of methods based on what's being interpreted:
Single instruction methods (
Command,Query,QueryFailable): These interpret individual instructions. Their primary purpose is type differentiation, with names mirroring their corresponding type aliases. They're essentially convenient wrappers that invokeRunAsyncon the given instruction.Workflow method: Interprets an entire workflow from the current domain, implemented in two steps:
A recursive inner function returning the workflow program's result, iterating through instructions one by one and executing the associated asynchronous effect via the
runEffectparameter provided by the domain projectA returned lambda that executes the given
workflowto construct theprogram, which is then interpreted asynchronously using theloopinner function. Any exceptions are caught and wrapped in aResult.Errorcontaining theBugcase of theErrorunion type (see bug helper function)
The Shopfoo repository's Interpreter includes additional observability features omitted here for brevityβsee Interpreter.fs#L23.
Conclusion
The V3 program provides significant improvements over V2:
Type Safety
Effects are strongly typed via interfaces
Commands and queries are distinguished at the type level
Flexibility
Supports any effect implementing
IProgramEffect<'a>Domain-specific effects can be developed in isolation without modifying core code
Simplicity
Single-responsibility components
Type aliases minimize boilerplate
Conventional top-down declaration order
Applying this framework to domain workflow implementation will further clarify any remaining aspects of the V3 program.
Last updated