A common challenge in building complex ASP.NET Core applications, especially multi-tenant systems, is accessing request-specific data from deep within your service layer. How does a data repository or a business logic service know the current user’s ID, their permissions, or the tenant they belong to?
A naive approach is “prop-drilling”: passing the HttpContext or specific data points down through every method call. This clutters your method signatures and creates a maintenance nightmare. A slightly better approach might involve injecting IHttpContextAccessor, but this tightly couples your services to the HTTP infrastructure, making them difficult to test.
There is a cleaner, more powerful pattern: using middleware to populate a scoped service. This approach decouples your application logic from the web layer, improves testability, and keeps your code clean.
The Core Concept: Scoped Services as Request State
Dependency Injection in ASP.NET Core has three main lifetimes:
- Singleton: One instance for the entire application lifetime.
- Transient: A new instance is created every time it’s requested.
- Scoped: A single instance is created for each client request (scope).
The scoped lifetime is the key to our solution. We can create a service, register it as scoped, and treat it as a container for all the data relevant to the current HTTP request. A middleware component, which runs at the start of the request, will be responsible for populating this container.
Step-by-Step Implementation
Let’s build a practical example where we resolve a TenantId from an X-Tenant-ID request header and make it available to any service via DI.
1. Create the Scoped Data Holder
First, define a simple class that will hold our request-specific data. This class acts as a strongly-typed container.
// Models/TenantInfo.cs
public class TenantInfo
{
// We use an internal setter to prevent this from being changed
// anywhere outside of our dedicated middleware.
public string? TenantId { get; internal set; }
}
This class is simple by design. It has one job: hold the tenant ID for the duration of a single request.
2. Create the Populating Middleware
Next, we create the middleware that will read the request header and populate our TenantInfo service.
// Middleware/TenantResolutionMiddleware.cs
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
private const string TenantIdHeaderName = "X-Tenant-ID";
public TenantResolutionMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, TenantInfo tenantInfo)
{
// The TenantInfo service is injected directly into the InvokeAsync method.
// This is possible because TenantInfo is a registered service.
if (context.Request.Headers.TryGetValue(TenantIdHeaderName, out var tenantId))
{
tenantInfo.TenantId = tenantId.FirstOrDefault();
}
// Pass control to the next middleware in the pipeline.
await _next(context);
}
}
Notice that we are not injecting TenantInfo in the constructor. Middleware is constructed once at application startup (acting like a singleton), but we need the scoped instance of TenantInfo for the current request. By adding it as a parameter to InvokeAsync, the DI container provides the correct scoped instance for that specific request.
3. Register the Services and Middleware
Now, let’s wire everything up in Program.cs.
First, we’ll create a small extension method to keep our Program.cs clean.
// Middleware/MiddlewareExtensions.cs
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder builder)
{
return builder.UseMiddleware<TenantResolutionMiddleware>();
}
}
Then, in Program.cs, we register the scoped service and the middleware.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// 1. Register our scoped service.
// This tells the DI container to create one TenantInfo instance per request.
builder.Services.AddScoped<TenantInfo>();
builder.Services.AddControllers();
// Add other services...
var app = builder.Build();
// 2. Register the middleware.
// This must be placed before any middleware that might need the tenant info,
// like authorization or the endpoint mapping itself.
app.UseTenantResolution();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
The order is critical. The UseTenantResolution middleware must run before the controllers are executed so that the TenantInfo service is populated before any controller or service tries to access it.
4. Consume the Data in a Service
Now, any other service registered as scoped or transient can simply inject TenantInfo and use the data, completely unaware of HttpContext or request headers.
Let’s create a ProductService.
public interface IProductService
{
string GetProductsForTenant();
}
public class ProductService : IProductService
{
private readonly TenantInfo _tenantInfo;
private readonly ILogger<ProductService> _logger;
public ProductService(TenantInfo tenantInfo, ILogger<ProductService> logger)
{
_tenantInfo = tenantInfo;
_logger = logger;
}
public string GetProductsForTenant()
{
if (string.IsNullOrEmpty(_tenantInfo.TenantId))
{
_logger.LogWarning("Tenant ID is not present in the request.");
return "No products found, as no tenant was specified.";
}
_logger.LogInformation("Fetching products for Tenant ID: {TenantId}", _tenantInfo.TenantId);
// Here, you would have logic to query a database
// using the tenant ID, for example:
// var products = _context.Products.Where(p => p.TenantId == _tenantInfo.TenantId).ToList();
return $"Returning products for tenant '{_tenantInfo.TenantId}'.";
}
}
Finally, inject IProductService into a controller:
[ApiController]
[Route("[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpGet]
public IActionResult Get()
{
var result = _productService.GetProductsForTenant();
return Ok(result);
}
}
When you run the application and make a request with the header X-Tenant-ID: acme-corp, the ProductService will have access to "acme-corp" through the injected TenantInfo object.
Callout: A Superior Alternative to
IHttpContextAccessorYou could achieve a similar result using
IHttpContextAccessor, but this pattern is superior for several reasons. It follows the Explicit Dependencies Principle; your service depends onTenantInfo, not the entirety ofHttpContext. This makes the service’s dependencies clear and, most importantly, makes it trivial to unit test. You can simplynew TenantInfo { TenantId = "test-tenant" }in your test setup without wrestling with mocking the complexHttpContext.
Why This Pattern Is So Effective
- Decoupling: Your business logic is completely decoupled from ASP.NET Core’s HTTP-specific types. Your services only know about the
TenantInfoclass. - Testability: Unit testing
ProductServiceis incredibly easy. You don’t need to mockHttpContextAccessororHttpContext; you just need to create an instance ofTenantInfo. - Cleanliness: It eliminates “prop-drilling” and keeps your service constructors and method signatures clean and focused on their actual dependencies.
- Centralized Logic: The logic for resolving the tenant ID is centralized in one middleware, making it easy to change or expand upon in the future.
This middleware-to-scoped-service pattern is a cornerstone of clean, maintainable, and testable application architecture in ASP.NET Core.
References
- Dependency injection in ASP.NET Core
- ASP.NET Core Middleware
- Factory-based middleware activation in ASP.NET Core