Skip to content

Multi-Tenancy

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.

The middleware pipeline resolves the tenant identity on every request. Two built-in resolvers run in priority order — first match wins:

OrderResolverSourceUse case
100HeaderTenantResolverX-Tenant-Id headerService-to-service calls, API gateways
200JwtClaimTenantResolvertenant_id JWT claimEnd-user requests via Keycloak

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>();

ICurrentTenant is backed by AsyncLocal<T>. This means:

  • The tenant context propagates through async/await calls 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
}
}

Granit supports three isolation strategies. You choose per deployment — or mix them dynamically for different tenant tiers.

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.

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.

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.
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]

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.

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 hand
WHERE "t"."TenantId" = @__currentTenant_Id

This 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.

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; }
}

Granit modules that need to read ICurrentTenant should reference Granit.Core.MultiTenancynot 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.Core
using Granit.Core.MultiTenancy;
public class ReportService(ICurrentTenant currentTenant)
{
public async Task<Report> GenerateAsync(CancellationToken cancellationToken)
{
if (currentTenant.IsAvailable)
{
// Tenant-scoped logic
}
// Host-level fallback
// ...
}
}