Skip to content

minimal api

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:

  1. Classify the request (Site vs Council, Commercial vs Domestic).
  2. Fetch way-points from a repository.
  3. Convert timestamps to local time.
  4. Constrain results to a bounding-box.
  5. 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.

Dynamic Connection Strings in EF Core 9 Minimal APIs

Altering a DbContext Connection at Runtime

In Entity Framework Core (including EF Core 9.x), the database connection is normally configured when the DbContext is constructed, and it isn’t intended to be changed afterward. Once a DbContext is injected (e.g. via DI in a minimal API), its DbContextOptions (including the connection string) are essentially fixed. There is no built-in method to reconfigure the context’s connection string after it’s been created in the DI container. In other words, you cannot directly “swap out” the connection string of an existing context instance once it’s been configured.

That said, EF Core does provide an advanced feature to support dynamic connections if done before first use. EF Core’s relational providers (SQL Server, PostgreSQL, etc.) allow you to register a context without initially specifying a connection string, then supply it at runtime. For example, UseSqlServer (and other Use* calls) have an overload that omits the connection string, in which case you must set it later before using the context. EF Core exposes the DatabaseFacade.SetConnectionString() extension method for this purpose. In practice, this means:

  • You would configure the DbContext with the provider but no connection string at startup. For instance: builder.Services.AddDbContext<MyContext>(opt => opt.UseSqlServer()); (calling the parameterless UseSqlServer overload). This registers the context without a concrete connection string.
  • Then, at runtime (before any database operations), you can set the actual connection string on the context instance. For example:
// Inside your endpoint or service, before using the context:
context.Database.SetConnectionString(actualConnectionString);
// Now you can use context (queries, SaveChanges, etc.)

This approach will dynamically point that context instance to the given connection string. However, caution is required: you must call SetConnectionString before the context is used to connect to the database (i.e., before any query or SaveChanges call). If the context has already been used (or was configured with a specific connection string initially), changing it at runtime is unsupported. In summary, EF Core technically allows dynamic assignment of the connection on a fresh context, but you cannot retroactively change an already-initialized connection string after the context has been used.

Supported Patterns for Dynamic Connection Strings

Given the constraints above, the recommended solution is to provide the correct connection string when the DbContext is created, rather than trying to alter it afterward. There are several patterns to achieve this in a .NET 9 Minimal API:

1. Use a DbContext Factory or Manual Context Creation

One option is to avoid injecting the DbContext directly, and instead inject a factory that can create DbContext instances on the fly with the desired connection string. EF Core provides IDbContextFactory<T> via AddDbContextFactory, or you can implement your own factory. For example, a custom factory interface and implementation might look like:

public interface ITenantDbContextFactory<TContext> where TContext : DbContext
{
    TContext Create(string databaseName);
}

public class Dcms3DbContextFactory : ITenantDbContextFactory<Dcms3DbContext>
{
    public Dcms3DbContext Create(string databaseName)
    {
        // Build new options with the given database name in the connection string
        var optionsBuilder = new DbContextOptionsBuilder<Dcms3DbContext>();
        optionsBuilder.UseSqlServer($"Server=...;Database={databaseName};TrustServerCertificate=True;");
        return new Dcms3DbContext(optionsBuilder.Options);
    }
}

This factory constructs a new Dcms3DbContext with a connection string targeting the specified database (the rest of the connection details can be fixed). The calling code (e.g. an endpoint handler) would request Dcms3DbContextFactory from DI and use it to create a context for the current request. This pattern is essentially what one Stack Overflow answer suggested for multi-database scenarios. For example, in a minimal API endpoint:

app.MapGet("/data/{dbName}", async (string dbName, Dcms3DbContextFactory factory) =>
{
    await using var db = factory.Create(dbName);
    var results = await db.MyEntities.ToListAsync();
    return Results.Ok(results);
});

Here, the context is created at request time with the appropriate connection string. Note: If you use IDbContextFactory<T> via AddDbContextFactory, it’s typically registered as a singleton by default. In a dynamic scenario, you may still need to call SetConnectionString on the created context (since AddDbContextFactory usually uses a fixed configuration). Alternatively, you can register the factory as scoped and supply the dynamic connection inside the factory as shown above. In either case, you are responsible for disposing of the context instance (as shown with await using var db = ...).

