BrandGhost
Build a Type-Safe C# Pipeline From Scratch

Build a Type-Safe C# Pipeline From Scratch

A type-safe C# pipeline lets each processing stage declare an input type and an output type. C# generics let the compiler verify assignment-compatible connections, including the flexibility permitted by generic interface variance. In the implementation below, a parser declared as IStage<OrderText, ParsedOrder> connects only where that stage type is assignable, and the design uses no shared object contract or casts between stages.

This article builds that synchronous pipeline from scratch. It stays intentionally small and dependency-free. There is no async work, cancellation token, dependency injection container, queue, Dataflow block, or performance tuning. The goal is one complete implementation that demonstrates heterogeneous transitions, exact execution order, immutable messages, and a terminal result.

For broader context on how intent separates one software pattern from another, start with The Big List of Design Patterns -- Everything You Need to Know.

The Type Problem a Pipeline Must Solve

Many introductory pipeline examples transform string into string at every step. That makes composition easy, but it avoids the more useful problem. Real processing usually changes representation:

OrderText -> ParsedOrder -> ValidatedOrder -> OrderSummary

The parser understands text. The validator understands parsed values. The summarizer understands an order that already passed validation. Each boundary carries new meaning, not merely a new variable name.

C# generics are designed to preserve compile-time type safety while allowing reusable algorithms and types. A generic stage contract can therefore represent the relationship between its input and output without forcing every value through a common runtime type (Generic types and methods).

The contract we will use is deliberately small:

IStage<TInput, TOutput>

An implementation receives TInput and returns TOutput. Composition then needs one additional type parameter for the value crossing the boundary between two stages:

TInput -> TIntermediate -> TOutput

The first argument must be assignable to the declared IStage<TInput, TIntermediate> type, and the second argument must be assignable to the declared IStage<TIntermediate, TOutput> type. Those requirements preserve the boundary while allowing valid variance conversions.

Complete Type-Safe C# Pipeline Implementation

The following code is one conceptual file. It targets the stable .NET 10 support baseline as of August 2026, uses C# 14, enables nullable reference types, requires no NuGet packages, and contains every type needed for the example.

#nullable enable

using System.Globalization;

namespace DevLeader.TypeSafePipeline;

public interface IStage<in TInput, out TOutput>
{
    TOutput Execute(TInput input);
}

public sealed class Pipeline<TInput, TIntermediate, TOutput>
    : IStage<TInput, TOutput>
{
    private readonly IStage<TInput, TIntermediate> _first;
    private readonly IStage<TIntermediate, TOutput> _second;

    public Pipeline(
        IStage<TInput, TIntermediate> first,
        IStage<TIntermediate, TOutput> second)
    {
        _first = first;
        _second = second;
    }

    public TOutput Execute(TInput input)
    {
        var intermediate = _first.Execute(input);

        return _second.Execute(intermediate);
    }
}

public sealed record OrderText(string Value);

public sealed record ParsedOrder(
    string OrderId,
    string CustomerId,
    decimal Subtotal,
    int ItemCount);

public sealed record ValidatedOrder(
    string OrderId,
    string CustomerId,
    decimal Subtotal,
    int ItemCount);

public sealed record OrderSummary(
    string OrderId,
    string CustomerId,
    decimal Subtotal,
    decimal Tax,
    decimal Total,
    int ItemCount);

public sealed class ParseOrderStage
    : IStage<OrderText, ParsedOrder>
{
    public ParsedOrder Execute(OrderText input)
    {
        ArgumentNullException.ThrowIfNull(input);

        var parts = input.Value.Split(
            '|',
            StringSplitOptions.TrimEntries);

        if (parts.Length != 4)
        {
            throw new FormatException(
                "Expected orderId|customerId|subtotal|itemCount.");
        }

        if (!decimal.TryParse(
                parts[2],
                NumberStyles.Number,
                CultureInfo.InvariantCulture,
                out var subtotal))
        {
            throw new FormatException("Subtotal is not a valid decimal.");
        }

        if (!int.TryParse(
                parts[3],
                NumberStyles.Integer,
                CultureInfo.InvariantCulture,
                out var itemCount))
        {
            throw new FormatException("Item count is not a valid integer.");
        }

        return new ParsedOrder(
            parts[0],
            parts[1],
            subtotal,
            itemCount);
    }
}

