Conventions

This document describes the conventions for creating builders, binders, and middleware in the Hyperbee Pipeline library. Adhering to these conventions ensures consistency, maintainability, and clarity across the codebase and for all contributors.

Monadic Foundations

The Hyperbee Pipeline is monadic. With TStart fixed, PipelineBuilder<TStart, _> forms a monad over the second type parameter. Each pipeline step is a Kleisli arrow (A -> Task<B>), and Pipe is Kleisli composition.

Monad Operation Pipeline Equivalent Description
return / pure PipelineFactory.Start<TStart>() Wraps the identity function into the pipeline
bind / »= Binder.Bind<TNext>(FunctionAsync<TOutput, TNext>) Composes a new step, producing FunctionAsync<TStart, TNext>

This is analogous to how Reader<R, _> or State<S, _> monads fix their first parameter and operate monadically on the second.

Generic Type Parameter Naming

Pipeline-Level Parameters

Parameter Role Where Used
TStart Invariant initial input type PipelineBuilder<TStart, TOutput>, all binders and builders
TOutput Output type of the current step Builder/binder class parameters
TNext Output type of the next step being composed Bind<TNext>() method parameters
TElement Element type within a collection ForEachBlockBinder, ReduceBlockBinder
TArgument Flexible input type for block operations BlockBinder.ProcessBlockAsync<TArgument, TNext>

TStart in Delegates

The delegate definitions also use TStart as their first type parameter:

public delegate Task<TOutput> FunctionAsync<in TStart, TOutput>(...);

At first glance, this seems inconsistent because these delegates are instantiated with types other than the pipeline’s TStart:

FunctionAsync<TOutput, TNext> next       // step function
FunctionAsync<TElement, TNext> next       // reduce step
Function<TOutput, bool> condition         // condition predicate

This is intentional and correct. From a Kleisli perspective, every function is itself a composable pipeline. When a step function is typed as FunctionAsync<TOutput, TNext>, TOutput is that step’s own “start.” The naming is self-similar: TStart means “the start of this composition” at every level of abstraction – whether “this” is the entire pipeline or a single step.

Type Flow Through Composition

The following diagram shows how generic type parameters flow when composing pipeline steps:

PipelineFactory.Start<string>()        -> IPipelineStartBuilder<string, string>
                                           TStart=string, TOutput=string

.Pipe((ctx, arg) => int.Parse(arg))    -> IPipelineBuilder<string, int>
                                           TStart=string, TOutput=int
                                           step function: FunctionAsync<string, int>
                                                          (step's own TStart=string)

.Pipe((ctx, arg) => arg * 2)           -> IPipelineBuilder<string, int>
                                           TStart=string, TOutput=int
                                           step function: FunctionAsync<int, int>
                                                          (step's own TStart=int)

.Pipe((ctx, arg) => arg.ToString())    -> IPipelineBuilder<string, string>
                                           TStart=string, TOutput=string
                                           step function: FunctionAsync<int, string>
                                                          (step's own TStart=int)

.Build()                               -> FunctionAsync<string, string>

Notice that TStart (the pipeline’s invariant start type) remains string throughout, while TOutput evolves with each step. Each step function has its own TStart which is the previous step’s TOutput.

When to Use TElement vs TArgument

  • TElement: Use when operating on individual elements of a collection that flows through the pipeline. Used by ForEachBlockBinder and ReduceBlockBinder where TOutput is IEnumerable<TElement>.
  • TArgument: Use in BlockBinder.ProcessBlockAsync<TArgument, TNext> when the next function’s input type may differ from TOutput (e.g., in Reduce and ForEach operations where the block processes elements, not the whole collection).

Builder and Binder Patterns

Builder methods are forward-looking: they always build the “next” step.

CurrentBuilder<TStart, TOutput>  ->  NextBuilder<TStart, TNext>
                                     via Binder.Bind<TNext>(FunctionAsync<TOutput, TNext>)
  • Builders and binders should be designed to maximize composability and type safety.
  • Prefer strongly-typed generics over object wherever possible.
  • Use clear, descriptive names for builder and binder classes to indicate their role in the pipeline.
  • Document the expected input and output types in XML comments.

The Binder Hierarchy

Binder<TStart, TOutput>                  Base class: holds Pipeline function, ProcessPipelineAsync
|-- StatementBinder<TStart, TOutput>     Adds middleware support, ProcessStatementAsync
|   |-- PipeStatementBinder              Bind<TNext>: transforms TOutput -> TNext
|   +-- CallStatementBinder              Bind: side-effect, preserves TOutput
+-- BlockBinder<TStart, TOutput>         ProcessBlockAsync<TArgument, TNext>
    |-- PipeBlockBinder                  Bind<TNext>: nested pipeline, TOutput -> TNext
    |-- CallBlockBinder                  Bind: nested pipeline, preserves TOutput
    |-- ForEachBlockBinder<.., TElement> Bind: iterates TElement, preserves TOutput
    |-- ReduceBlockBinder<.., TElement, TNext>  Bind: reduces TElement -> TNext
    |-- WaitAllBlockBinder               Bind<TNext>: parallel execution with reducer
    +-- ConditionalBlockBinder           Adds condition evaluation
        |-- PipeIfBlockBinder            Bind<TNext>: conditional transform
        +-- CallIfBlockBinder            Bind: conditional side-effect

Middleware Conventions

Hook Middleware

Hook middleware is always generic and type-safe. It is inserted at known points in the pipeline where the input and output types are known. Hooks surround individual pipeline steps. Always use generic signatures for hook middleware:

public delegate Task<TOutput> MiddlewareAsync<TStart, TOutput>(
    IPipelineContext context,
    TStart argument,
    FunctionAsync<TStart, TOutput> next);

Hooks must be constrained to the start of the pipeline by extending IPipelineStartBuilder<TStart, TOutput>.

Wrap Middleware

Wrap middleware surrounds a group of pipeline steps. It must be able to wrap any pipeline segment regardless of its input and output types. To enable this, wrap middleware uses object for its input and output types. This is a necessary compromise in C# to allow full compositionality:

MiddlewareAsync<object, object>

When implementing wrap middleware:

  • Use object for input and output types.
  • Document the expected types and perform runtime checks and casts as needed.
  • Only use this pattern for middleware that must be able to wrap arbitrary pipeline segments.

This distinction allows hook middleware to remain type-safe, while enabling wrap middleware to provide maximum flexibility.

Extending the Pipeline

  • When creating custom builders or binders, follow the established naming and type parameter conventions.
  • Register new pipeline steps using extension methods for discoverability.
  • Provide XML documentation and usage examples for all public APIs.
  • Most customizations can be achieved with extension methods. Only create a new binder for fundamentally new control flow or block structures. See Extending Pipelines.

Documentation and Examples

  • All new builders, binders, and middleware should be documented in the docs/ directory.
  • Include code samples and diagrams where appropriate.

For more information, see the middleware documentation and the API reference.


© Stillpoint Software.

Hyperbee Pipeline Docs