Multi-tenancy
Many line of business applications are designed to work with multiple customers. It is important to secure the data so that customer data isn't "leaked" or seen by other customers and potential competitors. These applications are classified as "multi-tenant" because each customer is considered a tenant of the application with their own set of data.
Important
This document provides examples and solutions "as is." These are not intended to be "best practices" but rather "working practices" for your consideration.
Tip
You can view the source code for this sample on GitHub
Supporting multi-tenancy
There are many approaches to implementing multi-tenancy in applications. One common approach (that is sometimes a requirement) is to keep data for each customer in a separate database. The schema is the same but the data is customer-specific. Another approach is to partition the data in an existing database by customer. This can be done by using a column in a table, or having a table in multiple schemas with a schema for each tenant.
Approach | Column for Tenant? | Schema per Tenant? | Multiple Databases? | EF Core Support |
---|---|---|---|---|
Discriminator (column) | Yes | No | No | Global query filter |
Database per tenant | No | No | Yes | Configuration |
Schema per tenant | No | Yes | No | Not supported |
For the database-per-tenant approach, switching to the right database is as simple as providing the correct connection string. When the data is stored in a single database, a global query filter can be used to automatically filter rows by the tenant ID column, ensuring that developers don't accidentally write code that can access data from other customers.
These examples should work fine in most app models, including console, WPF, WinForms, and ASP.NET Core apps. Blazor Server apps require special consideration.
Blazor Server apps and the life of the factory
The recommended pattern for using Entity Framework Core in Blazor apps is to register the DbContextFactory, then call it to create a new instance of the DbContext
each operation. By default, the factory is a singleton so only one copy exists for all users of the application. This is usually fine because although the factory is shared, the individual DbContext
instances are not.
For multi-tenancy, however, the connection string may change per user. Because the factory caches the configuration with the same lifetime, this means all users must share the same configuration. Therefore, the lifetime should be changed to Scoped
.
This issue doesn't occur in Blazor WebAssembly apps because the singleton is scoped to the user. Blazor Server apps, on the other hand, present a unique challenge. Although the app is a web app, it is "kept alive" by real-time communication using SignalR. A session is created per user and lasts beyond the initial request. A new factory should be provided per user to allow new settings. The lifetime for this special factory is scoped and a new instance is created per user session.
An example solution (single database)
A possible solution is to create a simple ITenantService
service that handles setting the user's current tenant. It provides callbacks so code is notified when the tenant changes. The implementation (with the callbacks omitted for clarity) might look like this:
namespace Common
{
public interface ITenantService
{
string Tenant { get; }
void SetTenant(string tenant);
string[] GetTenants();
event TenantChangedEventHandler OnTenantChanged;
}
}
The DbContext
can then manage the multi-tenancy. The approach depends on your database strategy. If you are storing all tenants in a single database, you are likely going to use a query filter. The ITenantService
is passed to the constructor via dependency injection and used to resolve and store the tenant identifier.
public ContactContext(
DbContextOptions<ContactContext> opts,
ITenantService service)
: base(opts) => _tenant = service.Tenant;
The OnModelCreating
method is overridden to specify the query filter:
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<MultitenantContact>()
.HasQueryFilter(mt => mt.Tenant == _tenant);
This ensures that every query is filtered to the tenant on every request. There is no need to filter in application code because the global filter will be automatically applied.
The tenant provider and DbContextFactory
are configured in the application startup like this, using Sqlite as an example:
builder.Services.AddDbContextFactory<ContactContext>(
opt => opt.UseSqlite("Data Source=singledb.sqlite"), ServiceLifetime.Scoped);
Notice that the service lifetime is configured with ServiceLifetime.Scoped
. This enables it to take a dependency on the tenant provider.
Note
Dependencies must always flow towards the singleton. That means a Scoped
service can depend on another Scoped
service or a Singleton
service, but a Singleton
service can only depend on other Singleton
services: Transient => Scoped => Singleton
.
Multiple schemas
Warning
This scenario is not directly supported by EF Core and is not a recommended solution.
In a different approach, the same database may handle tenant1
and tenant2
by using table schemas.
- Tenant1 -
tenant1.CustomerData
- Tenant2 -
tenant2.CustomerData
If you are not using EF Core to handle database updates with migrations and already have multi-schema tables, you can override the schema in a DbContext
in OnModelCreating
like this (the schema for table CustomerData
is set to the tenant):
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<CustomerData>().ToTable(nameof(CustomerData), tenant);
Multiple databases and connection strings
The multiple database version is implemented by passing a different connection string for each tenant. This can be configured at startup by resolving the service provider and using it to build the connection string. A connection string by tenant section is added to the appsettings.json
configuration file.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"TenantA": "Data Source=tenantacontacts.sqlite",
"TenantB": "Data Source=tenantbcontacts.sqlite"
},
"AllowedHosts": "*"
}
The service and configuration are both injected into the DbContext
:
public ContactContext(
DbContextOptions<ContactContext> opts,
IConfiguration config,
ITenantService service)
: base(opts)
{
_tenantService = service;
_configuration = config;
}
The tenant is then used to look up the connection string in OnConfiguring
:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var tenant = _tenantService.Tenant;
var connectionStr = _configuration.GetConnectionString(tenant);
optionsBuilder.UseSqlite(connectionStr);
}
This works fine for most scenarios unless the user can switch tenants during the same session.
Switching tenants
In the previous configuration for multiple databases, the options are cached at the Scoped
level. This means that if the user changes the tenant, the options are not reevaluated and so the tenant change isn't reflected in queries.
The easy solution for this when the tenant can change is to set the lifetime to Transient.
This ensures the tenant is re-evaluated along with the connection string each time a DbContext
is requested. The user can switch tenants as often as they like. The following table helps you choose which lifetime makes the most sense for your factory.
Scenario | Single database | Multiple databases |
---|---|---|
User stays in a single tenant | Scoped |
Scoped |
User can switch tenants | Scoped |
Transient |
The default of Singleton
still makes sense if your database does not take on user-scoped dependencies.
Performance notes
EF Core was designed so that DbContext
instances can be instantiated quickly with as little overhead as possible. For that reason, creating a new DbContext
per operation should usually be fine. If this approach is impacting the performance of your application, consider using DbContext pooling.
Conclusion
This is working guidance for implementing multi-tenancy in EF Core apps. If you have further examples or scenarios or wish to provide feedback, please open an issue and reference this document.