public sealed class ValidateOrderStage
    : IStage<ParsedOrder, ValidatedOrder>
{
    public ValidatedOrder Execute(ParsedOrder input)
    {
        ArgumentNullException.ThrowIfNull(input);

        if (string.IsNullOrWhiteSpace(input.OrderId))
        {
            throw new ArgumentException(
                "Order ID is required.",
                nameof(input));
        }

        if (string.IsNullOrWhiteSpace(input.CustomerId))
        {
            throw new ArgumentException(
                "Customer ID is required.",
                nameof(input));
        }

        if (input.Subtotal < 0m)
        {
            throw new ArgumentOutOfRangeException(
                nameof(input),
                "Subtotal cannot be negative.");
        }

        if (input.ItemCount <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(input),
                "Item count must be positive.");
        }

        return new ValidatedOrder(
            input.OrderId,
            input.CustomerId,
            input.Subtotal,
            input.ItemCount);
    }
}

public sealed class SummarizeOrderStage
    : IStage<ValidatedOrder, OrderSummary>
{
    private const decimal TaxRate = 0.13m;

    public OrderSummary Execute(ValidatedOrder input)
    {
        ArgumentNullException.ThrowIfNull(input);

        var tax = decimal.Round(
            input.Subtotal * TaxRate,
            2,
            MidpointRounding.AwayFromZero);

        return new OrderSummary(
            input.OrderId,
            input.CustomerId,
            input.Subtotal,
            tax,
            input.Subtotal + tax,
            input.ItemCount);
    }
}

public sealed class OrderPipelineExample
{
    private readonly IStage<OrderText, OrderSummary> _pipeline;

    public OrderPipelineExample()
    {
        var parseThenValidate =
            new Pipeline<OrderText, ParsedOrder, ValidatedOrder>(
                new ParseOrderStage(),
                new ValidateOrderStage());

        _pipeline =
            new Pipeline<OrderText, ValidatedOrder, OrderSummary>(
                parseThenValidate,
                new SummarizeOrderStage());
    }

    public OrderSummary Run(string orderText)
    {
        return _pipeline.Execute(new OrderText(orderText));
    }
}

The implementation is complete, but the mechanics remain visible. Pipeline<TInput, TIntermediate, TOutput> contains exactly two stages. Its Execute method invokes the first stage, stores the intermediate value, and passes that value to the second stage.

The three-stage example is created by composition. parseThenValidate is itself an IStage<OrderText, ValidatedOrder>. That composed stage can become the first half of another pipeline whose second stage accepts ValidatedOrder and returns OrderSummary.

This nesting is important. The implementation does not need a universal list of stages because a heterogeneous list would erase the relationship between neighboring types. Instead, each composition records one verified transition in its generic arguments.

How the Generic Stage Contract Preserves Types

The core safety comes from the declared generic arguments on IStage<TInput, TOutput>. The interface states the input a stage consumes and the output it produces, while normal assignment compatibility determines which concrete or variant stage implementations can satisfy that declaration. Composition then names the intermediate contract explicitly.

The declaration uses in for the consumed input and out for the returned output. C# supports variance annotations on generic interfaces when type parameters are used in valid input or output positions (Generic types and methods). Variance is useful flexibility, but the neighboring generic types still provide the central composition guarantee.

Consider the first composition:

Pipeline<OrderText, ParsedOrder, ValidatedOrder>

That declaration creates two assignment requirements:

  1. The first argument must be assignable to IStage<OrderText, ParsedOrder>.
  2. The second argument must be assignable to IStage<ParsedOrder, ValidatedOrder>.

Now imagine accidentally reversing the parser and validator:

new Pipeline<OrderText, ParsedOrder, ValidatedOrder>(
    new ValidateOrderStage(),
    new ParseOrderStage())

In the local net10.0 and C# 14 validation harness for this article, that sample fails compilation because ValidateOrderStage is not assignable to IStage<OrderText, ParsedOrder> and ParseOrderStage is not assignable to IStage<ParsedOrder, ValidatedOrder>. The mismatch is detected where the pipeline is assembled rather than after production data reaches the wrong stage.

This is the practical value of a type-safe C# pipeline. The code documents the route and the compiler enforces it.

Why the Pipeline Does Not Use Object Contracts

An IStage<object, object> interface can hold almost anything. That apparent flexibility pushes correctness into casts and runtime checks:

object -> cast -> object -> cast -> object

The compiler can no longer tell whether the next stage expects a parsed order, validated order, or unrelated customer record. A failed cast becomes a runtime problem. An accidentally omitted stage may also remain invisible because every connection has the same nominal type.

Generic types preserve the actual representation at each boundary. Microsoft's generic programming guidance highlights type safety and reduced need for boxing or runtime casts as core advantages of generics (Generic types and methods).

There are legitimate cases for a common envelope, especially at serialization or plug-in boundaries. Even there, the envelope should normally contain a discriminated contract, schema identity, or typed payload access rather than making arbitrary object the normal in-process stage API.

For this implementation, every stage knows exactly what it consumes and produces. That is the intended constraint.

Exact Stage Order Is Part of Composition

