Search

EF Core và Multi-Tenancy: Kiến Trúc Thực Chiến

EF Core và Multi-Tenancy: Kiến Trúc Thực Chiến

Multi-tenancy là pattern mà hầu hết SaaS application cần nhưng ít tài liệu nào nói rõ cách implement end-to-end. Tutorial thường dừng ở mức "thêm TenantId vào entity" — nhưng thực tế phức tạp hơn nhiều: làm sao ensure mọi query đều filter theo tenant? Làm sao ngăn developer quên thêm filter? Làm sao handle migration khi thêm tenant mới? Data leak giữa các tenant xử lý thế nào?

Bài viết này chia sẻ kiến trúc multi-tenancy mình dùng trong production — từ database strategy, tenant resolution, Global Query Filter, đến những bug đã gặp và cách phòng tránh.


Ba strategy Multi-Tenancy — Chọn cái nào?

Database per Tenant

Mỗi tenant có database riêng. Tenant A dùng app_tenant_a, tenant B dùng app_tenant_b.

Ưu điểm: isolation tuyệt đối (tenant A không thể access data tenant B dù code có bug), backup/restore per tenant dễ, performance tuning per tenant, compliance dễ (GDPR — xóa tenant = xóa database). Nhược điểm: migration phải chạy trên tất cả databases, connection pool per database (tốn resource), cross-tenant reporting phức tạp, số lượng tenant bị giới hạn (100 databases OK, 10,000 databases thì không).

Dùng khi: tenant là enterprise customer, yêu cầu data isolation cao, số lượng tenant ít (<100).

Schema per Tenant

Cùng database, mỗi tenant có schema riêng. tenant_a.invoices, tenant_b.invoices.

Trung gian giữa database-per-tenant và shared database. Isolation tốt hơn shared database (schema-level permissions), ít overhead hơn database-per-tenant. Nhưng PostgreSQL schema management phức tạp, migration vẫn phải chạy per schema, và không phải ORM nào cũng support tốt.

Dùng khi: cần isolation tốt hơn shared database nhưng không muốn manage nhiều databases.

Shared Database — Row-Level Isolation

Tất cả tenant chung database, chung table, phân biệt bằng TenantId column. Đây là strategy phổ biến nhất cho SaaS application và là cái mình sẽ đi sâu trong bài này.

Ưu điểm: đơn giản nhất để implement, migration chạy một lần, connection pool shared (tiết kiệm resource), cross-tenant reporting dễ, scale tốt với nhiều tenant (10,000+ tenant OK). Nhược điểm: phải ensure EVERY query filter theo TenantId (quên = data leak), performance tuning khó hơn khi tenant có data size khác nhau, không thể backup/restore per tenant dễ dàng.

Dùng khi: SaaS application, số lượng tenant lớn, tenant là SMB customer.


Bước 1: BaseEntity với TenantId

public abstract class BaseEntity
{
    public int Id { get; set; }
    public int TenantId { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
}

public class Invoice : BaseEntity
{
    public string InvoiceNumber { get; set; } = string.Empty;
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
    public decimal TotalAmount { get; set; }
    public InvoiceStatus Status { get; set; }
    public List<InvoiceLineItem> LineItems { get; set; } = [];
}

public class Customer : BaseEntity
{
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public List<Invoice> Invoices { get; set; } = [];
}

Mọi entity kế thừa BaseEntity → mọi record đều có TenantId. Không có ngoại lệ. Entity không thuộc tenant nào (ví dụ lookup tables) thì không kế thừa BaseEntity — tạo class riêng không có TenantId.


Bước 2: Tenant Resolution — Xác định tenant hiện tại

Tenant được xác định từ HTTP request. Có nhiều cách: subdomain (tenant-a.app.com), header (X-Tenant-Id), JWT claim, route parameter. Mình dùng JWT claim vì đã có authentication:

public interface ITenantProvider
{
    int GetCurrentTenantId();
}

public class HttpTenantProvider : ITenantProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public HttpTenantProvider(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public int GetCurrentTenantId()
    {
        var tenantClaim = _httpContextAccessor.HttpContext?
            .User?.FindFirst("tenant_id");

        if (tenantClaim == null || !int.TryParse(tenantClaim.Value, out var tenantId))
            throw new UnauthorizedAccessException(
                "Tenant context not available. Ensure user is authenticated.");

        return tenantId;
    }
}

Đăng ký Scoped — vì IHttpContextAccessor cần HTTP context:

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, HttpTenantProvider>();

Tại sao ITenantProvider thay vì đọc claim trực tiếp? Vì unit test: mock ITenantProvider trả về tenant bất kỳ, không cần setup HTTP context. Và vì background service: BackgroundTenantProvider đọc tenant từ config hoặc queue message thay vì HTTP context.


Bước 3: Global Query Filter — Tuyến phòng thủ quan trọng nhất

Đây là phần critical nhất. Global Query Filter đảm bảo EVERY query từ DbContext đều tự động filter theo TenantId — developer không cần nhớ thêm .Where(x => x.TenantId == tenantId) vào mọi query.

public class AppDbContext : DbContext
{
    private readonly ITenantProvider _tenantProvider;

    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantProvider tenantProvider) : base(options)
    {
        _tenantProvider = tenantProvider;
    }

