Building Domain-Specific “Russian-Doll” Pipelines in .NET 9 – a Functional Approach with ProblemOr
Move the elegance of ASP.NET Core middleware into any complex back-end workflow – without
HttpContext
, without OOP builders, and without losing DI, cancellation or early-exit behaviour.
Why a pipeline?
Business workflows such as Get GPS Logs often involve many steps:
- Classify the request (Site vs Council, Commercial vs Domestic).
- Fetch way-points from a repository.
- Convert timestamps to local time.
- Constrain results to a bounding-box.
- Materialise the response.
Each step should be independent, replaceable, testable and able to short-circuit on error or empty data – the classic “Russian-doll” pattern that ASP.NET Core middleware delivers for web traffic.
Key design choices
Decision | Rationale |
---|---|
Immutable context record (GpsLogContext ) |
No hidden state; easy to with -clone for functional purity. |
Discriminated-union result (ProblemOr<T> ) |
One return type covers success or one-or-many errors; consumers call Match . |
Pure functions + static “compose” helper | No builder classes or interfaces; the pipeline itself is a first-class function. |
DI scope passed in the context | Middleware still resolve scoped services, but the composer stays DI-agnostic. |
The core primitives
using ProblemOr;
public sealed record GpsLogContext(
RequestModel Request,
ResponseModel? Response,
IServiceScope Scope,
CancellationToken Ct);
// Pipeline delegate (mirrors aspnetcore RequestDelegate)
public delegate ValueTask<ProblemOr<GpsLogContext>> GpsLogDelegate(GpsLogContext ctx);
A functional static builder
public static class DomainPipeline
{
private static readonly GpsLogDelegate Terminal =
ctx => ValueTask.FromResult<ProblemOr<GpsLogContext>>(ctx);
public static GpsLogDelegate Compose(
params Func<GpsLogDelegate, GpsLogDelegate>[] parts)
{
var app = Terminal;
for (var i = parts.Length - 1; i >= 0; i--)
app = parts[i](app); // Russian-doll wrap
return app;
}
}
Compose
returns one GpsLogDelegate
; there is no stateful builder instance to maintain or mock.
Middleware components as pure functions
// Request classifier
static Func<GpsLogDelegate, GpsLogDelegate> RequestClassifier =>
next => async ctx =>
{
if (!ctx.Request.TryDetermineFlags())
return Error.Validation(code: "BadType", description: "Unknown request");
return await next(ctx);
};
// Way-point fetcher (shows early error exit)
static Func<GpsLogDelegate, GpsLogDelegate> WaypointFetcher =>
next => async ctx =>
{
var repo = ctx.Scope.ServiceProvider.GetRequiredService<IWaypointRepository>();
var points = await repo.GetAsync(ctx.Request.SiteNumber, ctx.Ct);
if (points.Count == 0)
return Error.NotFound(description: "No way-points");
var updated = ctx with { Request = ctx.Request with { Waypoints = points } };
return await next(updated);
};
// Time-zone resolver
static Func<GpsLogDelegate, GpsLogDelegate> TimezoneResolver =>
next => async ctx =>
{
var tz = ctx.Scope.ServiceProvider.GetRequiredService<ITimezoneService>();
var local = await tz.ToLocalAsync(ctx.Request.UtcTime, ctx.Ct);
var updated = ctx with { Request = ctx.Request with { LocalTime = local } };
return await next(updated);
};
// Bounding-box filter
static Func<GpsLogDelegate, GpsLogDelegate> BoundingBoxFilter =>
next => async ctx =>
{
var updated = ctx with { Request = ctx.Request.FilterToBoundingBox() };
return await next(updated);
};
// Response builder – terminal step, always succeeds
static Func<GpsLogDelegate, GpsLogDelegate> ResponseBuilder =>
_ => ctx =>
{
var response = ResponseModel.Create(ctx.Request);
return ValueTask.FromResult<ProblemOr<GpsLogContext>>(ctx with { Response = response });
};
Add or remove steps at will; order is controlled exclusively by the Compose
call.
Wiring the pipeline in Program.cs (minimal-API style)
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ProblemOr;
using static DomainPipeline;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddScoped<IWaypointRepository, WaypointRepository>()
.AddScoped<ITimezoneService, TimezoneService>();
// Build the single function at start-up
GpsLogDelegate gpsPipeline = Compose(
RequestClassifier,
WaypointFetcher,
TimezoneResolver,
BoundingBoxFilter,
ResponseBuilder);
var app = builder.Build();
app.MapPost("/gpslogs", async (RequestModel req, IServiceProvider sp, CancellationToken ct) =>
{
await using var scope = sp.CreateAsyncScope();
var seed = new GpsLogContext(req, null, scope, ct);
var result = await gpsPipeline(seed);
return result.Match(
ok => Results.Ok(ok.Response),
errors => Results.Problem(title: "GPS log error",
statusCode: 400,
detail: string.Join(" | ", errors.Select(e => e.Description))));
});
await app.RunAsync();
- DI scope is created per request, keeping repository and service lifetimes correct.
- All middleware run in-memory; no
HttpContext
, no Kestrel overhead. - Early failures propagate as a single 400 response with an aggregated error message.
Unit-testing a component (xUnit + NSubstitute)
[Fact]
public async Task WaypointFetcher_returns_NotFound_when_empty()
{
// Arrange
var repo = Substitute.For<IWaypointRepository>();
repo.GetAsync("05", Arg.Any<CancellationToken>()).Returns([]);
var services = new ServiceCollection().AddSingleton(repo).BuildServiceProvider();
await using var scope = services.CreateAsyncScope();
var ctx = new GpsLogContext(new("05"), null, scope, default);
// Act
var result = await WaypointFetcher(_ => throw new Exception("Next should not run"))(ctx);
// Assert
Assert.True(result.IsError);
Assert.Equal("No way-points", result.FirstError.Description);
}
Because each middleware is a pure function, testing entails no test-server fixtures.
Performance and scaling notes
- No reflection – the pipeline is a pre-composed delegate chain.
- Zero allocations per step – except when
with
creates a new record instance (which is unavoidable for immutability). - Parallel runs – the delegate is thread-safe; every invocation receives its own
GpsLogContext
.
If you need to reuse singleton configuration inside a component, capture it when you declare the lambda:
var staticOptions = builder.Configuration.GetSection("Gps").Get<GpsOptions>();
Func<GpsLogDelegate, GpsLogDelegate> ConfigInjector =
next => ctx => next(ctx with { Request = ctx.Request with { Options = staticOptions } });
Take-aways
- Functional composition is enough – no builder objects, no interfaces.
- ProblemOr
gives strongly-typed early exits; no boolean flags or exceptions are required for control-flow. - DI remains first-class because each middleware receives the current scope from the context.
- The pattern mirrors ASP.NET Core middleware closely, so new team-members recognise the mental model instantly.
Ready-to-paste code
All code above fits into three small files:
File | Contents |
---|---|
Pipeline.cs | Static Pipeline.Compose and the GpsLogDelegate alias. |
GpsLogContext.cs | Immutable record + supporting models. |
Middleware.cs | The five lambda components shown. |
Drop them into any .NET 9 project and enjoy web-grade pipeline composability inside your domain logic — no custom frameworks, no accidental complexity.