2. Configure DbContext per request via DI (Scoped Configuration)

Another approach is to leverage the dependency injection configuration to supply the connection string based on some scoped context (such as the current HTTP request or tenant). You can use the overload of AddDbContext that provides the IServiceProvider to build options. This allows you to retrieve information from other services (like configuration or HTTP context) each time a DbContext is created. For example, in Program.cs:

builder.Services.AddHttpContextAccessor(); // enable accessing HttpContext in DI

builder.Services.AddDbContext<Dcms3DbContext>((serviceProvider, options) =>
{
    // Get current HTTP context, route values, etc.
    var httpContext = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext;
    var dbName = httpContext?.Request.RouteValues["dbName"] as string;
    // Build the connection string dynamically (example assumes a base template)
    string baseConn = configuration.GetConnectionString("BaseTemplate"); // e.g. "Server=...;Database={0};...;"
    var connectionString = string.Format(baseConn, dbName);
    options.UseSqlServer(connectionString);
});

In this example, whenever Dcms3DbContext is requested, the DI container will execute our factory lambda: it grabs the current request’s dbName (perhaps from the URL or headers) and configures the DbContextOptions with the correct connection string. This effectively gives each HTTP request a context tied to its specific database. Felipe Gavilán’s blog illustrates this pattern using an HTTP header to convey a tenant ID, and the Code Maze series provides a similar example using a custom IDataSourceProvider service. In either case, the key is that the connection string comes from a scoped service or request data rather than being hard-coded at startup.

This pattern requires that you have access to the necessary context (like route values, a JWT claim, or a header) by the time the DbContext is being constructed. In minimal APIs, route parameters are available in HttpContext.Request.RouteValues. Using an IHttpContextAccessor (registered as singleton) is a straightforward way to get this information inside the AddDbContext lambda. Alternatively, you could set up a dedicated scoped service (e.g. ITenantService) earlier in the pipeline (via middleware or an endpoint filter) that stores the chosen database name for the request, and then have the DbContext configuration read from that service. This approach keeps your DbContext registration clean, e.g.:

builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddDbContext<Dcms3DbContext>((sp, options) =>
{
    var tenantService = sp.GetRequiredService<ITenantService>();
    string connStr = tenantService.GetCurrentTenantConnectionString();
    options.UseSqlServer(connStr);
});

In this case, TenantService would determine the current database (perhaps using the current user info or route data) and provide the appropriate connection string. The official EF Core documentation for multi-tenancy demonstrates this pattern: the context can accept an ITenantService (and perhaps IConfiguration) via its constructor, and use that in OnConfiguring to choose the connection string. The context is then added via AddDbContextFactory or AddDbContext as a scoped or transient service so that each request/tenant evaluation is fresh.

3. Use OnConfiguring with Injected Configuration (Alternative)

As mentioned above, you can also implement dynamic connection logic inside your DbContext class itself by overriding OnConfiguring. If your context’s constructor has access to something like IConfiguration and a tenant identifier, you can call the appropriate UseSqlServer (or other provider) within OnConfiguring. For example:

public class Dcms3DbContext : DbContext
{
    private readonly string _connection;
    public Dcms3DbContext(DbContextOptions<Dcms3DbContext> opts, IConfiguration config, ITenantService tenantSvc)
        : base(opts)
    {
        // Build the connection string using the current tenant info
        var tenantId = tenantSvc.TenantId;
        _connection = config.GetConnectionString(tenantId); 
    }
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!string.IsNullOrEmpty(_connection))
            optionsBuilder.UseSqlServer(_connection);
    }
}

In this setup, the context is still created per request (e.g. via a context factory or regular DI), and whenever it’s constructed, it grabs the current tenant’s identifier and finds the matching connection string from configuration. The OnConfiguring ensures the context uses that connection. This pattern achieves the same result – each DbContext instance is configured for the appropriate database – but keeps the logic inside the context class. The downside is that you must ensure the extra services (IConfiguration, ITenantService) are available to the context (which often means using AddDbContextFactory with a scoped lifetime or using constructor injection with AddDbContext and explicitly passing those services in). The EF Core team’s guidance notes that if a user can switch tenants within the same session or scope, you might need to register the context as transient to avoid caching the old connection string. In typical request-based scoping, this isn’t an issue (each request gets a new context anyway).