    public DbSet<Invoice> Invoices => Set<Invoice>();
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Tự động apply filter cho TẤT CẢ entity kế thừa BaseEntity
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (!typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
                continue;

            var method = typeof(AppDbContext)
                .GetMethod(nameof(ApplyTenantFilter),
                    System.Reflection.BindingFlags.NonPublic
                    | System.Reflection.BindingFlags.Static)!
                .MakeGenericMethod(entityType.ClrType);

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

    private static void ApplyTenantFilter<TEntity>(ModelBuilder modelBuilder)
        where TEntity : BaseEntity
    {
        modelBuilder.Entity<TEntity>().HasQueryFilter(
            e => e.TenantId == GetTenantId() && !e.IsDeleted);
    }

    // EF Core dịch method call này thành parameter trong SQL
    // Không gọi trực tiếp — chỉ dùng trong expression tree
    private static int GetTenantId() => throw new InvalidOperationException(
        "This method should only be used in EF Core query filters.");
}

Khoan — GetTenantId() throw exception? Đúng, vì nó không bao giờ được gọi trực tiếp. EF Core nhìn vào expression tree, thấy method call, và tự translate thành SQL parameter. Nhưng cách này có vấn đề — EF Core cần biết giá trị TenantId ở runtime.

Cách đúng dùng field reference để EF Core resolve giá trị từ DbContext instance:

public class AppDbContext : DbContext
{
    private readonly int _tenantId;

    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantProvider tenantProvider) : base(options)
    {
        _tenantId = tenantProvider.GetCurrentTenantId();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (!typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
                continue;

            var method = typeof(AppDbContext)
                .GetMethod(nameof(ApplyTenantFilter),
                    System.Reflection.BindingFlags.NonPublic
                    | System.Reflection.BindingFlags.Static)!
                .MakeGenericMethod(entityType.ClrType);

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

    private static void ApplyTenantFilter<TEntity>(
        ModelBuilder modelBuilder, AppDbContext context)
        where TEntity : BaseEntity
    {
        // EF Core capture reference đến context._tenantId
        // Mỗi request có DbContext mới → _tenantId đúng per request
        modelBuilder.Entity<TEntity>().HasQueryFilter(
            e => e.TenantId == context._tenantId && !e.IsDeleted);
    }
}

Giờ mọi query tự động có WHERE tenant_id = @tenantId AND is_deleted = false:

// Code developer viết:
var invoices = await _context.Invoices
    .Where(i => i.Status == InvoiceStatus.Approved)
    .ToListAsync();

// SQL thực tế PostgreSQL chạy:
// SELECT * FROM invoices
// WHERE status = 'Approved'
//   AND tenant_id = 1        ← tự động thêm
//   AND is_deleted = false    ← tự động thêm

Developer không thể quên filter tenant. Đó là point — security by default, không phải security by discipline.


Bước 4: Tự động set TenantId khi tạo record

Global Query Filter chỉ lo phần đọc. Phần ghi cũng cần tự động:

public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
    SetTenantAndAuditFields();
    return base.SaveChanges(acceptAllChangesOnSuccess);
}

public override async Task<int> SaveChangesAsync(
    bool acceptAllChangesOnSuccess,
    CancellationToken cancellationToken = default)
{
    SetTenantAndAuditFields();
    return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}

private void SetTenantAndAuditFields()
{
    foreach (var entry in ChangeTracker.Entries<BaseEntity>())
    {
        switch (entry.State)
        {
            case EntityState.Added:
                entry.Entity.TenantId = _tenantId;
                entry.Entity.CreatedAt = DateTime.UtcNow;
                break;

            case EntityState.Modified:
                // Ngăn đổi TenantId
                entry.Property(e => e.TenantId).IsModified = false;
                entry.Entity.UpdatedAt = DateTime.UtcNow;
                break;

            case EntityState.Deleted:
                // Convert hard delete thành soft delete
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.DeletedAt = DateTime.UtcNow;
                break;
        }
    }
}

Ba điều quan trọng: Added — TenantId tự động set, developer không cần gán thủ công. Modified — TenantId không thể bị đổi, ngăn move record sang tenant khác (dù vô tình hay cố ý). Deleted — convert thành soft delete, Global Query Filter ẩn record đã xóa.


Bước 5: Index Strategy cho Multi-Tenant

Mọi query đều có WHERE tenant_id = Xtenant_id phải là column đầu tiên trong mọi composite index:

public class InvoiceConfiguration : IEntityTypeConfiguration<Invoice>
{
    public void Configure(EntityTypeBuilder<Invoice> builder)
    {
        // Composite index: tenant_id luôn đầu tiên
        builder.HasIndex(e => new { e.TenantId, e.Status, e.CreatedAt })
            .HasDatabaseName("idx_invoices_tenant_status_created")
            .HasFilter("\"is_deleted\" = false");

        // Unique per tenant (không phải unique toàn cục)
        builder.HasIndex(e => new { e.TenantId, e.InvoiceNumber })
            .IsUnique()
            .HasDatabaseName("idx_invoices_number_unique")
            .HasFilter("\"is_deleted\" = false");

        // FK index cũng cần tenant_id
        builder.HasIndex(e => new { e.TenantId, e.CustomerId })
            .HasDatabaseName("idx_invoices_tenant_customer")
            .HasFilter("\"is_deleted\" = false");
    }
}

Tại sao tenant_id đầu tiên? Vì B-Tree composite index tuân theo leftmost prefix. Index (tenant_id, status, created_at) dùng được cho query WHERE tenant_id = 1WHERE tenant_id = 1 AND status = 'Approved'. Nhưng KHÔNG dùng được cho WHERE status = 'Approved' (không có tenant_id).

Vì mọi query đều có tenant_id (nhờ Global Query Filter), tenant_id đầu tiên ensure index luôn được sử dụng.


Bước 6: PostgreSQL Row-Level Security — Defense in Depth

Global Query Filter là application-level protection. Nếu ai đó bypass EF Core (raw SQL, DB tool, bug), data vẫn exposed. PostgreSQL Row-Level Security (RLS) thêm database-level protection:

-- Bật RLS cho table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Policy: user chỉ thấy rows thuộc tenant của mình
CREATE POLICY tenant_isolation ON invoices
    USING (tenant_id = current_setting('app.current_tenant_id')::int);

-- Áp dụng cho tất cả tables
DO $$
DECLARE
    tbl RECORD;
BEGIN
    FOR tbl IN SELECT tablename FROM pg_tables
               WHERE schemaname = 'public'
    LOOP
        EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl.tablename);
        EXECUTE format(
            'CREATE POLICY tenant_isolation ON %I USING (tenant_id = current_setting(''app.current_tenant_id'')::int)',
            tbl.tablename);
    END LOOP;
