BrandGhost
Register Pipeline Stages With .NET Dependency Injection

Register Pipeline Stages With .NET Dependency Injection

Registering .NET dependency injection pipeline stages looks easy until order and lifetime become part of correctness. A pipeline runner needs every stage, in the intended sequence, with dependencies that remain valid for exactly one pipeline execution. Microsoft DI can provide that composition cleanly, but only when the composition root owns ordering and the host owns scope creation.

This article focuses on that boundary. The pipeline itself still contains normal business logic. It does not ask IServiceProvider for dependencies, discover handlers dynamically, or hide execution behind a service locator. Instead, one registration method declares the stages, Microsoft DI supplies IEnumerable<T> in registration order, and the caller creates the right scope for the work being performed. This collection model applies only to homogeneous stage contracts such as IOrderStage; heterogeneous transitions require concrete typed stages composed explicitly in a typed runner or factory rather than erased into one collection.

Register .NET Dependency Injection Pipeline Stages in One Place

Microsoft's current service registration documentation establishes two behaviors that matter here:

That first rule gives a pipeline a simple composition model. Register the stages in execution order, inject the collection, and execute that collection in the same order. The second rule is the trap. A runner that injects one IOrderStage does not receive the first stage or an automatically composed pipeline. It receives one implementation -- specifically, the final registration.

Centralizing the registrations makes the sequence visible during code review. It also avoids an order that emerges accidentally from several modules calling AddScoped<IOrderStage, ...>() at unrelated points during startup.

The validation project targets the stable .NET 10 support baseline as of August 2026 with C# 14 and nullable reference types enabled. For the console validation, it used this exact project file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
</Project>

Microsoft's shared-framework targeting guidance requires Microsoft.NET.Sdk projects to add that FrameworkReference when consuming APIs from Microsoft.AspNetCore.App. In this validation setup, the Microsoft.Extensions.DependencyInjection and Microsoft.Extensions.Hosting assemblies came from that ASP.NET Core shared framework, which is additional to the base Microsoft.NETCore.App framework, rather than from external NuGet package references.

The homogeneous stage example is:

using Microsoft.Extensions.DependencyInjection;

namespace PipelineDiExample;

public sealed record OrderLine(
    string Sku,
    int Quantity,
    decimal UnitPrice);

public sealed record OrderWorkItem(
    string CustomerId,
    IReadOnlyList<OrderLine> Lines,
    decimal Total);

public interface IOrderStage
{
    Task<OrderWorkItem> ExecuteAsync(
        OrderWorkItem input,
        CancellationToken cancellationToken);
}

public sealed class NormalizeCustomerStage : IOrderStage
{
    public Task<OrderWorkItem> ExecuteAsync(
        OrderWorkItem input,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        OrderWorkItem output = input with
        {
            CustomerId = input.CustomerId.Trim().ToUpperInvariant()
        };

        return Task.FromResult(output);
    }
}

public sealed class CalculateTotalStage : IOrderStage
{
    public Task<OrderWorkItem> ExecuteAsync(
        OrderWorkItem input,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        decimal total = input.Lines.Sum(
            line => line.Quantity * line.UnitPrice);

        return Task.FromResult(input with { Total = total });
    }
}

public sealed class OrderPipeline
{
    private readonly IReadOnlyList<IOrderStage> _stages;

    public OrderPipeline(IEnumerable<IOrderStage> stages)
    {
        _stages = stages.ToArray();
    }

    public async Task<OrderWorkItem> ExecuteAsync(
        OrderWorkItem input,
        CancellationToken cancellationToken)
    {
        OrderWorkItem current = input;

        foreach (IOrderStage stage in _stages)
        {
            current = await stage.ExecuteAsync(
                current,
                cancellationToken);
        }

        return current;
    }
}

public static class OrderPipelineServiceCollectionExtensions
{
    public static IServiceCollection AddOrderPipeline(
        this IServiceCollection services)
    {
        services.AddScoped<IOrderStage, NormalizeCustomerStage>();
        services.AddScoped<IOrderStage, CalculateTotalStage>();
        services.AddScoped<OrderPipeline>();

        return services;
    }
}

