This guide details the Enterprise-grade Multi-tenancy implementation in the BookStore application with comprehensive performance optimizations, security features, and user experience enhancements.
The application uses Conjoined Tenancy where all tenants share the same database and schema, but data is logically isolated using a tenant_id column.
✅ Data Isolation: Marten-based conjoined multi-tenancy
✅ Event Sourcing: Full multi-tenant event store support
✅ Performance: Redis caching reduces DB queries by ~99%
✅ Security: Audit logging and per-tenant rate limiting
✅ UX: Tenant switcher UI with localStorage persistence
Our implementation follows Marten's official event store multi-tenancy recommendations:
✅ Event Store: TenancyStyle.Conjoined configured
✅ Documents: AllDocumentsAreMultiTenanted() policy applied
✅ Global Documents: [DoNotPartition] for Tenant model (Marten 8.5+)
✅ Sessions: Properly scoped per-tenant
✅ Projections: Automatically tenant-aware
✅ Async Daemon: Processes events per-tenant
// MartenConfigurationExtensions.cs
options.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined;What this means:
- All events are automatically captured with
tenant_id - Event streams are isolated per tenant
- Projections respect tenant boundaries
- Async daemon processes events per-tenant
options.Policies.AllDocumentsAreMultiTenanted();- Every document includes a
tenant_idcolumn - Sessions are tenant-scoped when created with
store.LightweightSession(tenantId) - Queries automatically filter by tenant ID
Exception: Tenant documents use [DoNotPartition] attribute to be globally accessible:
[Marten.Schema.DoNotPartition]
public class Tenant
{
public string Id { get; set; }
public string Name { get; set; }
public bool IsEnabled { get; set; }
}TenantResolutionMiddleware intercepts every HTTP request:
- Extracts
X-Tenant-IDheader - Validates tenant (with Redis caching)
- Sets
ITenantContextfor request scope - Logs tenant access for audit trail
- Returns
400 Bad Requestif invalid
If the header is omitted, the request uses the shared default tenant (*DEFAULT*). The lowercase alias default is also accepted in tests and helper flows.
All caching operations automatically include tenant context. Cache keys are tenant-scoped to prevent data leakage:
// Tenant ID is automatically appended by GetOrCreateLocalizedAsync
var cacheKey = $"book:{id}"; // Becomes: book:123|en-US|acmeNote
For complete caching implementation details including tenant-aware patterns, see the Caching Guide.
Unlike Marten (database) which has built-in support, Azure Blob Storage (and Azurite) uses a flat namespace. We implement logical isolation by prefixing all blobs with the tenant ID.
- Structure:
{tenantId}/{bookId}.{extension} - Example:
acme/book-123.png,contoso/book-456.png
The BlobStorageService accepts a tenantId (resolved from ITenantContext) and prefixes the blob path during upload. This ensures acme data never ends up in default folders.
The Challenge:
When a browser loads an image via an <img> tag, it makes a standard GET request. It does not send custom headers (like X-Tenant-ID) and, in a development environment (localhost), the domain is shared. The API cannot automatically distinguish which tenant the request belongs to.
The Solution: Proxy Endpoint with Explicit Context We serve images via a proxy endpoint that carries the tenant context in the URL:
- URL:
/api/books/{id}/cover?tenantId=acme - Mechanism:
BookCoverHandlerssaves this parameterized URL in the database.- The browser requests this URL.
- The API reads
tenantIdfrom the query string. - The API reconstructs the path (
acme/{bookId}.png) and streams the blob.
This guarantees robust isolation and correct image loading regardless of the domain or network environment (Docker/Host).
CachedTenantStore wraps MartenTenantStore with Redis:
- Cache Duration: 5 minutes
- Impact: ~99% reduction in DB queries
- Invalidation:
InvalidateCacheAsync()on tenant updates
1000 requests/minute per tenant prevents noisy neighbor problem:
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var tenantId = context.Items["TenantId"]?.ToString() ?? "default";
return RateLimitPartition.GetFixedWindowLimiter(tenantId,
new FixedWindowRateLimiterOptions
{
PermitLimit = 1000,
Window = TimeSpan.FromMinutes(1)
});
});For optimal multi-tenancy performance in production, proper database indexing is critical.
Indexes are declared in Marten configuration (ConfigureIndexes) and managed through Marten schema workflows, not hand-written SQL in feature code.
With proper indexing:
- Tenant-filtered queries: < 10ms for up to 1M records per tenant
- Event queries: < 5ms for recent events
For comprehensive indexing strategies, monitoring queries, and maintenance schedules, see Database Indexes Guide.
To ensure system-wide hygiene, background maintenance tasks are configured to be tenant-aware or global as needed:
- Unverified Account Cleanup: The background cleanup job for unverified accounts uses
.AnyTenant()to ensure stale accounts are removed across all tenants in a single pass. - Index Management: Indexes are monitored and rebuilt system-wide to maintain performance for all tenants.
All tenant access is logged:
_logger.LogInformation(
"Tenant {TenantId} accessing {Method} {Path} from {RemoteIp}",
tenantContext.TenantId,
context.Request.Method,
context.Request.Path,
context.Connection.RemoteIpAddress);The TenantSecurityMiddleware enforces strict isolation:
- Authenticated Users: Validates that the
tenant_idclaim in their JWT matches theX-Tenant-IDheader. Prevents cross-tenant token theft. - Anonymous Users: Only allowed to access the Default tenant (
*DEFAULT*) unless the endpoint is explicitly whitelisted.
For public endpoints that should be accessible from any tenant context without authentication (e.g., login, tenant info, health checks), use the AllowAnonymousTenantAttribute.
// Example: Whitelisting a group of endpoints
group.WithMetadata(new AllowAnonymousTenantAttribute());- System Admin: Only the Default tenant administrator (
admin@bookstore.com) can access global management endpoints under/api/admin/tenants. - Tenant Admin: Authenticated administrators for specific tenants (e.g.,
acme) are restricted to their own data and cannot list or manage other tenants. - Default Tenant Constants: In code, always use shared constants (e.g.,
MultiTenancyConstants.DefaultTenantId) for default-tenant checks instead of hardcoded*DEFAULT*/defaultliterals.
- Global: 1000 req/min per tenant
- Auth policy: per
tenantId:ippartition using configuredAuthPermitLimitandAuthWindowSeconds- default config: 20 req / 60s
- development config: 200 req / 60s
- Response:
429 Too Many RequestswithretryAfterseconds
Three-tier priority:
- URL parameter (
?tenant=xxx) - LocalStorage
- Default tenant
<MudMenu Icon="@Icons.Material.Filled.Business">
<MudMenuItem OnClick="@(() => SwitchTenant("acme"))">Acme Corp</MudMenuItem>
</MudMenu>await _localStorage.SetItemAsStringAsync("selected-tenant", tenantId);public class MyService(ITenantContext tenantContext)
{
var tenantId = tenantContext.TenantId;
}// ✅ Correct
var key = $"book:{id}:tenant={tenantContext.TenantId}";
// ❌ Wrong - data leak!
var key = $"book:{id}";If you create a new public endpoint that must be accessible from any tenant (like a shared resource or authentication flow), remember to apply the AllowAnonymousTenantAttribute to the route mapping.
[Test]
public async Task EntitiesAreIsolatedByTenant()
{
var acmeClient = CreateClient("acme");
var contosoClient = CreateClient("contoso");
var book = await acmeClient.PostAsync("/api/admin/books", data);
var response = await contosoClient.GetAsync($"/api/books/{book.Id}");
Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}- Verify all
tenant_idindexes exist - Configure Redis for distributed caching
- Set up audit log aggregation
- Configure rate limits per tenant tier
- Schedule monthly index rebuilds
ConnectionStrings__redis=localhost:6379
RateLimit__PermitLimit=1000
RateLimit__WindowInMinutes=1For index rebuild schedules and vacuum strategies, see Database Indexes Guide - Maintenance.
| Problem | Solution |
|---|---|
| Tenant not displaying | Restart Docker |
404 on /api/tenants/{id} |
Verify seeding, check [DoNotPartition] |
| Slow queries | Check index usage with EXPLAIN ANALYZE |
- Marten Event Store Multi-Tenancy
- Marten Document Multi-Tenancy
- ASP.NET Core Rate Limiting
- Database Indexes Guide - Comprehensive indexing strategies for production