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

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.

78% Of SaaS products use multi-tenancy (Gartner 2026)
3-5x Infrastructure cost reduction vs single-tenant
60% Of SaaS failures tie back to broken tenant isolation
99.95% Minimum SLA typical enterprise tenants require
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

CriterionShared DB + DiscriminatorSchema-per-TenantDatabase-per-Tenant
Isolation levelLow (application-level)Medium (DB schema-level)Highest (physical)
Infrastructure costLowestLowHigh (N databases)
Migration complexitySimple (once)Medium (N schemas)High (N databases)
Noisy-neighbor riskHighMediumNone
Compliance (HIPAA, PCI)Hard to meetDepends on providerEasiest to meet
Per-tenant backup/restoreNo native supportPossible (schema export)Native support
Suitable tenant count1,000 - 100,000+100 - 5,00010 - 500
Fit forB2C SaaS, freemiumMid-size B2B SaaSEnterprise, 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

StrategyExampleProsCons
Subdomainacme.app.comIntuitive, easy brandingNeeds wildcard SSL, DNS config
Path prefixapp.com/acme/...Simple, one domainMore complex routing
Header/ClaimX-Tenant-Id: acmeFlexible for APIsNot browser-friendly
JWT Claimtenant_id in the tokenStateless, secureMust refresh token when switching tenants
Custom domainapp.acme.comMost professionalSSL 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

ItemRequirementTool/Pattern
Data IsolationTenants cannot access other tenants' dataEF Global Filter + SQL Server RLS
Tenant ResolutionCorrectly identify the tenant on every requestMiddleware + Subdomain/JWT/Header
Cache IsolationCache keys must be tenant-prefixedRedis + tenant-prefixed keys
Cache QuotaCap per-tenant cache usageRedis memory tracking + eviction
MigrationSchema changes synced across every tenantParallel migration runner + expand-contract
MonitoringMetrics, logs, traces per tenantOpenTelemetry + per-tenant Grafana dashboards
Rate LimitingCap API calls per tenant/tierPolly v8 + Redis sliding window per tenant
Backup/RestoreAbility to restore a single tenant's dataDB-per-tenant native; shared DB needs custom logic
OnboardingAuto-provision new tenantsTenant provisioning service + migration
TestingIntegration tests for isolationxUnit + 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