The runner receives every IOrderStage, materializes the sequence once, and iterates it. The stages do not know about the container. They receive their dependencies through constructors, and their execution contract remains independently testable. If each stage changes the message type, inject the concrete typed stages into a typed runner or factory instead; do not erase heterogeneous transitions into IEnumerable<IOrderStage>.

If you want a broader introduction to these registration APIs before applying them to a pipeline, my IServiceCollection guide covers the basic AddTransient, AddScoped, and AddSingleton choices. Here, the important point is not the method name. It is whether the chosen lifetime matches the pipeline's execution boundary.

Resolve the Collection, Not One Stage

An ordered stage collection is a configuration contract. Treat it like one.

Injecting IEnumerable<IOrderStage> requests all registrations for that service contract, while injecting IOrderStage uses the last registration under the documented Microsoft DI resolution behavior. Adding a stage later can therefore change single-service resolution without changing the runner's source code. That is subtle and avoidable.

There are two reasonable ordering approaches:

  1. Registration order is the pipeline order.
  2. Stages carry explicit metadata and the composition root sorts them.

Registration order is the clearer default for a fixed application pipeline. Explicit numeric priorities can help a plugin system, but they introduce duplicate priorities, missing priorities, and sorting rules that now require their own validation. Do not add dynamic ordering merely to make a static list look flexible.

One extension method also gives you a natural review point. A reviewer can see that normalization precedes calculation in AddOrderPipeline. Moving either registration changes behavior and produces an obvious diff. That is much safer than allowing feature assemblies to register stages whenever they happen to initialize.

Choose Stage Lifetimes From Their State and Dependencies

The official .NET service lifetime guidance defines transient, scoped, and singleton behavior. Pipeline stages add one extra question: when does the runner resolve and retain the collection?

Transient stages

A transient stage is created each time the container resolves it, as defined by the .NET service lifetime guidance. This works well for lightweight, stateless stages. However, if a scoped OrderPipeline materializes its transient stages in the constructor, those particular transient objects remain with that pipeline instance for the rest of the scope. They are not recreated for every call to ExecuteAsync.

That behavior is often exactly what you want. It is still worth stating because "transient" does not mean "new object for every method call." It means "new object for every container resolution."

Transient stages can be a poor fit when construction is expensive or when they own disposable resources that should be shared for one work item. In those cases, scoped dependencies usually express ownership more clearly.

Scoped stages

A scoped stage is created once within a scope, as defined by the .NET service lifetime guidance. In ASP.NET Core, the request supplies an implicit scope. In a console application or worker, you usually create an explicit one. Scoped stages are a natural choice when the work item uses a scoped unit of work, database context, or another dependency that must be shared consistently across the stages for that execution.

The pipeline runner should normally be scoped as well. A scoped runner can safely receive scoped or transient stages. The scope then becomes the lifetime envelope for the complete execution.

My article on using IServiceCollection in console applications provides useful setup context for non-web hosts. The pipeline-specific rule is to create the scope around the operation, not once around the entire long-running process.

Singleton stages

A singleton stage is shared until the service provider shuts down and must be thread-safe, as defined by the .NET service lifetime guidance. For a pipeline stage, that includes every mutable field, cache, client wrapper, and collaborator reachable from the singleton.

A singleton can be appropriate for a genuinely stateless transformation whose complete dependency graph is singleton-safe. It is not a free optimization. Saving a few allocations is not evidence that a stage should share state across all requests and background items.

Be especially suspicious of mutable "current item" fields, reusable buffers, non-thread-safe collections, and dependencies that were designed for a scope. If any invocation-specific value is stored on the singleton, concurrent callers can observe or overwrite each other's state.

Avoid Captive Pipeline Dependencies

A captive dependency appears when a longer-lived service retains a shorter-lived one, violating the .NET service lifetime guidance. The classic mistake is registering stages as scoped while registering the constructor-injected OrderPipeline runner as singleton. OrderPipeline materializes its IEnumerable<IOrderStage> in the constructor. The singleton runner therefore retains those resolved stages. A scoped database context somewhere below either stage is now retained with them. Changing the field from an array to IEnumerable<IOrderStage> does not fix the dependency graph. The collection was still resolved for the singleton.

