Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </param>
/// <param name="autoUseExceptionHandler">
/// Whether to add the default exception handler middleware so registered <see cref="IExceptionHandler"/> implementations run.
/// Helpful when the host overrides exception handling (for example, the template's empty <c>UseExceptionHandler(options =&gt; {{ }})</c>).
/// </param>
public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore(
this IServiceCollection services,
Action<MultitenancyOptions>? configureCore = null,
Action<AspNetCoreMultitenancyOptions>? configureAspNetCore = null,
bool autoUseMiddleware = false)
bool autoUseMiddleware = false,
bool autoUseExceptionHandler = true)
{
ArgumentNullException.ThrowIfNull(services);

Expand All @@ -52,6 +57,10 @@ public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore(
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter, TenantResolutionStartupFilter>());
}
if (autoUseExceptionHandler)
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter, ExceptionHandlerStartupFilter>());
}

return services;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;

namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware;

/// <summary>
/// Ensures the default exception handler middleware is registered so <see cref="IExceptionHandler"/> implementations run.

Check warning on line 7 in src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

XML comment has cref attribute 'IExceptionHandler' that could not be resolved

Check warning on line 7 in src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

XML comment has cref attribute 'IExceptionHandler' that could not be resolved

Check warning on line 7 in src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs

View workflow job for this annotation

GitHub Actions / build-test-pack

XML comment has cref attribute 'IExceptionHandler' that could not be resolved

Check warning on line 7 in src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs

View workflow job for this annotation

GitHub Actions / build-test-pack

XML comment has cref attribute 'IExceptionHandler' that could not be resolved
/// </summary>
internal sealed class ExceptionHandlerStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
ArgumentNullException.ThrowIfNull(next);

return app =>
{
app.UseExceptionHandler();
next(app);
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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" };
Expand All @@ -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();
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Text;
using CleanArchitecture.Extensions.Multitenancy.Abstractions;

namespace CleanArchitecture.Extensions.Multitenancy.EFCore.Options;
Expand Down Expand Up @@ -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);
}

/// <summary>
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public bool AllowAnonymous { get; set; }
public bool AllowAnonymous { get; set; } = false;

/// <summary>
/// Gets or sets a fallback tenant used when no tenant is resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ITenantInfo?> ResolveFromStoreAsync(string tenantId, CancellationToken cancellationToken)
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -37,6 +38,7 @@ public static IServiceCollection AddCleanArchitectureMultitenancy(
services.TryAddSingleton<ICurrentTenant>(sp => sp.GetRequiredService<CurrentTenantAccessor>());
services.TryAddSingleton<ITenantContextSerializer, SystemTextJsonTenantContextSerializer>();
services.TryAddSingleton<ITenantCorrelationScopeAccessor, TenantCorrelationScopeAccessor>();
EnsureCorrelationPreProcessorPriority(services);

services.TryAddScoped<ITenantResolutionStrategy, CompositeTenantResolutionStrategy>();
services.TryAddScoped<ITenantResolver, TenantResolver>();
Expand Down Expand Up @@ -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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Linq;
using CleanArchitecture.Extensions.Multitenancy.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -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<IStartupFilter>().ToList();

Assert.Contains(filters, filter => filter.GetType().Name == "ExceptionHandlerStartupFilter");
}

[Fact]
public void AddCleanArchitectureMultitenancyAspNetCore_registers_startup_filter_when_enabled()
{
Expand Down
Loading