Multi-Tenancy on .NET 10 — Data Isolation Strategies, Row-Level Security, and Caching for Production SaaS 2026
Posted on: 4/17/2026 12:11:52 PM
Table of contents
- 1. What is Multi-Tenancy and why does it matter?
- 2. The Three Data Isolation Strategies
- 3. A Detailed Comparison of the Three Strategies
- 4. Tenant Resolution — Identifying the Tenant from the Request
- 5. Row-Level Security (RLS) — Database-level Protection
- 6. Caching Strategy for a Multi-Tenant System
- 7. Hybrid Strategy — Mixing by Customer Tier
- 8. Database Migrations in Multi-Tenant
- 9. Monitoring & Observability Per-Tenant
- 10. Testing Tenant Isolation
- 11. Production Checklist for Multi-Tenant SaaS
- 12. Conclusion
When building a SaaS (Software as a Service) product, one of the most critical architectural decisions is multi-tenancy — how you let many organizations (tenants) share the same application while guaranteeing data isolation, security, and performance for each tenant. This choice ripples through the database schema, caching, security, deployment, and operational cost. This article analyzes multi-tenancy strategies on .NET 10 in 2026 — from the data model to Row-Level Security, multi-layer caching, and per-tenant observability.
1. What is Multi-Tenancy and why does it matter?
In a multi-tenant architecture, a single instance of the application serves many customers (tenants). Each tenant has isolated data but runs on the same codebase and infrastructure. This is the foundation of every SaaS product — from Slack and Notion to Shopify.
graph LR
T1["Tenant A
(Startup)"] --> APP["SaaS Application
.NET 10"]
T2["Tenant B
(Enterprise)"] --> APP
T3["Tenant C
(SMB)"] --> APP
APP --> CACHE["Redis Cache
(Per-Tenant Keys)"]
APP --> DB["Database Layer"]
DB --> DB1["Strategy 1:
Shared DB"]
DB --> DB2["Strategy 2:
Schema-per-Tenant"]
DB --> DB3["Strategy 3:
DB-per-Tenant"]
style APP fill:#e94560,stroke:#fff,color:#fff
style CACHE fill:#2c3e50,stroke:#e94560,color:#fff
style DB fill:#16213e,stroke:#e94560,color:#fff
style T1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style T2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style T3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Figure 1: Multi-Tenancy overview — one app serving many tenants with different data-isolation strategies
2. The Three Data Isolation Strategies
Each strategy trades off isolation, cost, and operational complexity. There's no universal "best practice" — pick based on compliance needs, tenant count, and budget.
2.1. Shared Database, Shared Schema (Discriminator Column)
All tenants share the same database and the same tables. Every table gets a TenantId column to distinguish data. This is the simplest and cheapest strategy.
// Entity base class with TenantId
public abstract class TenantEntity
{
public int Id { get; set; }
public string TenantId { get; set; } = null!;
}
public class Product : TenantEntity
{
public string Name { get; set; } = null!;
public decimal Price { get; set; }
public string Sku { get; set; } = null!;
}
public class Order : TenantEntity
{
public DateTime CreatedAt { get; set; }
public decimal TotalAmount { get; set; }
public List<OrderItem> Items { get; set; } = [];
}
// EF Core 10 — Global Query Filter for multi-tenancy
public class AppDbContext : DbContext
{
private readonly ITenantService _tenantService;
public AppDbContext(DbContextOptions options, ITenantService tenantService)
: base(options)
{
_tenantService = tenantService;
}
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder builder)
{
// Global filter — every query auto-adds WHERE TenantId = @tenantId
builder.Entity<Product>()
.HasQueryFilter(p => p.TenantId == _tenantService.CurrentTenantId);
builder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantService.CurrentTenantId);
// Composite indexes for query optimization
builder.Entity<Product>()
.HasIndex(p => new { p.TenantId, p.Sku })
.IsUnique();
builder.Entity<Order>()
.HasIndex(o => new { o.TenantId, o.CreatedAt });
}
public override int SaveChanges()
{
SetTenantIdOnNewEntities();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
SetTenantIdOnNewEntities();
return base.SaveChangesAsync(ct);
}
private void SetTenantIdOnNewEntities()
{
foreach (var entry in ChangeTracker.Entries<TenantEntity>()
.Where(e => e.State == EntityState.Added))
{
entry.Entity.TenantId = _tenantService.CurrentTenantId;
}
}
}
Shared Database strengths
Lowest cost (a single database), simple migrations (run once for all tenants), easy backup/restore management. A fit when you have many tenants (thousands) but each tenant has small data — e.g. task-management SaaS, SMB CRM, e-learning platforms.
Risks to watch
If you forget to apply a Global Query Filter to one entity, another tenant's data will leak — that's a serious data leak. Always write integration tests that verify isolation. Also, a tenant with unusually large data (a noisy neighbor) can impact query performance for others.
2.2. Shared Database, Schema-per-Tenant
Each tenant gets its own schema within the same database — e.g. tenant_abc.Products, tenant_xyz.Products. Better isolation than shared schema without the cost of many databases.
// Dynamic schema selection per tenant
public class SchemaPerTenantDbContext : DbContext
{
private readonly string _schema;
public SchemaPerTenantDbContext(
DbContextOptions options,
ITenantService tenantService) : base(options)
{
_schema = $"tenant_{tenantService.CurrentTenantId}";
}
protected override void OnModelCreating(ModelBuilder builder)
{
builder.HasDefaultSchema(_schema);
builder.Entity<Product>().ToTable("Products", _schema);
builder.Entity<Order>().ToTable("Orders", _schema);
}
}
2.3. Database-per-Tenant
Each tenant has its own database. Strongest isolation — fit for enterprise tenants with stringent compliance requirements (HIPAA, PCI-DSS) or geo-residency needs.
// Dynamic connection string resolution
public class TenantConnectionResolver
{
private readonly Dictionary<string, string> _connections;
public string GetConnectionString(string tenantId)
{
if (_connections.TryGetValue(tenantId, out var conn))
return conn;
// Fallback: load from tenant registry database
return LoadFromRegistry(tenantId);
}
}
// DbContext factory for database-per-tenant
public class TenantDbContextFactory : IDbContextFactory<AppDbContext>
{
private readonly ITenantService _tenantService;
private readonly TenantConnectionResolver _resolver;
public AppDbContext CreateDbContext()
{
var connStr = _resolver.GetConnectionString(
_tenantService.CurrentTenantId);
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connStr)
.Options;
return new AppDbContext(options, _tenantService);
}
}
graph TB
subgraph "Strategy 1: Shared DB"
SDB["Database"]
SDB --> T1D["Products
TenantId=A"]
SDB --> T2D["Products
TenantId=B"]
end
subgraph "Strategy 2: Schema-per-Tenant"
SDB2["Database"]
SDB2 --> S1["Schema: tenant_a
Products, Orders"]
SDB2 --> S2["Schema: tenant_b
Products, Orders"]
end
subgraph "Strategy 3: DB-per-Tenant"
DB1["DB: tenant_a"]
DB2["DB: tenant_b"]
DB3["DB: tenant_c"]
end
style SDB fill:#e94560,stroke:#fff,color:#fff
style SDB2 fill:#2c3e50,stroke:#e94560,color:#fff
style DB1 fill:#16213e,stroke:#e94560,color:#fff
style DB2 fill:#16213e,stroke:#e94560,color:#fff
style DB3 fill:#16213e,stroke:#e94560,color:#fff
Figure 2: Three data-isolation strategies — from shared (left) to fully isolated (right)
3. A Detailed Comparison of the Three Strategies
| Criterion | Shared DB + Discriminator | Schema-per-Tenant | Database-per-Tenant |
|---|---|---|---|
| Isolation level | Low (application-level) | Medium (DB schema-level) | Highest (physical) |
| Infrastructure cost | Lowest | Low | High (N databases) |
| Migration complexity | Simple (once) | Medium (N schemas) | High (N databases) |
| Noisy-neighbor risk | High | Medium | None |
| Compliance (HIPAA, PCI) | Hard to meet | Depends on provider | Easiest to meet |
| Per-tenant backup/restore | No native support | Possible (schema export) | Native support |
| Suitable tenant count | 1,000 - 100,000+ | 100 - 5,000 | 10 - 500 |
| Fit for | B2C SaaS, freemium | Mid-size B2B SaaS | Enterprise, regulated |
4. Tenant Resolution — Identifying the Tenant from the Request
Before querying data, the application needs to know which tenant the request belongs to. Several resolution strategies exist, each suited to a different deployment style.
4.1. Common strategies
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| Subdomain | acme.app.com | Intuitive, easy branding | Needs wildcard SSL, DNS config |
| Path prefix | app.com/acme/... | Simple, one domain | More complex routing |
| Header/Claim | X-Tenant-Id: acme | Flexible for APIs | Not browser-friendly |
| JWT Claim | tenant_id in the token | Stateless, secure | Must refresh token when switching tenants |
| Custom domain | app.acme.com | Most professional | SSL per domain, complex routing |
// Tenant Resolution Middleware for .NET 10
public interface ITenantService
{
string CurrentTenantId { get; }
TenantInfo? CurrentTenant { get; }
}
public class TenantMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context, ITenantService tenantService)
{
var tenantId = ResolveTenantId(context);
if (string.IsNullOrEmpty(tenantId))
{
context.Response.StatusCode = 400;
await context.Response.WriteAsync("Tenant not identified");
return;
}
((TenantService)tenantService).SetTenant(tenantId);
await _next(context);
}
private static string? ResolveTenantId(HttpContext context)
{
// Strategy 1: Subdomain (acme.app.com)
var host = context.Request.Host.Host;
var parts = host.Split('.');
if (parts.Length >= 3)
return parts[0];
// Strategy 2: Header
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var header))
return header.ToString();
// Strategy 3: JWT claim
var claim = context.User.FindFirst("tenant_id");
if (claim is not null)
return claim.Value;
return null;
}
}
// Registration in Program.cs (.NET 10)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ITenantService, TenantService>();
var app = builder.Build();
app.UseMiddleware<TenantMiddleware>();
5. Row-Level Security (RLS) — Database-level Protection
EF Core's Global Query Filter runs at the application level — if there's a bug or someone runs SQL directly against the database, the filter is bypassed. Row-Level Security (RLS) on SQL Server guarantees isolation at the database engine level, independent of the app.
graph TB
APP["Application
(.NET 10)"] -->|"SET SESSION_CONTEXT
@TenantId = 'acme'"| DB["SQL Server"]
DB --> RLS["Row-Level Security
Policy Engine"]
RLS -->|"Filter: TenantId = 'acme'"| DATA["Returns only data
for tenant 'acme'"]
ADMIN["DBA / Direct Query"] -->|"Also filtered
unless a bypass role"| RLS
style RLS fill:#e94560,stroke:#fff,color:#fff
style DB fill:#2c3e50,stroke:#e94560,color:#fff
style APP fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Figure 3: Row-Level Security — the database engine filters data by tenant regardless of where the query comes from
-- SQL Server — Configuring Row-Level Security for Multi-Tenancy
-- 1. Create a tenant-filter function
CREATE FUNCTION dbo.fn_TenantFilter(@TenantId NVARCHAR(128))
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS Result
WHERE @TenantId = CAST(SESSION_CONTEXT(N'TenantId') AS NVARCHAR(128))
OR IS_MEMBER('db_owner') = 1; -- DBA bypass
-- 2. Create a security policy on the Products table
CREATE SECURITY POLICY dbo.TenantPolicy_Products
ADD FILTER PREDICATE dbo.fn_TenantFilter(TenantId) ON dbo.Products,
ADD BLOCK PREDICATE dbo.fn_TenantFilter(TenantId) ON dbo.Products
WITH (STATE = ON);
-- 3. Same pattern for the Orders table
CREATE SECURITY POLICY dbo.TenantPolicy_Orders
ADD FILTER PREDICATE dbo.fn_TenantFilter(TenantId) ON dbo.Orders,
ADD BLOCK PREDICATE dbo.fn_TenantFilter(TenantId) ON dbo.Orders
WITH (STATE = ON);
// .NET 10 — Set SESSION_CONTEXT before each query
public class TenantDbConnectionInterceptor : DbConnectionInterceptor
{
private readonly ITenantService _tenantService;
public TenantDbConnectionInterceptor(ITenantService tenantService)
{
_tenantService = tenantService;
}
public override async ValueTask ConnectionOpenedAsync(
DbConnection connection,
ConnectionEndEventData eventData,
CancellationToken ct = default)
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "EXEC sp_set_session_context @key=N'TenantId', @value=@tenantId";
var param = cmd.CreateParameter();
param.ParameterName = "@tenantId";
param.Value = _tenantService.CurrentTenantId;
cmd.Parameters.Add(param);
await cmd.ExecuteNonQueryAsync(ct);
}
}
// Registration
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(sp.GetRequiredService<TenantDbConnectionInterceptor>());
});
Defense in Depth — Use both layers
Best practice is to use both EF Core Global Query Filter (layer 1) AND SQL Server RLS (layer 2). The global filter catches mistakes early at the application; RLS guarantees safety at the database. If one layer is bypassed (code bug, raw SQL), the other still protects. This is the defense in depth principle — essential for production SaaS.
6. Caching Strategy for a Multi-Tenant System
Caching in multi-tenant is more complex than single-tenant because you must ensure: (1) cached data never mixes between tenants, (2) one tenant can't consume the entire cache quota, and (3) invalidation works precisely per tenant.
6.1. Tenant-Prefixed Cache Keys
// Cache key strategy — ALWAYS prefix with TenantId
public class TenantCacheService
{
private readonly IDatabase _redis;
private readonly ITenantService _tenantService;
private string Key(string key) => $"t:{_tenantService.CurrentTenantId}:{key}";
public async Task<T?> GetAsync<T>(string key)
{
var cached = await _redis.StringGetAsync(Key(key));
return cached.HasValue
? JsonSerializer.Deserialize<T>(cached!)
: default;
}
public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null)
{
await _redis.StringSetAsync(
Key(key),
JsonSerializer.Serialize(value),
expiry ?? TimeSpan.FromMinutes(15));
}
public async Task InvalidatePatternAsync(string pattern)
{
var server = _redis.Multiplexer.GetServer(
_redis.Multiplexer.GetEndPoints()[0]);
await foreach (var key in server.KeysAsync(
pattern: $"t:{_tenantService.CurrentTenantId}:{pattern}*"))
{
await _redis.KeyDeleteAsync(key);
}
}
}
6.2. Per-Tenant Cache Quota — Fighting the Noisy Neighbor
graph LR
APP["Application"] --> GUARD["Cache Quota Guard"]
GUARD -->|"Tenant A: 45/100 MB"| REDIS["Redis"]
GUARD -->|"Tenant B: 98/100 MB ⚠️"| REDIS
GUARD -->|"Tenant C: 12/100 MB"| REDIS
REDIS --> METRIC["Prometheus Metrics
cache_usage_per_tenant"]
METRIC --> ALERT["Alert: Tenant B
approaching quota"]
style GUARD fill:#e94560,stroke:#fff,color:#fff
style REDIS fill:#2c3e50,stroke:#e94560,color:#fff
style ALERT fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
Figure 4: Per-tenant cache quota — capping memory use to avoid noisy neighbors
// Cache Quota Enforcement
public class QuotaAwareCacheService
{
private readonly IDatabase _redis;
private readonly ITenantService _tenantService;
private const long DefaultQuotaBytes = 100 * 1024 * 1024; // 100MB per tenant
public async Task<bool> SetWithQuotaAsync<T>(
string key, T value, TimeSpan? expiry = null)
{
var tenantId = _tenantService.CurrentTenantId;
var json = JsonSerializer.Serialize(value);
var sizeBytes = Encoding.UTF8.GetByteCount(json);
// Check quota
var currentUsage = await _redis.StringGetAsync($"quota:{tenantId}:bytes");
var used = currentUsage.HasValue ? (long)currentUsage : 0;
if (used + sizeBytes > DefaultQuotaBytes)
{
// Record a metric, optionally evict LRU keys for this tenant
await EvictLruKeysAsync(tenantId, sizeBytes);
}
var cacheKey = $"t:{tenantId}:{key}";
await _redis.StringSetAsync(cacheKey, json, expiry);
await _redis.StringIncrementAsync(
$"quota:{tenantId}:bytes", sizeBytes);
return true;
}
private async Task EvictLruKeysAsync(string tenantId, long neededBytes)
{
// Scan and remove the oldest keys for this tenant until we free enough space
var server = _redis.Multiplexer.GetServer(
_redis.Multiplexer.GetEndPoints()[0]);
long freed = 0;
await foreach (var key in server.KeysAsync(pattern: $"t:{tenantId}:*"))
{
if (freed >= neededBytes) break;
var ttl = await _redis.KeyTimeToLiveAsync(key);
if (ttl.HasValue && ttl.Value < TimeSpan.FromMinutes(2))
{
var size = (await _redis.StringGetAsync(key)).Length();
await _redis.KeyDeleteAsync(key);
freed += size;
}
}
}
}
7. Hybrid Strategy — Mixing by Customer Tier
In reality, not every tenant has the same requirements. Most SaaS products tier their customers: Free/Starter (shared DB), Pro/Business (schema isolation), Enterprise (dedicated DB). This hybrid pattern is the most popular in 2026.
graph TB
REQ["Incoming Request"] --> MW["Tenant Middleware"]
MW --> RESOLVE["Resolve Tenant + Tier"]
RESOLVE --> ROUTE{"Tenant Tier?"}
ROUTE -->|"Free / Starter"| SHARED["Shared DB
Discriminator Column
(1 database, N tenants)"]
ROUTE -->|"Pro / Business"| SCHEMA["Schema-per-Tenant
(1 database, N schemas)"]
ROUTE -->|"Enterprise"| DEDICATED["Dedicated DB
(1 tenant = 1 database)"]
SHARED --> CACHE["Shared Redis
Prefix keys"]
SCHEMA --> CACHE
DEDICATED --> DCACHE["Dedicated Redis
Instance"]
style ROUTE fill:#e94560,stroke:#fff,color:#fff
style SHARED fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style SCHEMA fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
style DEDICATED fill:#2c3e50,stroke:#e94560,color:#fff
Figure 5: Hybrid Multi-Tenancy — routing tenants to the isolation level that matches their tier
// Hybrid Tenant Router
public class HybridTenantRouter
{
private readonly TenantRegistry _registry;
private readonly IServiceProvider _sp;
public DbContext CreateDbContext(string tenantId)
{
var tenant = _registry.Get(tenantId);
return tenant.Tier switch
{
TenantTier.Free or TenantTier.Starter =>
CreateSharedDbContext(tenantId),
TenantTier.Pro or TenantTier.Business =>
CreateSchemaDbContext(tenantId, tenant.SchemaName),
TenantTier.Enterprise =>
CreateDedicatedDbContext(tenant.ConnectionString),
_ => throw new InvalidOperationException(
$"Unknown tier for tenant {tenantId}")
};
}
private DbContext CreateSharedDbContext(string tenantId)
{
var options = new DbContextOptionsBuilder<SharedDbContext>()
.UseSqlServer(_registry.SharedConnectionString)
.Options;
return new SharedDbContext(options, tenantId);
}
private DbContext CreateSchemaDbContext(string tenantId, string schema)
{
var options = new DbContextOptionsBuilder<SchemaDbContext>()
.UseSqlServer(_registry.SharedConnectionString)
.Options;
return new SchemaDbContext(options, tenantId, schema);
}
private DbContext CreateDedicatedDbContext(string connectionString)
{
var options = new DbContextOptionsBuilder<DedicatedDbContext>()
.UseSqlServer(connectionString)
.Options;
return new DedicatedDbContext(options);
}
}
public enum TenantTier
{
Free,
Starter,
Pro,
Business,
Enterprise
}
8. Database Migrations in Multi-Tenant
Migrations are the biggest multi-tenancy challenge. With shared DB, run migrations once. With schema-per-tenant or DB-per-tenant, you must run migrations for every tenant — and make sure all tenants converge to the same schema version.
// Migration Runner for multi-tenant
public class TenantMigrationRunner
{
private readonly TenantRegistry _registry;
private readonly ILogger<TenantMigrationRunner> _logger;
public async Task MigrateAllTenantsAsync()
{
var tenants = await _registry.GetAllAsync();
var semaphore = new SemaphoreSlim(5); // Max 5 concurrent migrations
var tasks = tenants.Select(async tenant =>
{
await semaphore.WaitAsync();
try
{
_logger.LogInformation(
"Migrating tenant {TenantId} (Tier: {Tier})",
tenant.Id, tenant.Tier);
await using var context = CreateContextForTenant(tenant);
await context.Database.MigrateAsync();
_logger.LogInformation(
"Tenant {TenantId} migrated successfully", tenant.Id);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Migration failed for tenant {TenantId}", tenant.Id);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
}
Zero-Downtime Migration
With hundreds of tenants, a migration can take hours. Use the expand-contract pattern: (1) Expand — add new columns/tables while keeping the old ones, (2) Migrate data — copy data into the new schema, (3) Contract — drop the old columns/tables after verification. That way the application runs on both old and new schemas during migration, guaranteeing zero downtime.
9. Monitoring & Observability Per-Tenant
In a multi-tenant system you need to monitor not just the whole system but also per-tenant: which tenant uses the most resources, which one has the most errors, what's the response time per tenant.
// Custom per-tenant metrics with OpenTelemetry
public class TenantMetrics
{
private static readonly Meter Meter = new("SaaS.Tenants");
private static readonly Counter<long> RequestCounter =
Meter.CreateCounter<long>("saas.tenant.requests",
description: "Request count per tenant");
private static readonly Histogram<double> ResponseTime =
Meter.CreateHistogram<double>("saas.tenant.response_time_ms",
description: "Response time per tenant");
private static readonly UpDownCounter<long> ActiveConnections =
Meter.CreateUpDownCounter<long>("saas.tenant.active_connections",
description: "Active connections per tenant");
public static void RecordRequest(string tenantId, double durationMs)
{
var tags = new TagList { { "tenant_id", tenantId } };
RequestCounter.Add(1, tags);
ResponseTime.Record(durationMs, tags);
}
}
// Middleware that records per-tenant metrics automatically
public class TenantMetricsMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context, ITenantService tenant)
{
var sw = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
sw.Stop();
if (tenant.CurrentTenantId is not null)
{
TenantMetrics.RecordRequest(
tenant.CurrentTenantId, sw.Elapsed.TotalMilliseconds);
}
}
}
}
graph LR
APP["Application"] -->|"OTel Metrics"| COLLECTOR["OpenTelemetry
Collector"]
COLLECTOR --> PROM["Prometheus"]
COLLECTOR --> LOKI["Loki (Logs)"]
COLLECTOR --> TEMPO["Tempo (Traces)"]
PROM --> GRAFANA["Grafana Dashboard"]
LOKI --> GRAFANA
TEMPO --> GRAFANA
GRAFANA --> DASH["Per-Tenant Dashboard
• Request rate
• Error rate
• P99 latency
• Cache hit ratio
• DB query count"]
style COLLECTOR fill:#e94560,stroke:#fff,color:#fff
style GRAFANA fill:#2c3e50,stroke:#e94560,color:#fff
style DASH fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Figure 6: A per-tenant observability stack — from the OTel Collector to a Grafana dashboard
10. Testing Tenant Isolation
Tenant isolation is a critical security requirement. A small bug can expose tenant A's data to tenant B — that's the worst class of SaaS incident. Isolation integration tests must be mandatory in your CI/CD pipeline.
// Integration test verifying tenant isolation
[Fact]
public async Task Tenant_Cannot_Access_Other_Tenant_Data()
{
// Arrange: create data for tenant A
await using var contextA = CreateContext("tenant-a");
contextA.Products.Add(new Product
{
Name = "Product A", Price = 100, Sku = "SKU-A"
});
await contextA.SaveChangesAsync();
// Act: query from tenant B
await using var contextB = CreateContext("tenant-b");
var productsB = await contextB.Products.ToListAsync();
// Assert: tenant B must NOT see tenant A's data
Assert.Empty(productsB);
Assert.DoesNotContain(productsB, p => p.Name == "Product A");
}
[Fact]
public async Task Tenant_Cannot_Modify_Other_Tenant_Data()
{
// Arrange
await using var contextA = CreateContext("tenant-a");
var product = new Product
{
Name = "Secret Product", Price = 999, Sku = "SECRET"
};
contextA.Products.Add(product);
await contextA.SaveChangesAsync();
// Act: tenant B tries to find it
await using var contextB = CreateContext("tenant-b");
var found = await contextB.Products
.IgnoreQueryFilters() // Bypass the filter in the test to verify RLS
.FirstOrDefaultAsync(p => p.Sku == "SECRET");
// If RLS works, even direct queries return nothing
// If only EF filter is used, found != null when bypassed
// => This test verifies RLS at the database level
}
11. Production Checklist for Multi-Tenant SaaS
| Item | Requirement | Tool/Pattern |
|---|---|---|
| Data Isolation | Tenants cannot access other tenants' data | EF Global Filter + SQL Server RLS |
| Tenant Resolution | Correctly identify the tenant on every request | Middleware + Subdomain/JWT/Header |
| Cache Isolation | Cache keys must be tenant-prefixed | Redis + tenant-prefixed keys |
| Cache Quota | Cap per-tenant cache usage | Redis memory tracking + eviction |
| Migration | Schema changes synced across every tenant | Parallel migration runner + expand-contract |
| Monitoring | Metrics, logs, traces per tenant | OpenTelemetry + per-tenant Grafana dashboards |
| Rate Limiting | Cap API calls per tenant/tier | Polly v8 + Redis sliding window per tenant |
| Backup/Restore | Ability to restore a single tenant's data | DB-per-tenant native; shared DB needs custom logic |
| Onboarding | Auto-provision new tenants | Tenant provisioning service + migration |
| Testing | Integration tests for isolation | xUnit + per-tenant Testcontainers scenarios |
12. Conclusion
Multi-tenancy isn't a single pattern but a set of architectural decisions that touch every layer: database, caching, security, monitoring, deployment, and billing. There's no one-size-fits-all strategy — shared DB fits freemium SaaS with thousands of small tenants, while DB-per-tenant is required for enterprises with strict compliance requirements.
On .NET 10 in 2026, EF Core Global Query Filter combined with SQL Server Row-Level Security delivers a strong defense-in-depth layer. Redis with tenant-prefixed keys plus per-tenant cache quotas solves the caching problem. The hybrid strategy — routing tenants to the isolation level that matches their tier — is the architecture most production SaaS apps adopt today.
The most important takeaway: always test isolation. A data leak between tenants can destroy a SaaS's reputation instantly. Make tenant-isolation integration tests a non-negotiable requirement in your CI/CD pipeline.
References
- Multi-tenancy — EF Core | Microsoft Learn
- Row-Level Security — SQL Server | Microsoft Learn
- Architecting multitenant solutions on Azure | Microsoft Learn
- Multi-Tenant Architecture with EF Core — Milan Jovanovic
- SaaS Tenant Isolation Strategies — AWS
- Multi-Tenant Architecture: A Complete Guide 2026 — Finout
Elasticsearch 9 and Hybrid Search 2026 — BBQ, ELSER, Retrievers API, and a Production Search System Architecture
AWS Lambda Serverless 2026: Architecture, SnapStart, Event-Driven Patterns, and the Production Free Tier
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.