The Pipeline class invokes _first.Execute before _second.Execute. There is no event, subscriber collection, or unordered registry involved.

That means order is structural. It is represented by constructor arguments and nested generic types. To move validation before parsing, you would need types that make that transition possible. The current contracts correctly prevent it.

This is different from using a multicast delegate as a result-producing pipeline. C# multicast delegates can hold more than one method, invoke their methods in order, and expose only the return value from the final invocation. Microsoft explicitly notes that a delegate with a return value returns the value from the last method in its invocation list (Using delegates).

That behavior is a poor composition model for heterogeneous result transitions. Earlier return values do not automatically become later inputs, and the invocation list requires one shared delegate signature. This implementation uses ordinary method calls through typed stage interfaces instead. Every intermediate result is captured and passed deliberately.

An individual stage could internally use a single-cast delegate as an implementation detail. The important rule is that the pipeline does not compose result-returning stages with += and a multicast invocation list.

Immutable Messages and Exclusive Ownership

The example uses sealed positional records for message contracts. C# records are data-centric reference types with synthesized value equality, and positional record properties are init-only by default (Records documentation).

The records here contain strings, decimals, and integers, and their positional public properties cannot be reassigned after construction because those properties are init-only by default (Records documentation). Each stage returns a new representation rather than mutating the input object.

That approach has several benefits:

  • A stage cannot silently change data that an earlier stage still references.
  • Each transition shows which values are retained, removed, or introduced.
  • A failed stage does not leave a partially mutated message for later code.
  • Tests can compare complete values without inspecting private mutable state.

Records do not provide deep immutability. If a record contains a List<string>, callers can still mutate that list. Microsoft documents this shallow immutability behavior directly (Records documentation).

For pipeline messages, choose one of two clear policies. Use immutable members, as this example does, or give one stage exclusive ownership of a mutable object and do not share it elsewhere. Avoid a vague middle ground where multiple stages retain and mutate the same collection.

The Terminal Result Is a Real Type

OrderSummary is the sink representation for this example. It is not printed inside the pipeline and it is not hidden in a shared context dictionary. OrderPipelineExample.Run returns it to the caller.

That keeps the terminal boundary flexible. A console application can format the summary. A web endpoint can serialize it. A test can compare it. A persistence adapter can store it. None of those concerns needs to change the three transformation stages.

A pipeline does not always need to return a value. A terminal stage can own a side effect. However, returning a terminal type is a useful default for a synchronous educational implementation because it keeps the processing flow deterministic and easy to inspect.

The stage types also prevent skipping validation accidentally. SummarizeOrderStage accepts ValidatedOrder, not ParsedOrder. A caller cannot hand the parser output directly to the summarizer without writing an explicit conversion or changing the stage contract.

Baseline Failure Propagation

This article does not build a result type or a recovery policy. The stages use normal exceptions for malformed input and invalid values. If a stage throws, Pipeline.Execute does not catch the exception, so the next stage is not invoked and the exception propagates to the caller.

That behavior is intentionally basic. It gives the synchronous pipeline a clear rule without turning this implementation into an error-handling framework. A production design should decide whether expected rejection belongs in a typed result and which failures are exceptional, but that is a separate concern from proving heterogeneous composition.

The important baseline is that failure is not swallowed. There is no default value passed to the next stage and no partially constructed OrderSummary.

Extending the Type-Safe C# Pipeline

Adding a stage means defining a new input-to-output contract and composing it at a compatible boundary.

Suppose a future stage transforms OrderSummary into InvoiceDocument. The existing pipeline already implements IStage<OrderText, OrderSummary>, so it can be composed with IStage<OrderSummary, InvoiceDocument>:

Pipeline<OrderText, OrderSummary, InvoiceDocument>

No existing stage must inherit from a new base class. No central switch statement needs another case. The compiler still verifies the new boundary.

There is a tradeoff. Nested generic types become verbose as the number of stages grows. That verbosity is evidence of the actual type path, but it may reduce readability in very large compositions. A carefully designed builder can hide some nesting while preserving compile-time transitions. A builder that stores stages as object, however, has merely moved type checking out of sight.

For a small synchronous pipeline, explicit nesting is a reasonable place to start. It is easy to debug, contains no reflection, and does not require a framework.

Reading Nested Composition Without Getting Lost

The nested generic declarations are precise, but they can look dense at first. Read each pipeline from left to right as a sentence:

Pipeline<OrderText, ParsedOrder, ValidatedOrder>

That sentence says, "Accept OrderText, cross a ParsedOrder boundary, and return ValidatedOrder." The constructor arguments must supply the two stage contracts that make the sentence true.

Then read the outer composition:

Pipeline<OrderText, ValidatedOrder, OrderSummary>

