Namespace Resolution in ASP.NET Core
The ASP.NET Core client provides flexible namespace resolution specifically designed for multi-tenant web applications. Chronicle includes built-in resolvers for common scenarios, or you can implement custom resolvers to match your application's needs.
Built-in Namespace Resolvers
HTTP Header Resolution (Default)
The default namespace resolver in ASP.NET Core applications reads the tenant identifier from an HTTP header. This is a common pattern in multi-tenant SaaS applications where the tenant is identified in each request.
The built-in HttpHeaderEventStoreNamespaceResolver reads the namespace from a configurable HTTP header:
builder.Services.Configure<ChronicleAspNetCoreOptions>(options =>
{
options.EventStore = "my-event-store";
options.NamespaceHttpHeader = "x-cratis-tenant-id"; // Default value
});
// Or use the extension method
builder.Services.Configure<ChronicleAspNetCoreOptions>(options =>
options.EventStore = "my-event-store"
.WithHttpHeaderNamespaceResolver("x-cratis-tenant-id"));
When a request includes the configured header, that value is used as the namespace:
GET /api/orders HTTP/1.1
Host: api.example.com
x-cratis-tenant-id: customer-123
In this example, all Chronicle operations within this request will use the customer-123 namespace.
If the header is not present, the default namespace is used.
Subdomain-Based Resolution
The built-in SubdomainNamespaceResolver extracts the namespace from the subdomain of the HTTP request host. This is ideal for applications using subdomains for tenant identification (e.g., tenant1.example.com).
builder.Services.Configure<ChronicleAspNetCoreOptions>(options =>
options.EventStore = "my-event-store"
.WithSubdomainNamespaceResolver());
With this configuration:
- A request to
customer123.example.comuses namespacecustomer123 - A request to
example.comuses the default namespace - A request to
www.example.comuses the default namespace
Configuring Custom Resolvers
For scenarios not covered by the built-in resolvers, you can configure custom namespace resolvers in two ways: by type or by instance.
Type-Based Configuration
Configure a custom resolver type that will be instantiated with dependency injection:
builder.Services.Configure<ChronicleAspNetCoreOptions>(options =>
{
options.EventStore = "my-event-store";
options.EventStoreNamespaceResolverType = typeof(CustomNamespaceResolver);
});
The resolver type must implement IEventStoreNamespaceResolver and can have dependencies injected through the constructor.
Instance-Based Configuration
For more control, you can provide a pre-configured instance:
builder.Services.Configure<ChronicleAspNetCoreOptions>(options =>
{
options.EventStore = "my-event-store";
options.EventStoreNamespaceResolver = new CustomNamespaceResolver(someConfiguration);
});
Note: Instance configuration takes precedence over type configuration, unless the instance is a DefaultEventStoreNamespaceResolver, in which case the type configuration is used.
Custom Scenarios
Route-Based Resolution
Extract tenant from URL path:
public class RouteBasedNamespaceResolver : IEventStoreNamespaceResolver
{
readonly IHttpContextAccessor _httpContextAccessor;
public RouteBasedNamespaceResolver(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public EventStoreNamespaceName Resolve()
{
var context = _httpContextAccessor.HttpContext;
if (context?.Request.RouteValues.TryGetValue("tenantId", out var tenantId) == true)
{
return tenantId?.ToString() ?? EventStoreNamespaceName.Default;
}
return EventStoreNamespaceName.Default;
}
}
This resolver works with routes like:
app.MapGet("/api/{tenantId}/orders", (string tenantId) => { ... });
Combined Strategy
Implement a fallback strategy that tries multiple resolution methods:
public class MultiStrategyNamespaceResolver : IEventStoreNamespaceResolver
{
readonly IHttpContextAccessor _httpContextAccessor;
public MultiStrategyNamespaceResolver(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public EventStoreNamespaceName Resolve()
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
return EventStoreNamespaceName.Default;
}
// Try header first
if (context.Request.Headers.TryGetValue("x-tenant-id", out var headerValue))
{
return headerValue.ToString();
}
// Try claims second
var claim = context.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrEmpty(claim))
{
return claim;
}
// Try route value
if (context.Request.RouteValues.TryGetValue("tenantId", out var routeValue))
{
return routeValue?.ToString() ?? EventStoreNamespaceName.Default;
}
return EventStoreNamespaceName.Default;
}
}
Configuration Priority
The ASP.NET Core client follows this priority order when determining which resolver to use:
- Instance Configuration: If
EventStoreNamespaceResolveris set to a non-default instance, it's used - Type Configuration: If no instance is configured (or it's set to
DefaultEventStoreNamespaceResolver), the type specified inEventStoreNamespaceResolverTypeis instantiated - Default: If neither is configured,
HttpHeaderEventStoreNamespaceResolveris used
Best Practices
- Validation: Always validate tenant identifiers to ensure they're valid and the user has access
- Security: Verify that users can only access their own tenant's data
- Logging: Log namespace resolution for debugging and auditing purposes
- Performance: Keep resolution logic fast to avoid impacting request latency
- Defaults: Always provide a sensible default namespace for when resolution fails
- Testing: Write unit tests for your custom resolvers to ensure they handle edge cases
Thread Safety
Namespace resolvers in ASP.NET Core should be registered as scoped services (default behavior). The IHttpContextAccessor is thread-safe and provides the correct context for the current request.
Example: Complete Setup
Here's a complete example showing how to set up Chronicle with different built-in resolvers:
Using HTTP Header Resolution (Default)
using Cratis.Chronicle;
using Cratis.Chronicle.EventSequences;
using Microsoft.AspNetCore.Builder;
var builder = WebApplication.CreateBuilder(args);
builder.AddCratisChronicle(options =>
{
options.EventStore = "production-store";
// HTTP header resolution is the default, but you can configure it explicitly
options.WithHttpHeaderNamespaceResolver("x-tenant-id");
});
var app = builder.Build();
app.MapPost("/api/cart/{cartId}/items", async (string cartId, IEventLog eventLog) =>
{
var itemAdded = new ItemAddedToCart(ProductId: "product-123", Quantity: 1);
await eventLog.Append(cartId, itemAdded);
return Results.Ok();
});
app.Run();
Using Subdomain Resolution
using Cratis.Chronicle;
using Cratis.Chronicle.EventSequences;
using Microsoft.AspNetCore.Builder;
var builder = WebApplication.CreateBuilder(args);
builder.AddCratisChronicle(options =>
{
options.EventStore = "production-store";
options.WithSubdomainNamespaceResolver();
});
var app = builder.Build();
app.MapPost("/api/cart/{cartId}/items", async (string cartId, IEventLog eventLog) =>
{
var itemAdded = new ItemAddedToCart(ProductId: "product-123", Quantity: 1);
await eventLog.Append(cartId, itemAdded);
return Results.Ok();
});
app.Run();
record ItemAddedToCart(string ProductId, int Quantity);