Skip to content

Multi-Tenancy

Microtec ERP uses a database-per-tenant multi-tenancy strategy. Every customer (tenant) receives a fully isolated SQL Server database. This page explains how tenant context is resolved at runtime, how EF Core query filters enforce isolation, and the interfaces involved.


Strategy: Database-per-Tenant

Why Database-per-Tenant?

BenefitDetail
Hard data isolationNo SQL joins or queries can accidentally cross tenant boundaries
ComplianceSimplifies GDPR, data residency, and audit requirements per tenant
Independent backupsEach tenant's data can be backed up and restored independently
PerformanceTenant databases can be placed on different SQL instances based on tier

Operational Overhead

Database-per-tenant increases the number of databases significantly. For a 1,000-tenant deployment, there are 1,000+ databases. Microtec manages this via a shared SQL Server VM (non-prod) and per-tenant connection strings stored encrypted in Azure Key Vault.


Request Lifecycle


Core Interfaces

ITenantProvider

ITenantProvider is the read-only interface for consuming the current tenant's context. It is injected wherever a service needs the current tenant ID or connection string.

csharp
/// <summary>
/// Provides the current tenant's identity and connection details.
/// Resolved from the incoming HTTP request or ambient background job context.
/// </summary>
public interface ITenantProvider
{
    /// <summary>The unique identifier for the current tenant.</summary>
    Guid TenantId { get; }

    /// <summary>The tenant's database connection string.</summary>
    string ConnectionString { get; }

    /// <summary>The tenant's subdomain / slug (e.g. "company-a").</summary>
    string TenantSlug { get; }

    /// <summary>True if a tenant context has been established.</summary>
    bool HasTenant { get; }
}

ITenantContextManager

ITenantContextManager is the write interface, used by middleware and background jobs to set the active tenant before the request/job body executes.

csharp
/// <summary>
/// Allows setting the active tenant context explicitly.
/// Used by TenantResolutionMiddleware and background job processors.
/// </summary>
public interface ITenantContextManager
{
    /// <summary>Sets the current tenant by slug. Resolves connection string from store.</summary>
    Task SetTenantAsync(string tenantSlug, CancellationToken ct = default);

    /// <summary>Sets the current tenant by ID directly (used in background jobs).</summary>
    Task SetTenantByIdAsync(Guid tenantId, CancellationToken ct = default);

    /// <summary>Clears the current tenant context.</summary>
    void ClearTenant();
}

EF Core Integration

IUnitOfWork<TContext>

Each service has its own DbContext (e.g., AccountingDbContext). The unit of work pattern wraps the context and provides a typed repository accessor.

csharp
public interface IUnitOfWork<TContext> : IDisposable
    where TContext : DbContext
{
    IRepository<TEntity> Repository<TEntity>()
        where TEntity : class;

    IReadRepository<TEntity> ReadRepository<TEntity>()
        where TEntity : class;

    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

IMultiTenantEntity

Any entity that carries tenant-scoped data must implement this interface:

csharp
public interface IMultiTenantEntity
{
    Guid TenantId { get; }
}

Automatic Query Filters

The base TenantDbContext (from Microtec.Persistence) registers a global query filter for all entities implementing IMultiTenantEntity:

csharp
// In TenantDbContext.OnModelCreating(ModelBuilder modelBuilder)

foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    if (typeof(IMultiTenantEntity).IsAssignableFrom(entityType.ClrType))
    {
        var method = typeof(TenantDbContext)
            .GetMethod(nameof(SetGlobalQuery), BindingFlags.NonPublic | BindingFlags.Instance)!
            .MakeGenericMethod(entityType.ClrType);

        method.Invoke(this, [modelBuilder]);
    }
}

private void SetGlobalQuery<TEntity>(ModelBuilder modelBuilder)
    where TEntity : class, IMultiTenantEntity
{
    modelBuilder
        .Entity<TEntity>()
        .HasQueryFilter(e => e.TenantId == _tenantProvider.TenantId);
}

Never Bypass Query Filters in Production Code

context.Set<Invoice>().IgnoreQueryFilters() exists but must never be used in production code paths. It is permitted only in admin/super-admin background jobs and migrations, with explicit code review approval.

Dynamic Connection String Selection

The DbContext receives its connection string at construction time via the ITenantProvider:

csharp
public class AccountingDbContext : TenantDbContext
{
    public AccountingDbContext(
        DbContextOptions<AccountingDbContext> options,
        ITenantProvider tenantProvider)
        : base(options, tenantProvider) { }

    public DbSet<Invoice> Invoices => Set<Invoice>();
    public DbSet<Customer> Customers => Set<Customer>();
    // ...
}

The IUnitOfWork<AccountingDbContext> implementation creates the DbContext with the correct connection string from ITenantProvider.ConnectionString on every request scope.


Tenant Resolution Middleware

csharp
public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;

    public TenantResolutionMiddleware(RequestDelegate next)
        => _next = next;

    public async Task InvokeAsync(
        HttpContext context,
        ITenantContextManager tenantManager)
    {
        var host = context.Request.Host.Host; // e.g. "company-a.onlinemicrotec.com.sa"
        var slug = ExtractSlug(host);         // e.g. "company-a"

        if (!string.IsNullOrEmpty(slug))
        {
            await tenantManager.SetTenantAsync(slug);
        }

        await _next(context);
    }

    private static string? ExtractSlug(string host)
    {
        var parts = host.Split('.');
        return parts.Length >= 3 ? parts[0] : null;
    }
}

Registration order matters — the tenant middleware must be registered before UseAuthentication() and UseAuthorization():

csharp
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseAuthentication();
app.UseAuthorization();

Background Jobs (Hangfire)

Background jobs run outside an HTTP request context. They must explicitly set the tenant context before performing any tenant-scoped operations.

csharp
public class InvoiceReminderJob : IJob, IScopedService
{
    private readonly ITenantContextManager _tenantManager;
    private readonly IUnitOfWork<AccountingDbContext> _uow;
    private readonly IAllTenantsProvider _allTenants;

    public async Task ExecuteAsync(CancellationToken ct)
    {
        var tenants = await _allTenants.GetAllActiveTenantsAsync(ct);

        foreach (var tenant in tenants)
        {
            await _tenantManager.SetTenantByIdAsync(tenant.Id, ct);
            await ProcessTenantAsync(ct);
        }
    }

    private async Task ProcessTenantAsync(CancellationToken ct)
    {
        var overdueInvoices = await _uow
            .ReadRepository<Invoice>()
            .AsNoTracking()
            .Where(i => i.DueDate < DateTime.UtcNow && !i.IsPaid)
            .ToListAsync(ct);

        // send reminders...
    }
}

Tenant Provisioning

When a new tenant is onboarded via the BusinessOwner portal:

  1. A new SQL Server database is created ({tenant_slug}_db).
  2. EF Core migrations are applied to the new database.
  3. The connection string is stored in Azure Key Vault as TenantConnections--{tenantId}.
  4. A Keycloak client is created in the microtec realm for the tenant.
  5. The IAllTenantsProvider cache is invalidated to include the new tenant.

Provisioning Automation

Tenant provisioning is handled by the BusinessOwners microservice and triggered via the BusinessOwner portal. The provisioning is idempotent — re-running it for an existing tenant is safe.


Environment Domains per Tenant

EnvironmentDomain Pattern
Dev{slug}.microtec-test.com
Stage{slug}.microtecstage.com
UAT{slug}.microtec-uat.com
Production{slug}.onlinemicrotec.com.sa

Internal Documentation — Microtec Platform Team