title: "Building Multi-Tenant ERP Systems in .NET Core"
date: 2026-04-18
readingTime: 6 min read
tags: [".NET", "ERP", "Architecture", "SaaS"]
After architecting ERP systems serving multiple companies across the Middle East, I've learned that multi-tenancy isn't just a technical challenge—it's a business requirement.
This post covers the patterns we use for tenant isolation, data partitioning, and compliance in .NET Core ERP systems.
A multi-tenant system serves multiple customers (tenants) from a single application instance while keeping their data logically or physically separated.
For ERP systems, this is critical because:
Each tenant gets their own database.
public class TenantDbContext : DbContext
{
private readonly string _tenantId;
private readonly string _connectionString;
public TenantDbContext(string tenantId, string baseConnectionString)
{
_tenantId = tenantId;
_connectionString = $"{baseConnectionString};Database=Tenant_{tenantId}";
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(_connectionString);
}
}
Pros:
Cons:
Best for: Enterprise tenants, regulated industries, custom deployments
Each tenant gets their own schema within a shared database.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply tenant schema
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
var tableName = entity.GetTableName();
if (tableName.StartsWith("Tenant")) return;
entity.SetTableName($"Tenant_{_tenantId}.{tableName}");
}
}
Pros:
Cons:
Best for: Mid-market SaaS, moderate customization needs
All tenants share tables; a TenantId column separates data.
public interface ITenantEntity
{
string TenantId { get; set; }
}
public class Employee : ITenantEntity
{
public int Id { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
// ...
}
// Global query filter
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes()
.Where(t => typeof(ITenantEntity).IsAssignableFrom(t.ClrType)))
{
modelBuilder.Entity(entityType.ClrType)
.HasQueryFilter(CreateTenantFilter(entityType.ClrType));
}
}
private LambdaExpression CreateTenantFilter(Type entityType)
{
var parameter = Expression.Parameter(entityType, "e");
var property = Expression.Property(parameter, nameof(ITenantEntity.TenantId));
var constant = Expression.Constant(_tenantId);
var equality = Expression.Equal(property, constant);
return Expression.Lambda(equality, parameter);
}
Pros:
Cons:
Best for: SMB SaaS, cost-sensitive deployments, analytics-heavy systems
For our ERP, we use a hybrid model:
┌─────────────────────────────────────────────────┐
│ Application Layer │
│ (Tenant Resolution Middleware) │
└─────────────────────────────────────────────────┘
│
┌────────────┼────────────┐
│ │ │
↓ ↓ ↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Enterprise │ │ Standard │ │ Basic │
│ Tenants │ │ Tenants │ │ Tenants │
│ │ │ │ │ │
│ DB-per- │ │ Schema-per- │ │ Discriminator│
│ Tenant │ │ Tenant │ │ Column │
└─────────────┘ └─────────────┘ └─────────────┘
How do we know which tenant is making the request?
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context, ITenantService tenantService)
{
var host = context.Request.Host.Host;
var tenantId = host.Split('.').First(); // acme.app.com → "acme"
var tenant = await tenantService.GetTenantAsync(tenantId);
context.Items["Tenant"] = tenant;
await _next(context);
}
}
var tenantId = context.Request.Headers["X-Tenant-ID"].FirstOrDefault();
var tenantId = User.FindFirst("tenant_id")?.Value;
We use subdomain + JWT claim for defense in depth.
public class TenantConnectionFactory
{
private readonly Dictionary<string, string> _tenantConnections;
private readonly string _defaultConnectionString;
public string GetConnectionString(string tenantId, TenantTier tier)
{
return tier switch
{
TenantTier.Enterprise => GetEnterpriseConnection(tenantId),
TenantTier.Standard => GetStandardConnection(tenantId),
TenantTier.Basic => _defaultConnectionString,
_ => throw new ArgumentException("Unknown tier")
};
}
private string GetEnterpriseConnection(string tenantId)
{
var builder = new SqlConnectionStringBuilder(_defaultConnectionString)
{
InitialCatalog = $"ERP_{tenantId}"
};
return builder.ConnectionString;
}
}
Sometimes you need to query across tenants (admin dashboards, analytics).
public class CrossTenantQueryService
{
private readonly TenantRegistry _registry;
public async Task<TenantAnalytics> GetGlobalAnalyticsAsync()
{
var results = new List<TenantMetrics>();
foreach (var tenant in await _registry.GetAllTenantsAsync())
{
using var scope = _tenantScope.CreateScope(tenant.Id);
var metrics = await _metricsService.GetCurrentMetricsAsync();
results.Add(new TenantMetrics { TenantId = tenant.Id, Metrics = metrics });
}
return AggregateResults(results);
}
}
Some countries require data to stay within borders:
public class DataResidencyService
{
public string GetDatabaseLocation(string tenantId, string country)
{
return country switch
{
"BH" => "sql-bahrain.internal",
"AE" => "sql-dubai.internal",
"SA" => "sql-riyadh.internal",
_ => "sql-default.internal"
};
}
}
Every tenant action must be auditable:
public class AuditInterceptor : DbCommandInterceptor
{
private readonly IAuditLogService _auditService;
private readonly string _tenantId;
public override async ValueTask<InterceptionResult<int>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
CancellationToken cancellationToken = default)
{
await _auditService.LogAsync(
_tenantId,
command.CommandText,
command.Parameters.ToString());
return await base.ReaderExecutingAsync(command, eventData, cancellationToken);
}
}
// Enable connection pooling in connection string
"Pooling=true;Min Pool Size=10;Max Pool Size=100;"
// Always filter by tenant first
var employees = context.Employees
.Where(e => e.TenantId == _tenantId) // Filter first
.Where(e => e.Department == "IT") // Then other filters
.ToList();
public class TenantCache
{
private readonly IMemoryCache _cache;
public string GetCacheKey(string tenantId, string key)
=> $"tenant:{tenantId}:{key}";
public T Get<T>(string tenantId, string key)
{
var cacheKey = GetCacheKey(tenantId, key);
return _cache.Get<T>(cacheKey);
}
}
public class MultiTenantTestBase
{
protected TenantScope CreateTenantScope(string tenantId)
{
return new TenantScope(tenantId);
}
}
public class PayrollTests : MultiTenantTestBase
{
[Fact]
public void Payroll_CalculatesCorrectly_ForTenantA()
{
using var scope = CreateTenantScope("tenant-a");
// Test tenant A's payroll
}
[Fact]
public void Payroll_CalculatesCorrectly_ForTenantB()
{
using var scope = CreateTenantScope("tenant-b");
// Test tenant B's payroll
}
[Fact]
public void Payroll_Data_Is_Isolated_Between_Tenants()
{
using (var scopeA = CreateTenantScope("tenant-a"))
{
// Create employee in tenant A
}
using (var scopeB = CreateTenantScope("tenant-b"))
{
// Verify employee not visible in tenant B
}
}
}
Building multi-tenant systems? I'd love to hear your approach. Find me on LinkedIn or GitHub.