.NET Tools How-To's

Multi-tenant Apps With EF Core and ASP.​NET Core

Software-as-a-Service (SaaS) like JetBrains Space, YouTrack, and TeamCity Cloud are built around the concept of multi-tenancy. Many other services you are using every day probably are, too! Instead of spinning up a dedicated server for every customer, these services often share server resources while keeping configuration, data and user accounts for that service separate and isolated. Much like time-sharing a vacation rental, all tenants can use the swimming pool, but they have their separate set of towels.

In this post, we will explore what multi-tenancy means, why you may consider it when building an application, and how to implement it with the tools we’re all familiar with: Entity Framework Core and ASP.NET Core.

What is multi-tenancy?

At its core, multi-tenancy is an architecture where one codebase serves multiple customers while maintaining data isolation. To customers, it feels like they have their own copy of the software running, while the application really is just one deployment. The focus of multi-tenancy can vary from security, maintainability, reduced infrastructural complexity, and improved resource utilization.

Tenant is a catch-all term, and teams define what it means to them. A tenant can be a user, an organization, or other logical groupings. Although with many SaaS out there, you’ll see an organization is usually the boundary for specifying a tenant.

Teams wanting to adopt multi-tenancy typically have to design applications with the concept upfront. While you can bring multi-tenancy into existing applications, it makes it easier to build solutions when multi-tenancy is considered foundational. Multi-tenancy typically touches every aspect of an application from authentication and authorization, business logic, database schema, and isolation, and sometimes even elements users won’t see like the hosting environment.

The ultimate goal of multi-tenancy is to significantly reduce an area of complexity, with the trade-off being some additional complexity. So first, let’s look at some examples of multi-tenancy approaches and their advantages and disadvantages. We’ll be implementing these later with Entity Framework Core and ASP.NET Core.

Multiple Tenants, Same Infrastructure

The most common approach to multi-tenancy is to group all tenants into a single instance, with mechanisms to separate all groups. The separation mechanism is typically logical, with code paths understanding what group is making a request and applying the necessary filters to reduce access to only relevant information.

Advantages to this approach are plenty: you can share infrastructure, a single codebase, and ultimately one understanding across a team. One deployment updates all tenants to a newer version. Finally, running the approach can reduce hosting costs, eliminating the need for additional hosting servers and dependencies, saving a business’ operational budget.

The most notable disadvantage of this approach is the overhead of managing logical barriers between tenants. In addition, the method can add complexity to the codebase, require more data modeling, and ultimately sharing resources can exhaust allocated infrastructure more quickly. An ill-designed feature can jeopardize your entire user base and trust in your application.

Multiple Tenants, Different Infrastructure

Instead of isolating tenants logically, you may want to consider separating them physically. The approach is where you have different infrastructural dependencies based on the logical group accessing the application.

It becomes almost impossible for one tenant to access the resources of another tenant. You have different servers for all dependencies, but typically most developers will separate storage for each tenant. The physical separation can reduce the cognitive complexity of accessing data, as there is no need for additional filters in code. Finally, significantly smaller instances of databases can also visibly impact performance, with no other tenants using up valuable resources.

The challenge to this approach is your team managing different variants of the same application. In times of crisis, isolating which tenant may or not be in trouble can be challenging. In addition, each tenant is a replica, costing you multiples of what it may cost to host a single instance. Another factor to consider is the chance for feature drift among tenants. One customer asks for a unique feature, then another, and one more. Before you know it, you are managing multiple codebases.

Both approaches can also be combined: a single infrastructure for all tenants that runs the application, with isolated infrastructure such as dedicated storage and a separate database per tenant. While you may be wondering which approach is best, the “best” depends on your situation with considerations of risk, compliance with local regulations, and practicality. Consider your context, then make an educated decision.

Now that you have a basic understanding of multi-tenancy let’s look at some ASP.NET Core and EF Core examples that explore both options.

Single Database Multi-tenancy with EF Core

At the end of this section, we aim to have an endpoint in an ASP.NET Core application that retrieves a tenant’s data. To build a complete example, we need a mechanism that understands who the user is, which tenant they are attempting to access, and one more element that filters the data. You can change these three parts based on your use case, but for the sake of simplicity, we will be using the following techniques:

  • A query string parameter of tenant set by the user
  • An ASP.NET Core middleware that reads and sets the tenant based on the parameter
  • An EF Core DbContext that uses the tenant to filter queries

Let’s start with the database. Like most .NET applications powered by Entity Framework Core, you’ll have at least one DbContext. In our case, we have a Database class that defines our database model and configuration.

using Microsoft.EntityFrameworkCore;

namespace EntityFrameworkCoreMultiTenancy;

public class Database : DbContext
{
    private readonly string tenant;

    public DbSet<Animal> Animals { get; set; } = default!;

