Multi-Tenancy
The problem
Section titled “The problem”SaaS applications serve multiple organizations from a single deployment.
Without framework support, every repository method ends up with
WHERE TenantId = @tenantId scattered across the codebase. Miss one filter
and you leak data between tenants — a GDPR incident waiting to happen.
Granit solves this with transparent tenant isolation: the framework resolves the current tenant, stores it in an async-safe context, and automatically appends query filters to every EF Core query. Application code never writes a tenant WHERE clause.
Tenant resolution
Section titled “Tenant resolution”The middleware pipeline resolves the tenant identity on every request. Two built-in resolvers run in priority order — first match wins:
| Order | Resolver | Source | Use case |
|---|---|---|---|
| 100 | HeaderTenantResolver | X-Tenant-Id header | Service-to-service calls, API gateways |
| 200 | JwtClaimTenantResolver | tenant_id JWT claim | End-user requests via Keycloak |
Custom resolvers
Section titled “Custom resolvers”Implement ITenantResolver to add your own resolution logic (e.g., subdomain-based):
public class SubdomainTenantResolver : ITenantResolver{ public int Order => 50; // Runs before header and JWT resolvers
public Task<Guid?> ResolveAsync(HttpContext context, CancellationToken cancellationToken) { var host = context.Request.Host.Host; var subdomain = host.Split('.')[0];
// Look up tenant ID from subdomain — your logic here return Task.FromResult<Guid?>(null); }}Register it in your module:
services.AddTransient<ITenantResolver, SubdomainTenantResolver>();Async-safe tenant context
Section titled “Async-safe tenant context”ICurrentTenant is backed by AsyncLocal<T>. This means:
- The tenant context propagates through
async/awaitcalls automatically. - Parallel tasks (
Task.WhenAll,Parallel.ForEachAsync) each get their own isolated copy — no cross-contamination. - Background jobs must explicitly set the tenant context before executing tenant-scoped work.
public class InvoiceService(ICurrentTenant currentTenant){ public void PrintCurrentTenant() { if (!currentTenant.IsAvailable) { // No tenant context — running in a host-level scope return; }
var tenantId = currentTenant.Id; // Guid }}Isolation strategies
Section titled “Isolation strategies”Granit supports three isolation strategies. You choose per deployment — or mix them dynamically for different tenant tiers.
1. SharedDatabase
Section titled “1. SharedDatabase”All tenants share one database. Isolation is purely logical via
WHERE TenantId = @id query filters.
- Simplest to operate and migrate.
- Scales to 1 000+ tenants without connection pool pressure.
- Single backup/restore covers all tenants.
- Not suitable when regulations require physical data separation.
2. SchemaPerTenant
Section titled “2. SchemaPerTenant”Each tenant gets its own PostgreSQL schema within a shared database. Tables are identical, but isolated at the schema level.
- Practical limit: ~1 000 tenants (PostgreSQL catalog overhead).
- Per-tenant backup possible via
pg_dump --schema. - Migrations must run per schema — Granit handles this automatically.
3. DatabasePerTenant
Section titled “3. DatabasePerTenant”Each tenant gets a dedicated database. Full physical separation.
- Required for ISO 27001 when the client demands physical isolation.
- Practical limit: ~200 tenants with PgBouncer connection pooling.
- Independent backup, restore, and retention policies per tenant.
- Highest operational cost.
Decision matrix
Section titled “Decision matrix”flowchart TD
A[New tenant onboarding] --> B{"ISO 27001 physical<br/>separation required?"}
B -- Yes --> C[DatabasePerTenant]
B -- No --> D{"More than 1000<br/>tenants expected?"}
D -- Yes --> E[SharedDatabase]
D -- No --> F[SchemaPerTenant]
Dynamic strategy
Section titled “Dynamic strategy”Premium and standard tenants can coexist in the same deployment. Configure the strategy per tenant in the tenant registry:
public class Tenant{ public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public TenantIsolationStrategy Strategy { get; set; } public string? ConnectionString { get; set; } // For DatabasePerTenant public string? Schema { get; set; } // For SchemaPerTenant}The TenantConnectionStringResolver reads the strategy and returns the
appropriate connection string (or schema) at runtime:
internal sealed class TenantConnectionStringResolver( ITenantStore tenantStore) : ITenantConnectionStringResolver{ public async Task<TenantConnectionInfo?> ResolveAsync( Guid tenantId, CancellationToken cancellationToken) { Tenant? tenant = await tenantStore .FindByIdAsync(tenantId, cancellationToken) .ConfigureAwait(false);
if (tenant is null) return null;
return tenant.Strategy switch { TenantIsolationStrategy.DatabasePerTenant => new TenantConnectionInfo( Strategy: TenantIsolationStrategy.DatabasePerTenant, ConnectionString: tenant.ConnectionString!),
TenantIsolationStrategy.SchemaPerTenant => new TenantConnectionInfo( Strategy: TenantIsolationStrategy.SchemaPerTenant, Schema: tenant.Schema!),
_ => new TenantConnectionInfo( Strategy: TenantIsolationStrategy.SharedDatabase, ConnectionString: null) // Uses the default connection }; }}Register it in your module:
services.AddSingleton<ITenantConnectionStringResolver, TenantConnectionStringResolver>();Transparent query filters
Section titled “Transparent query filters”When you call modelBuilder.ApplyGranitConventions(currentTenant, dataFilter)
in your DbContext.OnModelCreating, the framework automatically registers
a HasQueryFilter for every entity implementing IMultiTenant:
-- Generated by EF Core query filter, not written by handWHERE "t"."TenantId" = @__currentTenant_IdThis filter combines with other Granit filters (ISoftDeletable, IActive,
IPublishable, IProcessingRestrictable) into a single HasQueryFilter
expression per entity. You never write manual HasQueryFilter calls —
ApplyGranitConventions handles all of them centrally.
Implementing a multi-tenant entity
Section titled “Implementing a multi-tenant entity”Entities opt into tenant isolation by implementing IMultiTenant:
using Granit.Core.Domain;using Granit.Core.MultiTenancy;
public class Invoice : AggregateRoot, IMultiTenant{ public Guid? TenantId { get; set; } public string Number { get; set; } = string.Empty; public decimal Amount { get; set; } public DateTimeOffset IssuedAt { get; set; }}Soft dependency rule
Section titled “Soft dependency rule”Granit modules that need to read ICurrentTenant should reference
Granit.Core.MultiTenancy — not Granit.MultiTenancy. There is no need
to add [DependsOn(typeof(GranitMultiTenancyModule))] or a
<ProjectReference> to the Granit.MultiTenancy package.
A NullTenantContext is registered by default in every Granit application.
It returns IsAvailable = false and acts as the null object when
multi-tenancy is not installed.
Hard dependency on Granit.MultiTenancy is allowed only when a module
must enforce strict tenant isolation (e.g., Granit.BlobStorage throws if
no tenant context exists — required for GDPR data segregation).
// Correct — soft dependency via Granit.Coreusing Granit.Core.MultiTenancy;
public class ReportService(ICurrentTenant currentTenant){ public async Task<Report> GenerateAsync(CancellationToken cancellationToken) { if (currentTenant.IsAvailable) { // Tenant-scoped logic }
// Host-level fallback // ... }}Further reading
Section titled “Further reading”- Multi-Tenancy reference — configuration, API surface, and extension methods
- Persistence concept — how query filters and interceptors integrate with tenant isolation