Development scope validation can detect direct lifetime violations, but code should not rely on validation as the design. Keep the runner no longer-lived than the stages it consumes.

The same rule applies to factories that close over resolved stages. A singleton delegate returning an already resolved scoped stage is still a captive dependency with different syntax.

Materialized Collections Are a Lifetime Decision

Materializing the stages with ToArray() is useful. It prevents repeated enumeration and freezes the sequence for one runner instance. It also makes lifetime behavior explicit: the runner retains every stage in that array.

That is safe when the runner is scoped and the stages are scoped or transient. It is risky when the runner is singleton, because the array extends every stage's effective lifetime to the application lifetime.

Leaving the constructor parameter as IEnumerable<IOrderStage> does not create lazy, scope-aware resolution on each iteration because Microsoft DI resolves the registered collection for the consuming service. If you truly need different stages per work item, resolve a new scoped runner for that work item instead of making business logic call back into the container.

This distinction keeps dependency injection where it belongs. The hosting layer controls scopes and resolution. The pipeline controls stage execution. Individual stages control their own business behavior.

Create One Explicit Scope Per Background Work Item

Microsoft's Generic Host documentation defines the hosted-service lifecycle, while its scoped-service guidance for BackgroundService states that a hosted service registered with AddHostedService is singleton and that no scope is created for it by default, so it must not inject a scoped pipeline directly. The supported approach is to inject IServiceScopeFactory, create a scope for each work item, resolve the scoped runner inside that scope, await the work, and then dispose the scope.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace PipelineDiExample;

public interface IOrderWorkSource
{
    IAsyncEnumerable<OrderWorkItem> ReadAllAsync(
        CancellationToken cancellationToken);
}

public sealed class OrderWorker(
    IOrderWorkSource source,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        await foreach (OrderWorkItem item in
            source.ReadAllAsync(stoppingToken)
                .WithCancellation(stoppingToken))
        {
            await using AsyncServiceScope scope =
                scopeFactory.CreateAsyncScope();

            OrderPipeline pipeline = scope.ServiceProvider
                .GetRequiredService<OrderPipeline>();

            await pipeline.ExecuteAsync(item, stoppingToken);
        }
    }
}

This resolution is at the host boundary, where scope creation is the job. It is not a service locator hidden inside a stage. The stage implementations still receive ordinary constructor dependencies and remain unaware of IServiceProvider.

The .NET Generic Host documentation describes the host as the owner of startup, hosted services, and graceful shutdown, including the stoppingToken passed to BackgroundService.ExecuteAsync. Passing it into the pipeline allows stages to cooperate with shutdown, while the await using statement ensures the scope is disposed after the work item finishes or fails.

If a single work item starts several independent pipeline executions, define whether they share one scope or each receive their own. One scope means scoped dependencies and state are shared. Separate scopes isolate them. That is a data and transaction decision, not merely a DI preference.

Let the Scope Own Disposal

Microsoft's dependency injection guidelines state that the container disposes services it creates according to their lifetimes. Do not manually dispose an injected stage or one of its injected dependencies.

For a scoped pipeline, disposing the scope disposes scoped and transient disposable services created within it, while singleton services are disposed when the root provider shuts down under the .NET DI disposal guidance. This ownership model prevents double disposal and keeps cleanup aligned with the same boundary that created the objects.

Payload ownership is separate. If an input or output message wraps a stream or another disposable resource, the application contract must say who owns that payload. DI can dispose the stage instance; it cannot infer whether the stage, runner, or caller owns data passing through the stage.

A Practical Lifetime Checklist

Before registering a pipeline, walk through these questions:

  • Is the stage order declared in one composition method?
  • Does the runner inject IEnumerable<IStage> rather than one implementation?
  • Is the runner lifetime no longer than any stage or dependency it retains?
  • Does every background work item receive an explicit scope?
  • Are singleton stages stateless or fully thread-safe?
  • Does the container own disposal for container-created services?
  • Is payload disposal documented separately?
  • Can a reviewer understand the final order without tracing startup across several assemblies?

These checks are deliberately boring. That is a feature. Pipeline composition should be easy to inspect because a small registration change can alter every execution.

Treat Registration Changes as Behavior Changes

Adding a new IOrderStage registration is not merely dependency-injection maintenance. It changes the behavior of every pipeline resolved from that composition method. Review the change with the same care as adding another explicit method call to the runner.

Start by asking where the stage belongs. A normalization stage that must run before every validator should appear before validation. A persistence stage that commits the terminal state should not be inserted halfway through a sequence simply because its implementation lives in a nearby project. Physical code organization does not define execution order.

Then review its lifetime. If the new stage depends on a scoped resource, the runner and every host path that resolves it must already provide a compatible scope. If the stage is registered as singleton, inspect the complete constructor graph rather than only the stage class. A stage with no mutable fields can still depend on a non-thread-safe collaborator.

Finally, review disposal and ownership. A stage that opens a resource through a container-created dependency should let the scope dispose that dependency. A stage that receives an owned payload must follow the pipeline's separate payload contract.

These questions belong next to the registration diff because that is where the application selects the implementation, order, and lifetime together. Keeping those decisions centralized makes accidental changes easier to catch.

Prefer Explicit Pipelines Over Ambient Discovery

Assembly scanning and plugin discovery can reduce registration code, but they also make order harder to see. For a fixed application pipeline, two explicit registration lines are often clearer than conventions involving attributes, names, priorities, and reflection.

Dynamic discovery is justified when extension modules are a real product requirement. If you take that path, validate that every stage has a unique identity and deterministic order, and fail startup when metadata is missing or ambiguous. Do not silently sort duplicate priorities or depend on file-system or reflection enumeration order.

The goal is not the fewest lines in Program.cs. The goal is a composition that another developer can inspect and predict.

Frequently Asked Questions

Does IEnumerable preserve pipeline stage registration order?

Yes, for registrations of the same service contract. Microsoft DI returns those registrations through IEnumerable<T> in registration order (service registration documentation). Treat that order as a configuration contract and keep the registrations together; use explicit typed composition for heterogeneous transitions.

Should every pipeline stage be scoped?

Not automatically. The .NET service lifetime guidance defines the transient, scoped, and singleton behaviors; choose among them based on the stage's state, dependencies, and work-item boundary.

Can a singleton BackgroundService inject a scoped pipeline?

No. The .NET BackgroundService scoped-service guidance requires creating a scope through IServiceScopeFactory before resolving scoped work from a singleton hosted service.

Is resolving the pipeline from a background scope a service locator?

It is resolution at the composition and hosting boundary, where lifetime ownership must be explicit. Resolving dependencies from inside business stages would be service-locator behavior and should be avoided.

Does keeping IEnumerable instead of calling ToArray avoid captive dependencies?

No. Microsoft DI resolves the collection for the consuming service, and a singleton consumer retains those stage references under the .NET lifetime rules whether it stores them as an array, list, or enumerable.

When is a singleton stage reasonable?

Use one when the stage is stateless or deliberately shares only thread-safe state, and every dependency it receives is compatible with the singleton lifetime. Do not use it solely to reduce allocations without measurement.

Keep Composition Explicit

The clean model for .NET dependency injection pipeline stages is straightforward: register stages in one visible sequence, inject the full collection into a runner with a compatible lifetime, and let the host create a scope around each unit of work.

Microsoft DI handles ordered collection resolution and container-owned service disposal. It does not decide what a work item is, whether a singleton is thread-safe, or whether a materialized collection has captured scoped state. Those are application decisions. Make them at the composition root, keep them visible, and the pipeline can remain ordinary, testable C#.

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.

Build a Type-Safe C# Pipeline From Scratch

Build a type-safe C# pipeline from scratch with generic stage contracts, heterogeneous transitions, immutable messages, and compiler-checked composition.

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