The first argument already knows how to transform OrderText into ValidatedOrder. The second argument transforms ValidatedOrder into OrderSummary. The outer pipeline does not care that the first argument contains two internal stages. It sees one compatible IStage<OrderText, ValidatedOrder>.

This recursive property is what keeps the implementation small. Composition creates another stage with a new input-output contract. The caller can execute the complete route through IStage<OrderText, OrderSummary> without learning how many internal transitions exist.

Name intermediate compositions after their meaning rather than their mechanics. parseThenValidate communicates more than pipeline1. If a route becomes difficult to name, it may contain too many responsibilities or cross a domain boundary that deserves a separate abstraction.

Common Ways To Lose Type Safety

The first failure mode is storing heterogeneous stages in List<object>. The list looks configurable, but every execution step must rediscover input and output types at runtime. Reflection, casts, or dynamic invocation replaces the generic guarantees.

The second failure mode is a mutable catch-all context. A stage writes a value under "ParsedOrder", another stage reads it, and a typo becomes a runtime defect. A context can carry shared metadata, but it should not replace the primary typed value moving through the route.

The third failure mode is returning the same broad base type from every stage. A base interface can be useful when every representation genuinely shares one stable contract. It becomes harmful when each stage immediately casts that base value back to a concrete subtype.

Finally, avoid a builder that validates compatibility only when Build() or Execute() runs. A fluent API is helpful only if each method carries the current output type into the next stage selection. Hiding the generic nesting is reasonable. Erasing the generic relationship is not.

The practical test is simple: if you connect two incompatible stages, does the compiler reject the composition at that line? If the answer is no, the abstraction is not providing the main benefit promised by a type-safe C# pipeline.

When This Implementation Is the Right Size

Use this type-safe C# pipeline when:

  • The stages are synchronous and run in process.
  • Each item follows one linear, dependent route.
  • Representations change between stages.
  • Compile-time composition is more valuable than runtime reconfiguration.
  • A few explicit stage objects are clearer than a framework.

Do not stretch this implementation into a job scheduler. It has no buffering, backpressure, cancellation, parallel workers, durable state, or lifecycle coordinator. Those omissions are features for the problem it solves.

If the whole operation is only two obvious private method calls inside one cohesive class, even this abstraction may be unnecessary. The Pipeline Pattern is useful when named stages and contracts clarify responsibilities. It is not useful when the types create ceremony around logic that is already direct and readable.

Type-Safe C# Pipeline FAQ

Why not make every stage use the same message type?

You can when every stage genuinely consumes and produces the same representation. Heterogeneous types are better when the meaning changes. ParsedOrder and ValidatedOrder communicate different guarantees even if some properties happen to match.

Why use an interface instead of Func?

A named interface gives the stage a stable semantic contract and lets each implementation have a descriptive type. C# delegates are type-safe method references and can work well for lightweight single stages (Using delegates). The interface avoids using multicast delegate composition for intermediate results and keeps the complete example explicit.

Does the Pipeline class support any number of stages?

It composes two stages at a time. Because the result also implements IStage<TInput, TOutput>, compositions can be nested to form longer pipelines. The example builds three processing stages with two Pipeline instances.

Are C# records deeply immutable?

No. Positional record properties are init-only, but referenced mutable objects can still change. Use immutable members or establish exclusive ownership for mutable values (Records documentation).

What happens when validation fails?

The validation stage throws, composition stops, the summary stage is not called, and the exception reaches the caller. This is a baseline propagation rule, not a complete production failure model.

Can this pipeline run stages in parallel?

Not in this implementation: each shown stage requires the preceding output. Parsing must produce the value that validation consumes, and validation must produce the value that summarization consumes. This implementation is intentionally synchronous and sequential.

What This Implementation Proves

A useful type-safe C# pipeline does not need a package or a complicated builder. It needs honest contracts.

IStage<TInput, TOutput> defines one transition. Pipeline<TInput, TIntermediate, TOutput> composes two compatible transitions. Nested composition builds a longer route without erasing types. Immutable message contracts make state changes visible. The final result leaves through an explicit terminal type.

That is the full synchronous baseline. Keep it until the requirements demand something more.

How To Implement The Pipeline Design Pattern in C#

Learn about the pipeline design pattern in C#. Discover how to create and chain pipeline stages. Get code examples, tips, and use cases for this design pattern.

Pipeline Pattern in C#: A Modern .NET Guide

Learn the pipeline pattern in C#, its core stages, execution models, tradeoffs, and how to choose a stable .NET 10 implementation for production systems.

How C# Source Generators Work: The Roslyn Compilation Pipeline Explained

Learn exactly how C# source generators work inside the Roslyn compilation pipeline. Understand the two-phase compilation model, syntax providers, and incremental execution.

An error has occurred. This application may no longer respond until reloaded. Reload