diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs
index 7bbc4fe..7c31fee 100644
--- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs
+++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs
@@ -28,11 +28,16 @@ public static class DependencyInjectionExtensions
/// Whether to add the multitenancy middleware automatically. Use only for host/header resolution.
/// Route/claim resolution requires manual ordering after routing/authentication.
///
+ ///
+ /// Whether to add the default exception handler middleware so registered implementations run.
+ /// Helpful when the host overrides exception handling (for example, the template's empty UseExceptionHandler(options => {{ }})).
+ ///
public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore(
this IServiceCollection services,
Action? configureCore = null,
Action? configureAspNetCore = null,
- bool autoUseMiddleware = false)
+ bool autoUseMiddleware = false,
+ bool autoUseExceptionHandler = true)
{
ArgumentNullException.ThrowIfNull(services);
@@ -52,6 +57,10 @@ public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore(
{
services.TryAddEnumerable(ServiceDescriptor.Singleton());
}
+ if (autoUseExceptionHandler)
+ {
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ }
return services;
}
diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs
new file mode 100644
index 0000000..f007720
--- /dev/null
+++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs
@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+
+namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware;
+
+///
+/// Ensures the default exception handler middleware is registered so implementations run.
+///
+internal sealed class ExceptionHandlerStartupFilter : IStartupFilter
+{
+ public Action Configure(Action next)
+ {
+ ArgumentNullException.ThrowIfNull(next);
+
+ return app =>
+ {
+ app.UseExceptionHandler();
+ next(app);
+ };
+ }
+}
diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md
index 9535c4e..6e3a7bb 100644
--- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md
+++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md
@@ -10,7 +10,7 @@ ASP.NET Core adapter for CleanArchitecture.Extensions.Multitenancy. It provides
- Endpoint metadata helpers (`RequireTenant`, `AllowAnonymousTenant`, `WithTenantHeader`, `WithTenantRoute`).
- ProblemDetails mapping for multitenancy exceptions.
- Exception handler integration (`TenantExceptionHandler`) for consistent ProblemDetails responses.
-- Optional startup filter to auto-add the middleware when you cannot modify `Program.cs`.
+- Optional startup filters to auto-add the middleware and the default exception handler when you cannot modify `Program.cs`.
## Install
@@ -25,10 +25,12 @@ dotnet add package CleanArchitecture.Extensions.Multitenancy.AspNetCore
```csharp
using CleanArchitecture.Extensions.Multitenancy.AspNetCore;
-builder.Services.AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: true);
+builder.Services.AddCleanArchitectureMultitenancyAspNetCore(
+ autoUseMiddleware: true,
+ autoUseExceptionHandler: true); // default for the template
```
-Use `autoUseMiddleware` when header or host resolution is enough and you do not depend on route values. For claim- or route-based resolution, disable it and place `app.UseCleanArchitectureMultitenancy()` after authentication or routing. If you want tenant resolution exceptions to flow through your global exception handler, place it after `app.UseExceptionHandler(...)`.
+Use `autoUseMiddleware` when header or host resolution is enough and you do not depend on route values. For claim- or route-based resolution, disable it and place `app.UseCleanArchitectureMultitenancy()` after authentication or routing. `autoUseExceptionHandler` is enabled by default to work around the template's `UseExceptionHandler(options => { })` stub; disable it if you already call `app.UseExceptionHandler()` with the new IExceptionHandler pipeline intact.
### 2) Add the middleware (manual)
@@ -69,6 +71,7 @@ using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options;
using CleanArchitecture.Extensions.Multitenancy.Configuration;
builder.Services.AddCleanArchitectureMultitenancyAspNetCore(
+ autoUseExceptionHandler: true,
coreOptions =>
{
coreOptions.HeaderNames = new[] { "X-Tenant-ID" };
@@ -87,7 +90,7 @@ builder.Services.AddCleanArchitectureMultitenancyAspNetCore(
- Prefer header or host resolution to keep the template unchanged.
- Route-based tenancy requires routing middleware before multitenancy; opt in only if you can adjust the pipeline.
-Example for route-based tenants:
+Example for route-based tenants (note the default exception handler already runs):
```csharp
app.UseRouting();
@@ -115,3 +118,4 @@ if (TenantProblemDetailsMapper.TryCreate(exception, httpContext, out var details
- The middleware stores the resolved `TenantContext` in `HttpContext.Items` by default.
- `GetTenantContext()` respects the configured `AspNetCoreMultitenancyOptions.HttpContextItemKey` when available.
- `AddCleanArchitectureMultitenancyAspNetCore` registers `TenantExceptionHandler` so `UseExceptionHandler` can map multitenancy exceptions to ProblemDetails.
+- Keep `autoUseExceptionHandler` enabled (default) when the host pipeline does not call `UseExceptionHandler()` (or overrides it), so your registered `IExceptionHandler` implementations still run.
diff --git a/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Options/EfCoreMultitenancyOptions.cs b/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Options/EfCoreMultitenancyOptions.cs
index 2128abd..544c549 100644
--- a/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Options/EfCoreMultitenancyOptions.cs
+++ b/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Options/EfCoreMultitenancyOptions.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.Text;
using CleanArchitecture.Extensions.Multitenancy.Abstractions;
namespace CleanArchitecture.Extensions.Multitenancy.EFCore.Options;
@@ -140,12 +141,14 @@ public bool EnableSaveChangesEnforcement
return DefaultSchema;
}
- if (string.IsNullOrWhiteSpace(SchemaNameFormat) || tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId))
+ var normalizedId = NormalizeTenantId(tenant?.TenantId);
+
+ if (string.IsNullOrWhiteSpace(SchemaNameFormat) || normalizedId is null)
{
return DefaultSchema;
}
- return string.Format(CultureInfo.InvariantCulture, SchemaNameFormat, tenant.TenantId);
+ return string.Format(CultureInfo.InvariantCulture, SchemaNameFormat, normalizedId);
}
///
@@ -159,11 +162,33 @@ public bool EnableSaveChangesEnforcement
return ConnectionStringProvider(tenant);
}
- if (string.IsNullOrWhiteSpace(ConnectionStringFormat) || tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId))
+ var normalizedId = NormalizeTenantId(tenant?.TenantId);
+
+ if (string.IsNullOrWhiteSpace(ConnectionStringFormat) || normalizedId is null)
{
return null;
}
- return string.Format(CultureInfo.InvariantCulture, ConnectionStringFormat, tenant.TenantId);
+ return string.Format(CultureInfo.InvariantCulture, ConnectionStringFormat, normalizedId);
+ }
+
+ private static string? NormalizeTenantId(string? tenantId)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return null;
+ }
+
+ var builder = new StringBuilder();
+
+ foreach (var ch in tenantId)
+ {
+ if (char.IsLetterOrDigit(ch) || ch is '-' or '_')
+ {
+ builder.Append(char.ToLowerInvariant(ch));
+ }
+ }
+
+ return builder.Length == 0 ? null : builder.ToString();
}
}
diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs b/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs
index 11b3c1d..760223c 100644
--- a/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs
+++ b/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs
@@ -14,7 +14,7 @@ public sealed class MultitenancyOptions
/// Gets or sets a value indicating whether tenant-less requests are allowed when explicitly marked optional.
/// When false, optional requirements are treated as required.
///
- public bool AllowAnonymous { get; set; }
+ public bool AllowAnonymous { get; set; } = false;
///
/// Gets or sets a fallback tenant used when no tenant is resolved.
diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Context/TenantResolver.cs b/src/CleanArchitecture.Extensions.Multitenancy/Context/TenantResolver.cs
index 6750e53..8f6b2cf 100644
--- a/src/CleanArchitecture.Extensions.Multitenancy/Context/TenantResolver.cs
+++ b/src/CleanArchitecture.Extensions.Multitenancy/Context/TenantResolver.cs
@@ -120,7 +120,8 @@ public TenantResolver(
return null;
}
- return await _tenantCache.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
+ var cached = await _tenantCache.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
+ return cached is null ? null : TenantInfo.From(cached);
}
private async Task ResolveFromStoreAsync(string tenantId, CancellationToken cancellationToken)
@@ -137,11 +138,13 @@ public TenantResolver(
return null;
}
+ var snapshot = TenantInfo.From(tenant);
+
if (_tenantCache is not null)
{
- await _tenantCache.SetAsync(tenant, _options.ResolutionCacheTtl, cancellationToken).ConfigureAwait(false);
+ await _tenantCache.SetAsync(snapshot, _options.ResolutionCacheTtl, cancellationToken).ConfigureAwait(false);
}
- return tenant;
+ return snapshot;
}
}
diff --git a/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs b/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs
index 7c037dd..4e8157d 100644
--- a/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs
+++ b/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs
@@ -5,6 +5,7 @@
using CleanArchitecture.Extensions.Multitenancy.Providers;
using CleanArchitecture.Extensions.Multitenancy.Serialization;
using MediatR;
+using MediatR.Pipeline;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -37,6 +38,7 @@ public static IServiceCollection AddCleanArchitectureMultitenancy(
services.TryAddSingleton(sp => sp.GetRequiredService());
services.TryAddSingleton();
services.TryAddSingleton();
+ EnsureCorrelationPreProcessorPriority(services);
services.TryAddScoped();
services.TryAddScoped();
@@ -77,8 +79,33 @@ public static MediatRServiceConfiguration AddCleanArchitectureMultitenancyCorrel
{
ArgumentNullException.ThrowIfNull(configuration);
- configuration.AddOpenRequestPreProcessor(typeof(TenantCorrelationPreProcessor<>));
- configuration.AddOpenRequestPostProcessor(typeof(TenantCorrelationPostProcessor<,>));
+ // Correlation pre/post processors are registered automatically; this method is kept for backward compatibility.
return configuration;
}
+
+ private static void EnsureCorrelationPreProcessorPriority(IServiceCollection services)
+ {
+ var preProcessorDescriptor = ServiceDescriptor.Transient(typeof(IRequestPreProcessor<>), typeof(TenantCorrelationPreProcessor<>));
+ var postProcessorDescriptor = ServiceDescriptor.Transient(typeof(IRequestPostProcessor<,>), typeof(TenantCorrelationPostProcessor<,>));
+
+ // Remove existing registrations to avoid duplicates and control ordering.
+ RemoveDescriptor(services, typeof(IRequestPreProcessor<>), typeof(TenantCorrelationPreProcessor<>));
+ RemoveDescriptor(services, typeof(IRequestPostProcessor<,>), typeof(TenantCorrelationPostProcessor<,>));
+
+ // Insert the pre-processor at the front so it runs before template LoggingBehaviour.
+ services.Insert(0, preProcessorDescriptor);
+ services.Add(postProcessorDescriptor);
+ }
+
+ private static void RemoveDescriptor(IServiceCollection services, Type serviceType, Type implementationType)
+ {
+ for (var i = services.Count - 1; i >= 0; i--)
+ {
+ var descriptor = services[i];
+ if (descriptor.ServiceType == serviceType && descriptor.ImplementationType == implementationType)
+ {
+ services.RemoveAt(i);
+ }
+ }
+ }
}
diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs
index efbc496..322553d 100644
--- a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs
+++ b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using CleanArchitecture.Extensions.Multitenancy.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
@@ -6,6 +7,20 @@ namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests;
public class DependencyInjectionExtensionsTests
{
+ [Fact]
+ public void AddCleanArchitectureMultitenancyAspNetCore_registers_exception_handler_startup_filter_by_default()
+ {
+ var services = new ServiceCollection();
+
+ services.AddCleanArchitectureMultitenancyAspNetCore();
+
+ using var provider = services.BuildServiceProvider();
+
+ var filters = provider.GetServices().ToList();
+
+ Assert.Contains(filters, filter => filter.GetType().Name == "ExceptionHandlerStartupFilter");
+ }
+
[Fact]
public void AddCleanArchitectureMultitenancyAspNetCore_registers_startup_filter_when_enabled()
{