Published on
🍵 4 min read

Implementing Single Database Multi-Tenancy with EF Core Global Query Filters

Authors

Photo

Overview

TL;DR

This blog post shows how to set up multi-tenancy in a single database using Entity Framework Core. It explains how to create a TenantProvider, configure the DbContext, set up middleware for tenant ID extraction

Introduction

Multi-tenancy is a design pattern where a single instance of an application serves multiple tenants. This blog post will demonstrate how to implement single database multi-tenancy using Entity Framework Core with global query filters

Example

1 - Define the Tenant Provider

Create a TenantProvider to hold the current tenant’s ID

public class TenantProvider
{
    public int TenantId { get; set; }
}

2 - Configure the DbContext

Add the global query filter to your DbContext to ensure tenant-specific data is automatically filtered

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    private readonly TenantProvider _tenantProvider;
    public AppDbContext(DbContextOptions<AppDbContext> options, TenantProvider tenantProvider)
        : base(options)
    {
        _tenantProvider = tenantProvider;
    }

    public virtual DbSet<CatalogEntity> Catalogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<CatalogEntity>()
            .HasQueryFilter(y => y.TenantId == _tenantProvider.TenantId);
    }
}

3 - Middleware for Tenant

Create middleware to extract the x-tenant-id header and set it in the TenantProvider

public class TenantMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context, TenantProvider tenantProvider)
    {
        if (context.Request.Headers.TryGetValue("x-tenant-id", out var tenantIdStr) &&
            int.TryParse(tenantIdStr, out var tenantId))
        {
            tenantProvider.TenantId = tenantId;
        }
        else
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Tenant ID is missing.");
            return;
        }

        await next(context);
    }
}

4 - Register Services


builder.Services.AddSingleton<TenantProvider>();
builder.Services.AddDbContext<AppDbContext>(
    options => options.UseInMemoryDatabase("catalog-api")
);

....

app.UseMiddleware<TenantMiddleware>();

5 - Create Endpoints


app.MapPost("/create", async ([FromBody] CreateCatalogRequestModel request, AppDbContext dbContext, TenantProvider tenantProvider) =>
{
    var catalogEntity = new CatalogEntity
    {
        Name = request.Name,
        TenantId = tenantProvider.TenantId
    };

    await dbContext.Catalogs.AddAsync(catalogEntity);
    await dbContext.SaveChangesAsync();
    return Results.Ok();
});

app.MapGet("/get", async (AppDbContext dbContext) =>
{
    var catalogEntities = await dbContext.Catalogs.ToListAsync();
    return Results.Ok(catalogEntities);
});

Test

Here are example cURL requests:

curl -X POST -H "x-tenant-id: 1" -H "Content-Type: application/json" \
     -d '{"name": "Sample for tenant id 1"}' \
     http://localhost:3000/create
tenant-id-1-create
curl -H "x-tenant-id: 1" http://localhost:3000/get
tenant-id-1

As you can see, we get the records created with x-tenant-id: 1

Now, let's create another record with the x-tenant-id: 2 header:

curl -X POST -H "x-tenant-id: 2" -H "Content-Type: application/json" \
     -d '{"name": "Sample for tenant id 2"}' \
     http://localhost:3000/create
curl -H "x-tenant-id: 2" http://localhost:3000/get
tenant-id-2

As you can see, only the records from x-tenant-id: 2 are returned because of the global query filter.

Conclusion

In this post, we covered implementing multi-tenancy with Entity Framework Core global query filters. While we used headers here, the tenant ID can also come from tokens or query parameters, depending on your needs.

For code examples, check out the project here: GitHub - catalog-api.

Frequently Asked Questions