    public Database(DbContextOptions<Database> options, ITenantGetter tenantGetter)
        : base(options)
    {
        tenant = tenantGetter.Tenant;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder
            .Entity<Animal>()
            .HasQueryFilter(a => a.Tenant == tenant)
            .HasData(
                new() {Id = 1, Kind = "Dog", Name = "Samson", Tenant = "Khalid"},
                new() {Id = 2, Kind = "Dog", Name = "Guinness", Tenant = "Khalid"},
                new() {Id = 3, Kind = "Cat", Name = "Grumpy Cat", Tenant = "Internet"},
                new() {Id = 4, Kind = "Cat", Name = "Mr. Bigglesworth", Tenant = "Internet"}
            );
    }
}

public class Animal
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Kind { get; set; } = string.Empty;
    public string Tenant { get; set; } = Tenants.Internet;
}

There are two essential items in the DbContext code. First, it’s the constructor of our Database class, which takes two arguments. 

There’s the DbOptions instance, which allows us to set our database options. The creation of this instance comes from our services collection, typically defined at the start of the program. We’ll see this later in the post. There’s also our ITenantGetter service, which informs us which tenant we are operating within. We’ll use these parameters in the next method, OnModelCreating.

During the instantiation of Database, our service locator will invoke the OnModelCreating, allowing us to change the tenant and apply the correct value to HasQueryFilter. Applying HasQueryFilter adds an implicit filter to all queries that use the entity of Animal, which means we have to worry about one less thing as we write our application. It’s important to apply such a query filter to any tenant-focused entities and their associated DbSet property to ensure proper data isolation. So, how do we get the tenant value?

As we move to our Program class, we’ll see registrations for our implementations. Note that you can find more of this code in the sample repository at the end of this article.

using EntityFrameworkCoreMultiTenancy;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScopedAs<TenantService>(new[] {
    typeof(ITenantGetter),
    typeof(ITenantSetter)
});

builder.Services.AddScoped<MultiTenantServiceMiddleware>();
builder.Services.AddDbContext<Database>(db => {
    db.UseSqlite("Data Source=multi-tenant.db");
});

We register the TenantService and the two interfaces of ITenantGetter and ITenantSetter. I chose to separate the interfaces for more apparent intent, but you could just as easily have a single service. So what does the TenantService do?

namespace EntityFrameworkCoreMultiTenancy;

public static class Tenants
{
    public const string Internet = nameof(Internet);
    public const string Khalid = nameof(Khalid);

    public static IReadOnlyCollection<string> All = new[] {Internet, Khalid};

    public static string Find(string? value)
    {
        return All.FirstOrDefault(t => t.Equals(value?.Trim(), StringComparison.OrdinalIgnoreCase)) ?? Internet;
    }
}

public class TenantService : ITenantGetter, ITenantSetter
{
    public string Tenant { get; private set; } = Tenants.Internet;

    public void SetTenant(string tenant)
    {
        Tenant = tenant;
    }
}

public interface ITenantGetter 
{
    string Tenant { get; }
}

public interface ITenantSetter
{
    void SetTenant(string tenant);
}

The service doesn’t do any more than holding the tenant value for the lifetime of the user request, which allows other objects to understand the scope of their work. In our case, the Database will receive the tenant value when our service locator creates it. So, what uses the TenantService? The MultiTenantServiceMiddleware of course.

namespace EntityFrameworkCoreMultiTenancy;

public class MultiTenantServiceMiddleware : IMiddleware
{
    private readonly ITenantSetter setter;

    public MultiTenantServiceMiddleware(ITenantSetter setter)
    {
        this.setter = setter;
    }
    
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        if (context.Request.Query.TryGetValue("tenant", out var values))
        {
            var tenant = Tenants.Find(values.FirstOrDefault());
            setter.SetTenant(tenant);
        }
        else
        {
            // set default tenant
            setter.SetTenant(Tenants.Internet);
        }

        await next(context);
    }
}

The middleware currently tries to find a tenant query parameter and sets the value using the ITenantSetter instance. For the purposes of this post, the query parameter gives us the ability to experiment more easily transitioning between tenants. You’d likely read tenant information from a secure and encrypted authentication cookie in a production application to avoid user tampering. Or use the current hostname to distinguish between tenants.

When no tenant is found, the middleware uses a standard tenant. Instead of doing this, you could also implement a redirect to the marketing website where users can sign up to create a new tenant on your service.

Finally, let’s complete the contents of our Program.cs file.

using EntityFrameworkCoreMultiTenancy;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScopedAs<TenantService>(new[] {
    typeof(ITenantGetter),
    typeof(ITenantSetter)
});

builder.Services.AddScoped<MultiTenantServiceMiddleware>();
builder.Services.AddDbContext<Database>(db => {
    db.UseSqlite("Data Source=multi-tenant.db");
});
var app = builder.Build();

// initialize the database
using (var scope = app.Services.CreateScope()) {
    var db = scope.ServiceProvider.GetRequiredService<Database>();
    await db.Database.MigrateAsync();    
}

// middleware that reads and sets the tenant
app.UseMiddleware<MultiTenantServiceMiddleware>();

// multi-tenant request, try adding ?tenant=Khalid or ?tenant=Internet (default)
app.MapGet("/", async (Database db) => await db
    .Animals
    // hide the tenant, which is response noise
    .Select(x => new { x.Id, x.Name, x.Kind })
    .ToListAsync());