END $$;

Set tenant context mỗi request trong EF Core:

// Interceptor set PostgreSQL session variable
public class TenantDbConnectionInterceptor : DbConnectionInterceptor
{
    private readonly ITenantProvider _tenantProvider;

    public TenantDbConnectionInterceptor(ITenantProvider tenantProvider)
    {
        _tenantProvider = tenantProvider;
    }

    public override async Task ConnectionOpenedAsync(
        DbConnection connection,
        ConnectionEndEventData eventData,
        CancellationToken cancellationToken = default)
    {
        var tenantId = _tenantProvider.GetCurrentTenantId();
        await using var cmd = connection.CreateCommand();
        cmd.CommandText = $"SET app.current_tenant_id = '{tenantId}'";
        await cmd.ExecuteNonQueryAsync(cancellationToken);
    }
}

// Đăng ký
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseNpgsql(connectionString);
    options.AddInterceptors(
        sp.GetRequiredService<TenantDbConnectionInterceptor>());
});

Giờ có hai layer bảo vệ: EF Core Global Query Filter (application level) và PostgreSQL RLS (database level). Ngay cả raw SQL query cũng bị filter theo tenant.


Background Service — Tenant context không có HTTP request

Background service không có HTTP context → HttpTenantProvider throw exception. Cần provider riêng:

