DEV Community

Cover image for Mastering Multi-Tenant Architecture with .NET 8 Minimal APIs: A Complete Guide for Scalable SaaS Applications
Leandro Veiga
Leandro Veiga

Posted on

Mastering Multi-Tenant Architecture with .NET 8 Minimal APIs: A Complete Guide for Scalable SaaS Applications

As businesses grow, the need to support multiple clients or tenants within the same application becomes critical. Multi-tenant architecture allows you to serve multiple customers (tenants) from a single instance of your application while keeping their data separate. In this post, I’ll guide you through the steps to build a multi-tenant architecture for Minimal APIs using .NET 8.

This architecture is essential for SaaS applications, where tenants can be organizations, users, or departments that share the application but have isolated data and configurations.


What is Multi-Tenant Architecture?

In a multi-tenant architecture, a single instance of an application serves multiple tenants, each having its own isolated data, preferences, or configurations. There are various approaches to implement multi-tenancy:

  1. Database per Tenant: Each tenant has its own separate database.
  2. Schema per Tenant: A single database with separate schemas for each tenant.
  3. Shared Database with Tenant IDs: A single database where tenant data is segregated by unique tenant identifiers.

For this blog post, we’ll focus on the Shared Database with Tenant IDs approach, which is commonly used due to its simplicity and scalability.


1. Setting Up the Tenant Context

The first step is to identify the tenant in every request. This can be done by passing a tenant identifier (such as a Tenant ID) in the HTTP headers, query string, or even the URL.

Adding Tenant Middleware

You can create middleware that extracts the tenant information from the incoming request and makes it available for the rest of the application.

public class TenantMiddleware
{
    private readonly RequestDelegate _next;

    public TenantMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Extract tenant ID from headers (or another source)
        var tenantId = context.Request.Headers["X-Tenant-ID"].FirstOrDefault();

        if (tenantId == null)
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("Tenant ID is missing.");
            return;
        }

        // Store the tenant ID in the request context
        context.Items["TenantId"] = tenantId;

        await _next(context);
    }
}

// Register middleware in Program.cs
app.UseMiddleware<TenantMiddleware>();
Enter fullscreen mode Exit fullscreen mode

This middleware checks for the tenant identifier in the request headers (or other sources) and stores it in the request context for use in other parts of the application.


2. Configuring a Multi-Tenant Database

For a shared database model, we need to make sure that all data operations are scoped by the Tenant ID. This can be done by adding a tenant filter in your DbContext to ensure that each query and operation is limited to the tenant’s data.

Tenant-Aware DbContext

In the OnModelCreating method, we can add a query filter to automatically apply the tenant scope to all entities.

public class TenantDbContext : DbContext
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenantDbContext(DbContextOptions<TenantDbContext> options, IHttpContextAccessor httpContextAccessor)
        : base(options)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var tenantId = _httpContextAccessor.HttpContext.Items["TenantId"]?.ToString();

        // Add global query filter for tenant-specific data
        modelBuilder.Entity<YourEntity>().HasQueryFilter(e => e.TenantId == tenantId);
    }
}
Enter fullscreen mode Exit fullscreen mode

By adding a global query filter, we ensure that each query executed against the database is automatically scoped to the tenant, preventing data leaks across tenants.


3. Isolating Data by Tenant

For a shared database architecture, every table that stores tenant-specific data should have a Tenant ID column. This column allows us to filter data for each tenant and ensure isolation.

Example of Tenant-Aware Entity

public class YourEntity
{
    public int Id { get; set; }
    public string TenantId { get; set; } // Tenant-specific column
    public string Data { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Each entity contains a TenantId that ensures data is tied to a specific tenant. This Tenant ID is used in the query filter to ensure that each tenant only accesses its own data.


4. Routing Based on Tenant

You can also define routes that take the Tenant ID into account. This is useful for services where the tenant’s context should be part of the URL structure.

Example of Tenant-Aware Routing

app.MapGet("/api/{tenantId}/data", (string tenantId, YourEntityDbContext dbContext) =>
{
    // Fetch tenant-specific data
    var data = dbContext.YourEntities.Where(e => e.TenantId == tenantId).ToList();
    return Results.Ok(data);
});
Enter fullscreen mode Exit fullscreen mode

This pattern ensures that the Tenant ID is part of the route, providing another way to scope the request to the appropriate tenant. Alternatively, this can be handled via headers or query parameters.


5. Securing Multi-Tenant APIs

Security is crucial in a multi-tenant environment. You need to ensure that:

  • Authentication: Only authorized users can access a tenant’s data.
  • Authorization: Users are restricted to their own tenant’s resources.

For token-based authentication, you can use OAuth2 or OpenID Connect. Ensure that the JWT contains the Tenant ID as a claim, and validate this claim for every request.

services.AddAuthorization(options =>
{
    options.AddPolicy("TenantPolicy", policy =>
    {
        policy.RequireClaim("TenantId");
    });
});

app.MapGet("/api/data", [Authorize(Policy = "TenantPolicy")] async (YourEntityDbContext dbContext) =>
{
    // Your tenant-specific logic here
    return Results.Ok();
});
Enter fullscreen mode Exit fullscreen mode

By implementing tenant-aware authorization policies, you ensure that users can only access their own tenant’s data.


6. Automating Tenant Provisioning

A critical aspect of multi-tenancy is the ability to provision new tenants dynamically. When a new tenant signs up, you may need to:

  • Create tenant-specific records in the database.
  • Initialize tenant-specific configurations or features.
  • Assign a unique Tenant ID that will be used for scoping data and requests.

Here’s an example of automating tenant creation:

app.MapPost("/api/tenants", async (TenantDbContext dbContext, Tenant newTenant) =>
{
    // Add new tenant to the database
    dbContext.Tenants.Add(newTenant);
    await dbContext.SaveChangesAsync();

    return Results.Created($"/api/tenants/{newTenant.Id}", newTenant);
});
Enter fullscreen mode Exit fullscreen mode

7. Monitoring and Logging in a Multi-Tenant System

Logging and monitoring become more complex in a multi-tenant environment. To troubleshoot issues or monitor usage, it’s essential to include Tenant IDs in your logs. Use tools like Serilog or NLog to inject Tenant IDs into every log entry.

app.Use(async (context, next) =>
{
    LogContext.PushProperty("TenantId", context.Items["TenantId"]);
    await next.Invoke();
});
Enter fullscreen mode Exit fullscreen mode

With tenant-aware logging, you can filter logs by tenant, making it easier to diagnose issues specific to a particular tenant.


Conclusion

Building a multi-tenant architecture for Minimal APIs in .NET 8 requires careful design to ensure data isolation, security, and scalability. By leveraging features like middleware for tenant resolution, global query filters, and tenant-aware routing, you can efficiently serve multiple tenants while keeping the architecture streamlined and performant.

This architecture is ideal for SaaS applications or any system that requires tenant-level isolation without the overhead of managing separate instances. By following these best practices, you can ensure that your application scales to meet the needs of multiple tenants while maintaining security and performance.

Top comments (0)