Appearance
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?
| Benefit | Detail |
|---|---|
| Hard data isolation | No SQL joins or queries can accidentally cross tenant boundaries |
| Compliance | Simplifies GDPR, data residency, and audit requirements per tenant |
| Independent backups | Each tenant's data can be backed up and restored independently |
| Performance | Tenant 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:
- A new SQL Server database is created (
{tenant_slug}_db). - EF Core migrations are applied to the new database.
- The connection string is stored in Azure Key Vault as
TenantConnections--{tenantId}. - A Keycloak client is created in the
microtecrealm for the tenant. - The
IAllTenantsProvidercache 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
| Environment | Domain Pattern |
|---|---|
| Dev | {slug}.microtec-test.com |
| Stage | {slug}.microtecstage.com |
| UAT | {slug}.microtec-uat.com |
| Production | {slug}.onlinemicrotec.com.sa |