public class BackgroundTenantProvider : ITenantProvider
{
    private static readonly AsyncLocal<int?> _currentTenantId = new();

    public int GetCurrentTenantId()
    {
        return _currentTenantId.Value
            ?? throw new InvalidOperationException(
                "Tenant context not set. " +
                "Use SetTenant() before accessing tenant-scoped services.");
    }

    public static IDisposable SetTenant(int tenantId)
    {
        _currentTenantId.Value = tenantId;
        return new TenantScope();
    }

    private class TenantScope : IDisposable
    {
        public void Dispose() => _currentTenantId.Value = null;
    }
}

// Sử dụng trong BackgroundService
public class InvoiceReminderService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            // Lấy danh sách tất cả tenant
            var tenantIds = await GetAllTenantIdsAsync();

            foreach (var tenantId in tenantIds)
            {
                // Set tenant context cho iteration này
                using var tenantScope = BackgroundTenantProvider
                    .SetTenant(tenantId);
                using var scope = _scopeFactory.CreateScope();

                var invoiceService = scope.ServiceProvider
                    .GetRequiredService<IInvoiceService>();

                await invoiceService.SendRemindersAsync(ct);
                // Mọi query trong SendRemindersAsync tự động
                // filter theo tenantId nhờ Global Query Filter
            }

            await Task.Delay(TimeSpan.FromHours(1), ct);
        }
    }
}

Đăng ký provider dựa trên context:

// Trong HTTP context → dùng HttpTenantProvider
// Trong background context → dùng BackgroundTenantProvider
builder.Services.AddScoped<ITenantProvider>(sp =>
{
    var httpContext = sp.GetService<IHttpContextAccessor>()?.HttpContext;
    if (httpContext != null)
        return new HttpTenantProvider(sp.GetRequiredService<IHttpContextAccessor>());

    return new BackgroundTenantProvider();
});

Bypass Global Query Filter — Khi nào và cách nào

Đôi khi cần query cross-tenant: admin dashboard, reporting, data migration. Dùng IgnoreQueryFilters():

// Admin endpoint — cần xem tất cả tenant
[Authorize(Roles = "SuperAdmin")]
public async Task<IActionResult> GetAllInvoiceStats()
{
    var stats = await _context.Invoices
        .IgnoreQueryFilters()  // Bỏ cả tenant filter VÀ soft delete filter
        .GroupBy(i => i.TenantId)
        .Select(g => new
        {
            TenantId = g.Key,
            TotalInvoices = g.Count(),
            TotalAmount = g.Sum(i => i.TotalAmount)
        })
        .ToListAsync();

    return Ok(stats);
}

Cẩn thận: IgnoreQueryFilters() bỏ TẤT CẢ Global Query Filters — cả tenant filter lẫn soft delete filter. Nếu chỉ muốn bỏ tenant filter mà giữ soft delete, phải thêm .Where(e => !e.IsDeleted) thủ công. Đây là limitation của EF Core — không thể ignore filter selective.

Quy tắc: IgnoreQueryFilters() chỉ dùng trong admin/reporting endpoints, luôn có [Authorize(Roles = "SuperAdmin")], và luôn add lại soft delete filter manually.


Testing Multi-Tenant

Unit test — Mock ITenantProvider

public class InvoiceServiceTests
{
    private AppDbContext CreateContext(int tenantId)
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase("Test_" + Guid.NewGuid())
            .Options;

        var mockTenantProvider = new Mock<ITenantProvider>();
        mockTenantProvider.Setup(t => t.GetCurrentTenantId())
            .Returns(tenantId);