app.Run();

Great! Now, as you start to make requests to the root endpoint, we’ll either fall back to the default tenant of Internet or can move to the Khalid tenant.

We just completed logical multi-tenancy, but what about physical multi-tenancy? Well, we’ll explore that in the next section.

Isolated Databases Multi-tenancy with EF Core

When it comes to moving tenants into different databases, it requires a few changes to the application seen above. Some of the changes required include:

  • Moving tenants to configuration along with connection strings
  • Altering the middleware to find the tenant from configuration
  • Modifying the TenantService to use an object rather than key

Let’s first start with the configuration found in our sample project. We are using applicationSettings.json to define our tenants, but a production application may choose to represent tenants during the deployment process or the infrastructure building phase of development. Therefore, the source of tenant information is not as important as the information itself. After looking at the code, decide where you’d prefer to store and manage it for your use case.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Tenants" : [
    { "Name":  "Internet", "ConnectionString":  "Data Source=internet.db" },
    { "Name":  "Khalid", "ConnectionString":  "Data Source=khalid.db" }
  ],
  "AllowedHosts": "*"
}

Notice we have two items in our Tenants collection, each with its unique database connection string. We’ll be using this information in our updated MultiTenantServiceMiddleware class. Let’s see how.

using Microsoft.Extensions.Options;

namespace EntityFrameworkCoreMultiTenancy;

public class MultiTenantServiceMiddleware : IMiddleware
{
    private readonly ITenantSetter setter;
    private readonly IOptions<TenantConfigurationSection> config;
    private readonly ILogger<MultiTenantServiceMiddleware> logger;

    public MultiTenantServiceMiddleware(
        ITenantSetter setter, 
        IOptions<TenantConfigurationSection> config, 
        ILogger<MultiTenantServiceMiddleware> logger)
    {
        this.setter = setter;
        this.config = config;
        this.logger = logger;
    }
    
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var tenant = config.Value.Tenants.First();
        
        if (context.Request.Query.TryGetValue("tenant", out var values))
        {
            var key = values.First();
            tenant = config.Value
                .Tenants
                .FirstOrDefault(t => t.Name.Equals(key?.Trim(), StringComparison.OrdinalIgnoreCase)) ?? tenant;
        }

        logger.LogInformation("Using the tenant {tenant}", tenant.Name);
        setter.SetTenant(tenant);
        
        await next(context);
    }
}

The new implementation differs from the previous implementation by dealing with a Tenant object rather than a string identifier. Our object contains the name and the connection string to our database. To take advantage of our new connection string, we need to modify our DbContextOptions. We can do that back in our Program.cs file. We have to include a default connection string to continue to generate migrations, but that will be unused while our application is running.

builder.Services.AddDbContext<Database>((s, o) =>
{
    var tenant = s.GetService<ITenantGetter>()?.Tenant;
    // for migrations
    var connectionString = tenant?.ConnectionString ?? "Data Source=default.db";
    // multi-tenant databases
    o.UseSqlite(connectionString);
});

I’ll include the entire file to clarify where to place the updates in your existing application.

using EntityFrameworkCoreMultiTenancy;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// tenant setter & getter
builder.Services.AddScopedAs<TenantService>(new[] {typeof(ITenantGetter), typeof(ITenantSetter)});

// IOptions version of tenants
builder.Services.Configure<TenantConfigurationSection>(builder.Configuration);

// middleware that sets the current tenant
builder.Services.AddScoped<MultiTenantServiceMiddleware>();
builder.Services.AddDbContext<Database>((s, o) =>
{
    var tenant = s.GetService<ITenantGetter>()?.Tenant;
    // for migrations
    var connectionString = tenant?.ConnectionString ?? "Data Source=default.db";
    // multi-tenant databases
    o.UseSqlite(connectionString);
});

var app = builder.Build();
await Database.Initialize(app);

// middleware that reads and sets the tenant
app.UseMiddleware<MultiTenantServiceMiddleware>();

// multi-tenant request, try adding ?tenant=Khalid or ?tenant=Internet (default)
app.MapGet("/", async (Database db) => await db
    .Animals
    // hide the tenant, which is response noise
    .Select(x => new {x.Id, x.Name, x.Kind})
    .ToListAsync());

app.Run();

This approach works because every time we make a request, our Database instance is created based on the information within the user request, allowing us to swap the connection string.

Conclusion

In this post, we saw two ways in which you can use Entity Framework Core and ASP.NET Core to build multi-tenant applications. One approach uses delimiters and query filters to limit the data users see, while the other puts user data into separate physical storage. 

Both approaches have their advantages and disadvantages, so you’ll have to pick the one that works best for you. You’ll also want to consider alternative differentiation mechanisms for switching between tenants, have it be auth, domain names, or other important factors in your application.

If you’d like to try this code out for yourself, you can clone the complete multi-tenancy with Entity Framework Core and ASP.NET Core GitHub repository. You’ll want to switch between branches to see both implementations.

I hope you enjoyed this post, and please let me know your thoughts and experiences building multi-tenant applications in .NET.

Discover more