Design Rationale

This section documents the key architectural decisions that shape the Flow API, focusing on the “why” behind the design.

1. The Core Philosophy: Recipe & Chef

The central architectural pattern is a strict separation of concerns:

1a. The Visitor Pattern in Detail

To keep the public IFlow<T> interface lean, the Visitor pattern is implemented using an internal interface:

  1. public interface IFlow<T>: The public-facing interface, which remains a simple, clean marker.
  2. internal interface IVisitableFlow<T>: An internal interface that adds an ExecuteWith(FlowEngine engine) method.
  3. Concrete Flow Types: All internal AST nodes (e.g., SucceededFlow<T>) implement IVisitableFlow<T>.
  4. FlowEngine as the Visitor: The FlowEngine is an instance class with overloaded Execute(...) methods for each concrete flow type.

When FlowEngine.ExecuteAsync is called, it casts the public IFlow<T> to the internal IVisitableFlow<T> and begins the execution. This design provides the static, compile-time safety of the Visitor pattern without polluting the public API, at the cost of a single, controlled cast at the entry point.

1b. The Philosophy: Pragmatism over Purity

While Flow is heavily inspired by functional programming concepts, it is not a strict FP framework.

The primary goal is to provide an intuitive, discoverable, and productive API for the typical C# developer.

This means Flow will always favour a design that is pragmatic and familiar over one that is theoretically pure but abstract.

A good example of this is the approach to concurrency:

The design choice is to provide specific, named solutions to common problems rather than a single, generic tool that requires academic knowledge to use.

This principle of pragmatism guides the entire API design.

(For those interested, a more detailed mapping of Flow concepts to their functional counterparts can be found in Notes for FP Developers.)

2. FlowExecutionOptions: Decoupling Execution from Declaration

All parameters that control a specific execution run (the “how-to-run” instructions) are contained within the FlowExecutionOptions object.

This includes the CancellationToken and the IFlowExecutionObserver.

This approach keeps the FlowEngine.ExecuteAsync signature stable and makes the API extensible, as new execution-time options can be added without breaking changes.

3. The Observer Pattern for Diagnostics

To provide visibility into the engine’s execution, Flow is designed with a decoupled observer pattern rather than baking diagnostics directly in.

It is designed to be implemented by users to bridge Flow events to their chosen diagnostic or logging framework.

This provides the best of both worlds: it’s easy for users to implement (they only override what they need), and it’s extensible for Flow (new methods can be added without breaking existing implementations).

This allows for flexible, type-safe pattern matching in observer implementations while decoupling the observer from the concrete event implementation classes.

4. Extensible Enumerations: The FlowOperationTypes Pattern

To identify the type of operation in a diagnostic event, Flow uses a string property (IOperationEvent.OperationType).

To provide compile-time safety for built-in operations, Flow additionally provides a public static class FlowOperationTypes containing const string definitions.

This is a standard .NET library pattern that provides the safety of an enum for known types and the extensibility of a string for user-defined custom operations.

5. Strongly-Typed IDs

Instead of using primitive string types for flow and operation identifiers, Flow uses dedicated FlowId and OperationId classes.

This is the best practice that:

6. The Failure Model

A Failure<T> in the Outcome<T> model always contains an Exception.

This was a deliberate design choice to align with standard .NET idioms.

“Business-level” failures (e.g., validation errors) should be modeled as a type of Success<T> (e.g., Success<ValidationResult>), while the Failure path is reserved for true, exceptional circumstances that disrupt the normal flow of a computation.

7. Resource Management Patterns

The Flow library provides a single, clear pattern for managing resources.

8. The Behaviour System: AST Rewriting

Operators like .WithRetry() and .WithTimeout() are fundamentally different from simple operators like .Select(). They don’t just transform a value; they alter the execution strategy of a preceding operation. A naive implementation that simply re-executes the entire upstream flow is dangerous, as it can lead to the re-execution of unintended side-effects (e.g., sending an email multiple times).

To solve this problem correctly while keeping the FlowEngine simple, behaviours are implemented using an AST (Abstract Syntax Tree) Rewriting approach.

This design keeps the FlowEngine completely ignorant of complex behaviours, fulfilling its role as a simple, dumb interpreter.

8a. The Pragmatic API for Behaviours: Pure vs. Failable Nodes

A key design question is how execution-altering behaviours should apply to different kinds of operations. Flow takes a pragmatic approach that prioritises developer experience and the Principle of Least Surprise.

This two-pronged approach provides both safety and predictability for the built-in execution modifiers where it matters, and forgiveness where it doesn’t.

9. The Validate Operator and Value-Introspection

9a. Introduction: A New Class of Operator

The introduction of .Validate() marks a new category of operator in the library. While most operators are “State-Reactive,” .Validate() is “Value-Introspective.”

9b. State-Reactive vs. Value-Introspective Operators

9c. Parallels in Functional Programming

This concept is not unique to Flow. In functional libraries like Cats Effect or ZIO, this capability is typically composed from the fundamental flatMap primitive or exposed via methods like .ensure or .filterOrFail. Flow makes a deliberate design choice to elevate this common pattern to a named, first-class operator to improve ergonomics, discoverability, and readability for a broader audience.

9d. Design Deep Dive: The Validate Signature

In addition to the synchronous signature above, Flow also exposes asynchronous and cancellable variants to mirror the rest of the API surface. These variants preserve the exact same outcome semantics; they differ only in how the predicate is evaluated:

IFlow<T> Validate<T>(Func<T, Task<bool>> predicateAsync, Func<T, Exception> exceptionFactory)
IFlow<T> Validate<T>(Func<T, CancellationToken, Task<bool>> predicateCancellableAsync, Func<T, Exception> exceptionFactory)

Use the cancellable overload when you want the predicate’s evaluation to respect the execution token provided to the engine.