From 674754377ffb2ff38de0d852c260d9915b774094 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Mon, 12 Jan 2026 19:13:15 +0300 Subject: [PATCH 1/2] Resolve multitenancy middleware scoped services per request --- .../Middleware/TenantResolutionMiddleware.cs | 81 +++++++++---------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/TenantResolutionMiddleware.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/TenantResolutionMiddleware.cs index 6fd2e70..e17119f 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/TenantResolutionMiddleware.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/TenantResolutionMiddleware.cs @@ -16,18 +16,20 @@ namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; public sealed class TenantResolutionMiddleware { private readonly RequestDelegate _next; - private readonly ITenantResolver _resolver; - private readonly ITenantAccessor _accessor; - private readonly ITenantResolutionContextFactory _contextFactory; - private readonly AspNetCoreMultitenancyOptions _aspNetCoreOptions; - private readonly MultitenancyOptions _options; - private readonly ILogger _logger; - private readonly ITenantCorrelationScopeAccessor _scopeAccessor; /// /// Initializes a new instance of the class. /// /// Request delegate. + public TenantResolutionMiddleware(RequestDelegate next) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + } + + /// + /// Executes the middleware. + /// + /// HTTP context. /// Tenant resolver. /// Tenant accessor. /// Resolution context factory. @@ -35,8 +37,8 @@ public sealed class TenantResolutionMiddleware /// Core multitenancy options. /// Logger. /// Correlation scope accessor. - public TenantResolutionMiddleware( - RequestDelegate next, + public async Task InvokeAsync( + HttpContext httpContext, ITenantResolver resolver, ITenantAccessor accessor, ITenantResolutionContextFactory contextFactory, @@ -44,41 +46,35 @@ public TenantResolutionMiddleware( IOptions options, ILogger logger, ITenantCorrelationScopeAccessor scopeAccessor) - { - _next = next ?? throw new ArgumentNullException(nameof(next)); - _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); - _accessor = accessor ?? throw new ArgumentNullException(nameof(accessor)); - _contextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); - _aspNetCoreOptions = aspNetCoreOptions?.Value ?? throw new ArgumentNullException(nameof(aspNetCoreOptions)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); - } - - /// - /// Executes the middleware. - /// - /// HTTP context. - public async Task InvokeAsync(HttpContext httpContext) { ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(resolver); + ArgumentNullException.ThrowIfNull(accessor); + ArgumentNullException.ThrowIfNull(contextFactory); + ArgumentNullException.ThrowIfNull(aspNetCoreOptions); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(scopeAccessor); - var resolutionContext = _contextFactory.Create(httpContext); - var tenantContext = await _resolver.ResolveAsync(resolutionContext, httpContext.RequestAborted).ConfigureAwait(false); + var aspNetCoreOptionsValue = aspNetCoreOptions.Value ?? throw new ArgumentNullException(nameof(aspNetCoreOptions)); + var optionsValue = options.Value ?? throw new ArgumentNullException(nameof(options)); - if (_aspNetCoreOptions.StoreTenantInHttpContextItems) + var resolutionContext = contextFactory.Create(httpContext); + var tenantContext = await resolver.ResolveAsync(resolutionContext, httpContext.RequestAborted).ConfigureAwait(false); + + if (aspNetCoreOptionsValue.StoreTenantInHttpContextItems) { - httpContext.Items[_aspNetCoreOptions.HttpContextItemKey] = tenantContext; + httpContext.Items[aspNetCoreOptionsValue.HttpContextItemKey] = tenantContext; } - using var tenantScope = _accessor.BeginScope(tenantContext); + using var tenantScope = accessor.BeginScope(tenantContext); IDisposable? logScope = null; - var shouldClearScope = _scopeAccessor.CurrentScope is null; + var shouldClearScope = scopeAccessor.CurrentScope is null; if (shouldClearScope) { - logScope = BeginLoggingScope(tenantContext?.TenantId); - _scopeAccessor.SetScope(logScope, owned: false); + logScope = BeginLoggingScope(logger, optionsValue, tenantContext?.TenantId); + scopeAccessor.SetScope(logScope, owned: false); } try @@ -89,9 +85,9 @@ public async Task InvokeAsync(HttpContext httpContext) { if (shouldClearScope) { - if (ReferenceEquals(_scopeAccessor.CurrentScope, logScope)) + if (ReferenceEquals(scopeAccessor.CurrentScope, logScope)) { - _scopeAccessor.ClearScope()?.Dispose(); + scopeAccessor.ClearScope()?.Dispose(); } else { @@ -101,13 +97,16 @@ public async Task InvokeAsync(HttpContext httpContext) } } - private IDisposable? BeginLoggingScope(string? tenantId) + private static IDisposable? BeginLoggingScope( + ILogger logger, + MultitenancyOptions options, + string? tenantId) { - var scopeKey = string.IsNullOrWhiteSpace(_options.LogScopeKey) + var scopeKey = string.IsNullOrWhiteSpace(options.LogScopeKey) ? "tenant_id" - : _options.LogScopeKey; + : options.LogScopeKey; - if (_options.AddTenantToActivity) + if (options.AddTenantToActivity) { var activity = Activity.Current; if (activity is not null) @@ -117,11 +116,11 @@ public async Task InvokeAsync(HttpContext httpContext) } } - if (!_options.AddTenantToLogScope) + if (!options.AddTenantToLogScope) { return null; } - return _logger.BeginScope(new Dictionary { [scopeKey] = tenantId }); + return logger.BeginScope(new Dictionary { [scopeKey] = tenantId }); } } From daaf7fb393401120bfaa9f7e91632caf017e6d59 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Mon, 12 Jan 2026 19:22:04 +0300 Subject: [PATCH 2/2] test: update middleware tests for scoped resolution Aligns TenantResolutionMiddleware tests with the new InvokeAsync signature and adds a pipeline build check with scope validation. --- .../TenantResolutionMiddlewareTests.cs | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantResolutionMiddlewareTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantResolutionMiddlewareTests.cs index f6b45d2..ed17845 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantResolutionMiddlewareTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantResolutionMiddlewareTests.cs @@ -1,12 +1,15 @@ using CleanArchitecture.Extensions.Multitenancy; using CleanArchitecture.Extensions.Multitenancy.Abstractions; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Context; +using CleanArchitecture.Extensions.Multitenancy.AspNetCore; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options; using CleanArchitecture.Extensions.Multitenancy.Behaviors; using CleanArchitecture.Extensions.Multitenancy.Configuration; using CleanArchitecture.Extensions.Multitenancy.Context; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using OptionsFactory = Microsoft.Extensions.Options.Options; @@ -34,8 +37,13 @@ public async Task InvokeAsync_sets_current_tenant_and_http_context_items() return Task.CompletedTask; }; - var middleware = new TenantResolutionMiddleware( - next, + var middleware = new TenantResolutionMiddleware(next); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Tenant-ID"] = "tenant-1"; + + await middleware.InvokeAsync( + httpContext, resolver, accessor, factory, @@ -44,16 +52,31 @@ public async Task InvokeAsync_sets_current_tenant_and_http_context_items() logger, new TenantCorrelationScopeAccessor()); - var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers["X-Tenant-ID"] = "tenant-1"; - - await middleware.InvokeAsync(httpContext); - Assert.Equal("tenant-1", observedTenantId); Assert.NotNull(observedContext); Assert.Null(accessor.TenantId); } + [Fact] + public void UseCleanArchitectureMultitenancy_builds_pipeline_with_validated_scopes() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCleanArchitectureMultitenancyAspNetCore(); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + + var app = new ApplicationBuilder(provider); + app.UseCleanArchitectureMultitenancy(); + app.Run(_ => Task.CompletedTask); + + var exception = Record.Exception(() => app.Build()); + Assert.Null(exception); + } + private sealed class CapturingTenantResolver : ITenantResolver { public TenantResolutionContext? CapturedContext { get; private set; }