        return new AppDbContext(options, mockTenantProvider.Object);
    }

    [Fact]
    public async Task GetInvoices_ShouldOnlyReturnCurrentTenantData()
    {
        // Arrange — seed data cho 2 tenant
        using var seedContext = CreateContext(1);
        seedContext.Invoices.AddRange(
            new Invoice { TenantId = 1, InvoiceNumber = "INV-001" },
            new Invoice { TenantId = 1, InvoiceNumber = "INV-002" },
            new Invoice { TenantId = 2, InvoiceNumber = "INV-003" }  // Tenant khác
        );
        await seedContext.SaveChangesAsync();

        // Act — query với tenant 1
        using var tenant1Context = CreateContext(1);
        var invoices = await tenant1Context.Invoices.ToListAsync();

        // Assert — chỉ thấy data tenant 1
        Assert.Equal(2, invoices.Count);
        Assert.All(invoices, i => Assert.Equal(1, i.TenantId));
    }

    [Fact]
    public async Task CreateInvoice_ShouldAutoSetTenantId()
    {
        using var context = CreateContext(3);
        var invoice = new Invoice { InvoiceNumber = "INV-001" };
        // Không set TenantId

        context.Invoices.Add(invoice);
        await context.SaveChangesAsync();

        Assert.Equal(3, invoice.TenantId); // TenantId tự động = 3
    }
}

Integration test — Verify tenant isolation

[Fact]
public async Task TenantIsolation_ShouldPreventCrossTenantAccess()
{
    // Tenant 1 tạo invoice
    using var tenant1Context = CreateContext(1);
    tenant1Context.Invoices.Add(
        new Invoice { InvoiceNumber = "SECRET-001", TotalAmount = 999999 });
    await tenant1Context.SaveChangesAsync();

    // Tenant 2 cố truy cập
    using var tenant2Context = CreateContext(2);
    var canAccess = await tenant2Context.Invoices
        .AnyAsync(i => i.InvoiceNumber == "SECRET-001");

    Assert.False(canAccess); // Tenant 2 KHÔNG thấy data tenant 1
}

Những bug đã gặp trên production

Bug 1: Quên filter trong raw SQL

// BUG — raw SQL không có Global Query Filter!
var invoices = await _context.Invoices
    .FromSqlRaw("SELECT * FROM invoices WHERE status = 'Pending'")
    .ToListAsync();
// Trả về data TẤT CẢ tenant!

FromSqlRaw() bypass Global Query Filter. Phải thêm filter thủ công hoặc dùng FromSqlInterpolated() rồi thêm .Where() sau. Tốt nhất: tránh raw SQL trong multi-tenant app. Nếu bắt buộc, luôn include AND tenant_id = {tenantId}.

Bug 2: Include navigation property cross-tenant

// Nếu Invoice thuộc tenant 1, nhưng Customer thuộc tenant 2 (data lỗi)
var invoice = await _context.Invoices
    .Include(i => i.Customer)  // Customer cũng bị filter theo tenant_id
    .FirstOrDefaultAsync(i => i.Id == 1);
// invoice.Customer = null (vì Customer tenant 2 bị filter ra)

Global Query Filter apply cho cả navigation property trong Include. Đây là behavior đúng (ngăn cross-tenant access), nhưng nếu data bị inconsistent (FK trỏ sang tenant khác), navigation property sẽ null dù FK có giá trị. Giải pháp: database constraint đảm bảo FK references chỉ nằm trong cùng tenant.

Bug 3: Singleton service cache data cross-tenant

// BUG — Singleton cache không phân biệt tenant
public class ProductCacheService  // Singleton
{
    private List<Product>? _cached;

    public async Task<List<Product>> GetProductsAsync()
    {
        _cached ??= await LoadFromDb();  // Load tenant 1 lần đầu
        return _cached;  // Tenant 2 nhận data tenant 1!
    }
}

Cache trong multi-tenant phải key theo TenantId:

public class ProductCacheService
{
    private readonly ConcurrentDictionary<int, List<Product>> _cache = new();

    public async Task<List<Product>> GetProductsAsync(int tenantId)
    {
        return _cache.GetOrAdd(tenantId, _ => LoadFromDb(tenantId));
    }
}

Kết luận

Multi-tenancy với shared database trong EF Core xoay quanh một nguyên tắc: mọi thứ phải tự động. TenantId tự động set khi tạo record. Mọi query tự động filter theo tenant. Developer không cần nhớ — và không thể quên.

Global Query Filter là tuyến phòng thủ đầu tiên. PostgreSQL RLS là tuyến thứ hai. Override SaveChanges tự động set TenantId và ngăn đổi tenant. Index strategy với tenant_id đầu tiên ensure performance.

Kiến trúc này chạy ổn với 100+ tenant, hàng triệu records. Quan trọng nhất không phải code — mà là mindset: trong multi-tenant, mọi feature đều phải nghĩ "cái này có đúng cho mọi tenant không? Có leak data không? Có cần filter không?" Nếu câu trả lời là "có" — automate nó, đừng dựa vào discipline.

Tags:
Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy