Skip to content

Recipe Aspnet Host Integration

Mark Lauter edited this page May 10, 2026 · 4 revisions

ASP.NET Core Host Integration

This is the v3 flagship use case. You have an ASP.NET Core app — minimal API, MVC, gRPC, doesn't matter — and you've decided that some piece of work inside it deserves its own pipeline. A webhook endpoint that needs to validate a signature, parse a payload, dispatch to a workflow, and write to two stores. A scheduled job that fans out to a handful of services. An internal API endpoint with five steps that each want their own logger and their own scope. The host already owns DI, configuration, and logging; Plumber should reuse all of it.

This recipe shows how.

When this recipe applies

Reach for host-mode integration when:

  • The work is not HTTP request handling (or it's HTTP handling that's complex enough to warrant its own composable chain).
  • You already have an ASP.NET Core host with services registered, a configuration root, and logging set up.
  • You want each step in the pipeline to be a small, testable unit with access to the same DI container the rest of your app uses.
  • You'd rather have one DI graph than two.

The standalone RequestHandlerBuilder<TReq, TRes>.Build() path is the right call when you have no host — a Lambda function, a queue-polling console app, a CLI tool. Inside an ASP.NET Core app, building a second DI container next to the host's is wasted work. Host-mode lets the handler share the host's IServiceProvider.

Why host-mode

The two paths produce a RequestHandler<TRequest, TResponse> with the same surface. The difference is who owns the IServiceProvider.

// Standalone — Plumber owns its own DI, configuration, logging.
using var handler = RequestHandlerBuilder
    .Create<MyRequest, MyResponse>()
    .ConfigureServices((services, _) => services.AddSingleton<IMyService, MyService>())
    .Build()
    .Use<MyMiddleware>();
// Host-mode — Plumber reuses the host's IServiceProvider.
var handler = RequestHandler
    .Create<MyRequest, MyResponse>(serviceProvider)
    .Use<MyMiddleware>();

In standalone mode the handler is using-disposed and the service provider it built dies with it. In host-mode the handler holds a reference to the host's provider but does not own it — Dispose on the handler does not touch the provider. The host's lifetime governs the provider; Plumber rides along.

A side benefit: IMyService is registered exactly once, in builder.Services, and any other part of the app that wants it gets the same registration.

Project setup

Start from the standard minimal API template:

dotnet new web -n Webhook.Api
cd Webhook.Api
dotnet add package MSL.Plumber.Pipeline

Plumber targets .NET 10. The ASP.NET Core 10 templates target the same framework, so no extra version juggling.

Defining the pipeline

The README's CreateBuilder + Configure split exists for the standalone case. Host-mode collapses it: the host already built DI, so there's no builder to configure. You define a single Configure method that takes the host-mode-created handler and adds middleware:

internal static class ProcessingPipeline
{
    public static RequestHandler<ProcessRequest, ProcessResponse> Configure(
        RequestHandler<ProcessRequest, ProcessResponse> handler) =>
        handler
            .Use<ValidationMiddleware>()
            .Use<EnrichmentMiddleware>()
            .Use<DispatchMiddleware>();
}

Three middleware classes — each in its own file, each with its own dependencies, each independently testable. The pipeline's shape lives in one place.

The request and response types are records:

public sealed record ProcessRequest(string Tenant, JsonElement Payload);

public sealed record ProcessResponse(string CorrelationId, string Status);

A middleware example, to make the shape concrete:

internal sealed class ValidationMiddleware(
    RequestMiddleware<ProcessRequest, ProcessResponse> next,
    ILogger<ValidationMiddleware> logger)
{
    public Task InvokeAsync(RequestContext<ProcessRequest, ProcessResponse> context)
    {
        if (string.IsNullOrWhiteSpace(context.Request.Tenant))
        {
            logger.LogWarning("rejected request {Id}: tenant required", context.Id);
            context.Response = new ProcessResponse(context.Id.ToString(), "rejected");
            return Task.CompletedTask;
        }

        return next(context);
    }
}

ILogger<T> is a singleton in the host's DI, so constructor injection is fine. Anything per-request goes on InvokeAsync — see Sharing services below.

Registering the handler in the host's DI

The handler is registered as a singleton. The factory builds the host-mode handler, hands it to your Configure method, and returns the configured pipeline. From that point forward, the same handler instance is reused for every request:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IMyService, MyService>();
builder.Services.AddScoped<AppDbContext>();

builder.Services.AddSingleton(sp =>
    ProcessingPipeline.Configure(
        RequestHandler.Create<ProcessRequest, ProcessResponse>(sp)));

var app = builder.Build();

Why singleton? The handler is reusable. It compiles its middleware dispatch once (expression trees, see Advanced) and reuses that compiled delegate for every invocation. Each call to InvokeAsync creates its own DI scope from the host's IServiceScopeFactory, so per-request services are fresh on every call — the singleton handler holds only registration-time state.

Registering the handler as scoped or transient would defeat the point. You'd rebuild the same compiled pipeline on every HTTP request and pay the registration cost over and over.

A small ergonomic improvement: wrap the registration in an extension method so Program.cs stays clean.

internal static class PipelineRegistration
{
    public static IServiceCollection AddProcessingPipeline(this IServiceCollection services) =>
        services.AddSingleton(sp =>
            ProcessingPipeline.Configure(
                RequestHandler.Create<ProcessRequest, ProcessResponse>(sp)));
}
builder.Services.AddProcessingPipeline();

Calling from a minimal API endpoint

The handler shows up like any other DI service. Inject it into the endpoint delegate:

app.MapPost("/process", async (
    ProcessRequest request,
    RequestHandler<ProcessRequest, ProcessResponse> handler,
    CancellationToken cancellationToken) =>
{
    var response = await handler.InvokeAsync(request, cancellationToken);
    return response is null
        ? Results.Problem("pipeline returned no response", statusCode: 500)
        : Results.Ok(response);
});

app.Run();

Two things worth pointing out.

The CancellationToken parameter is the one ASP.NET Core gives you — the request-aborted token. Pass it to InvokeAsync and Plumber threads it through every middleware. When the client hangs up, the pipeline cancels.

InvokeAsync returns Task<TResponse?>. The response is null when no middleware assigned context.Response. In a webhook-style pipeline the dispatcher should always set a response, so a null return signals a bug. Logging or returning a 500 is reasonable.

For controllers, the shape is the same:

[ApiController]
[Route("[controller]")]
public sealed class ProcessController(
    RequestHandler<ProcessRequest, ProcessResponse> handler)
    : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Post(
        ProcessRequest request,
        CancellationToken cancellationToken)
    {
        var response = await handler.InvokeAsync(request, cancellationToken);
        return response is null ? Problem("pipeline returned no response") : Ok(response);
    }
}

Disposal semantics

The host owns the IServiceProvider. Plumber's host-mode handler does not.

In RequestHandler<TRequest, TResponse>, the ownsProvider flag is false for handlers built via RequestHandler.Create(IServiceProvider). Dispose flips the disposed bit but leaves the provider alone. When the host shuts down, it disposes its own provider, which transitively disposes the singleton handler registration.

Practically:

  • In production, the handler is a singleton and the host runs the show. Wrapping it in using would dispose it after the first call, and subsequent calls would throw ObjectDisposedException. Let the DI container handle its lifetime.
  • In tests, you can wrap a host-mode handler in using if you want explicit cleanup. The provider it points at is unaffected — only the handler itself is marked disposed.

The standalone path is different: using var handler = builder.Build() is mandatory there because Plumber owns the provider and disposing the handler is the only thing that disposes the provider. Mixing the two patterns gets confusing; the rule of thumb is match disposal to ownership. Owns the provider, dispose it. Doesn't own it, leave it alone.

TimeProvider

RequestHandler.Create(IServiceProvider) resolves TimeProvider from the provider if one is registered, otherwise falls back to TimeProvider.System. The host normally registers TimeProvider.System for you (it's the default in Microsoft.Extensions.Hosting); to swap in a fake clock for tests, register FakeTimeProvider in the host's Services before calling builder.Build():

// in a test setup
builder.Services.AddSingleton<TimeProvider>(new FakeTimeProvider());

Plumber's Elapsed clock and any timeout you configure on the handler will read from that provider. This is the same mechanism the standalone path uses; the only difference is where you register the provider.

Sharing services

Anything in the host's DI is available to Plumber middleware. The two injection styles map to the two service-lifetime categories:

Constructor injection for singletons. Plumber constructs each middleware once at registration time, so constructor parameters are resolved from the root provider. Things that are safe to capture across all requests live here: ILogger<T>, TimeProvider, options instances, factories.

internal sealed class EnrichmentMiddleware(
    RequestMiddleware<ProcessRequest, ProcessResponse> next,
    ILogger<EnrichmentMiddleware> logger,
    TimeProvider clock)
{
    public Task InvokeAsync(RequestContext<ProcessRequest, ProcessResponse> context)
    {
        logger.LogInformation("enriching {Id} at {Now}", context.Id, clock.GetUtcNow());
        return next(context);
    }
}

Method injection for scoped or transient services. The first parameter on InvokeAsync is the context; everything after that is resolved from the per-request scope on every call. This is the safe place for DbContext, HttpClient (when registered via IHttpClientFactory), and anything that should not be shared across requests.

internal sealed class DispatchMiddleware(
    RequestMiddleware<ProcessRequest, ProcessResponse> next)
{
    public async Task InvokeAsync(
        RequestContext<ProcessRequest, ProcessResponse> context,
        AppDbContext db,
        IHttpClientFactory httpFactory)
    {
        var client = httpFactory.CreateClient("downstream");
        // ... do work ...
        await db.SaveChangesAsync(context.CancellationToken);
        context.Response = new ProcessResponse(context.Id.ToString(), "ok");
    }
}

The dispatch is compiled to an expression tree once per registration, so per-invocation reflection cost is zero. Method-injected parameters are resolved through GetRequiredService, so a missing registration throws fast and clearly at invocation time.

Put another way: anything you'd inject into a controller, you can inject into a Plumber middleware the same way. Constructor for singletons, InvokeAsync parameters for everything else.

Sharing data between middleware

Use RequestContext.Data for state that flows down the chain. The dictionary is allocated lazily — if your pipeline has nothing to share, you pay nothing.

// In ValidationMiddleware
context.Data["tenant"] = context.Request.Tenant;

// In a downstream middleware
if (context.TryGetValue<string>("tenant", out var tenant))
{
    // ...
}

Define your keys as constants on a DataKeys class so you can grep for them. The Sample.Cli project is the canonical example.

Testing

Host-mode pipelines test naturally with WebApplicationFactory<TEntryPoint> from Microsoft.AspNetCore.Mvc.Testing. The factory builds your real host (with whatever overrides you supply) and you resolve the handler from the host's services.

public sealed class ProcessingPipelineTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> factory;

    public ProcessingPipelineTests(WebApplicationFactory<Program> factory) =>
        this.factory = factory.WithWebHostBuilder(b =>
            b.ConfigureServices(services =>
            {
                services.AddSingleton<IMyService, FakeMyService>();
            }));

    [Fact]
    public async Task ValidRequestProducesOkResponseAsync()
    {
        using var scope = factory.Services.CreateScope();
        var handler = scope.ServiceProvider
            .GetRequiredService<RequestHandler<ProcessRequest, ProcessResponse>>();

        var response = await handler.InvokeAsync(
            new ProcessRequest("acme", JsonDocument.Parse("{}").RootElement));

        Assert.NotNull(response);
        Assert.Equal("ok", response!.Status);
    }

    [Fact]
    public async Task EndpointReturnsOkAsync()
    {
        using var client = factory.CreateClient();
        var content = JsonContent.Create(new { tenant = "acme", payload = new { } });
        var response = await client.PostAsync("/process", content);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

Two complementary test styles. Resolving the handler directly from factory.Services lets you hit the pipeline without going through Kestrel — fast, deterministic, easy to assert against typed responses. factory.CreateClient() wraps the whole stack and is the right shape for testing the endpoint, status codes, and content negotiation.

For more on factory-based testing of standalone pipelines, see PlumberApplicationFactory. For the general testing strategy — direct invocation vs factory — see Testing.

Tested against

  • .NET 10
  • ASP.NET Core 10
  • MSL.Plumber.Pipeline 3.0.0
  • Microsoft.AspNetCore.Mvc.Testing 10.0.0

See also

  • Advanced — host-mode internals, custom TimeProvider, multiple Build() calls
  • Recipe-Background-Service-Worker — the same host-mode pattern inside a BackgroundService
  • Recipe-Webhook-Receiver — webhook-specific concerns layered on top of this recipe
  • Middleware — full middleware reference: delegate vs class, injection rules
  • Testing — strategy overview, when to reach for WebApplicationFactory vs PlumberApplicationFactory
  • Home

Clone this wiki locally