4. Multiple DbContext registrations (not typical for this scenario)

For completeness, if the set of possible databases is known and small, some applications simply register multiple contexts (one per connection string) and choose between them. However, in your scenario (a single DbContext type, database name only known at request time, no multi-schema), that’s not an ideal solution. It’s better to use one of the dynamic approaches above rather than duplicating context types or manually choosing between many DI registrations.

Best Practices for Dynamic Connection Scenarios in EF Core 9

When implementing dynamic connection strings, keep these best practices in mind:

  • Provide the connection string as early as possible: Ideally, configure the DbContext with the correct connection string at creation time (per request). This avoids any need to change it afterward. Use factories or DI patterns to supply the string based on the current context (tenant, user, etc.) before the context is used.

  • Use scoped or transient lifetimes appropriately: For web apps, a scoped lifetime (per HTTP request) is usually appropriate for DbContext. If there’s a possibility a user will change the target database mid-session and you want the same user session to fetch a new database, consider using a transient context or creating new context instances as needed. Do not reuse the same context instance for different databases.

  • Avoid global mutable state: Don’t store the “current connection string” in a static or singleton that is modified per request without proper scoping. For example, the Code Maze example uses a singleton DataSourceProvider with a CurrentDataSource property, but notes this must be carefully managed (and per-user in multi-user scenarios). A safer approach is to keep tenant-specific info in scoped services or the HttpContext. This ensures threads or concurrent requests don’t interfere with each other’s settings.

  • Dispose of contexts properly: When you manually create contexts (via a factory or new DbContext(options)), be sure to dispose of them after use (e.g. use using/await using blocks or let the DI scope handle disposal if the context is resolved from the container). Each context/connection should be cleaned up after the request/unit-of-work ends.

  • Be mindful of connection pooling and performance: If you use DbContext pooling (AddDbContextPool or AddPooledDbContextFactory), be aware that pooled contexts might retain state (including an open connection or a set connection string). Pooling is generally not recommended for dynamic connection scenarios because a context from the pool might still be tied to a previous connection. Stick to regular scoped contexts or your own factories so that each context starts fresh with the correct connection. If performance becomes a concern, measure it – EF Core is designed to create contexts quickly, and per-request creation is usually fine.

  • Secure the tenant selection: Since the database is chosen at runtime, ensure that the mechanism for selecting the database is secure and validated. For example, if you pass a database name via an API route or header, validate that the caller is authorized for that database and that the name is valid. Avoid directly concatenating untrusted input into connection strings without checks (to prevent connection string injection or accidental exposure of other databases).

  • Follow EF Core updates: EF Core (including v9) continues to improve support for multi-tenant scenarios. Keep an eye on official docs and release notes. The official multi-tenancy guidance (for EF Core ⅞+) provides patterns that are still applicable in EF Core 9. While EF Core doesn’t have a built-in multi-tenant manager, the combination of the techniques above (scoped config, context factories, SetConnectionString, etc.) is the supported way to go.

By using these patterns, you can handle a scenario where the database name is determined at request processing time. The preferred approach is to create a new DbContext (or configure one via DI) with the proper connection string per request, rather than trying to mutate an existing injected context. This aligns with EF Core’s unit-of-work pattern and ensures each context instance talks to the correct database.

Sources:

  • Microsoft Docs – EF Core Multi-Tenancy (Multiple Databases)
  • Microsoft Docs – DbContext Configuration & Lifetime (for using AddDbContextFactory and DI)
  • Code Maze – Dynamically Switching DbContext at Runtime (dynamic connection via DI)
  • Stack Overflow – EF Core: dynamic connection string per tenant (custom factory example)

Project Directory Structures

Various methods exist for organizing a project's directory structure, but I have a particular preference. Prior to delving into that, let's examine the conventional approaches often suggested by templates and demoware and why they may not be the most advantageous choice.