Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multitenancy #3632

Merged
merged 19 commits into from
Mar 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions entity-framework/core/miscellaneous/multitenancy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
title: Multi-tenancy - EF Core
description: Learn several ways to implement multi-tenant databases using Entity Framework Core.
author: jeremylikness
ms.author: jeliknes
ms.date: 03/01/2022
uid: core/miscellaneous/multitenancy
---
# 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](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/Multitenancy)

## 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](/ef/core/querying/filters) 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](/aspnet/core/blazor/blazor-server-ef-core) is to register the [DbContextFactory](/ef/core/dbcontext-configuration/#using-a-dbcontext-factory-eg-for-blazor), 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:

:::code language="csharp" source="../../../samples/core/Miscellaneous/Multitenancy/Common/ITenantService.cs":::

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.

:::code language="csharp" source="../../../samples/core/Miscellaneous/Multitenancy/SingleDbSingleTable/Data/ContactContext.cs" range="10-13":::

The `OnModelCreating` method is overridden to specify the query filter:

:::code language="csharp" source="../../../samples/core/Miscellaneous/Multitenancy/SingleDbSingleTable/Data/ContactContext.cs" range="31-33":::

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:

:::code language="csharp" source="../../../samples/core/Miscellaneous/Multitenancy/SingleDbSingleTable/Program.cs" range="13-14":::

Notice that the [service lifetime](/dotnet/core/extensions/dependency-injection#service-lifetimes) 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

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):

```csharp
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<CustomerData>().ToTable(nameof(CustomerData), tenant);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work? Doesn't the model get built once (the first time), meaning that subsequent contexts with different tenant IDs will still use the initial tenant ID for the schema? Unless I'm mistaken, this requires doing "multiple models with the same DbContext type" (see docs), i.e. fiddling around with IModelCacheKeyFactory.

/cc @ajcvickers

```

> [!WARNING]
> This scenario is not directly supported by EF Core and is not a recommended solution.

## 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.

:::code language="json" source="../../../samples/core/Miscellaneous/Multitenancy/MultiDb/appsettings.json":::

The service and configuration are both injected into the `DbContext`:

:::code language="csharp" source="../../../samples/core/Miscellaneous/Multitenancy/MultiDb/ContactContext.cs" range="11-19":::

The tenant is then used to look up the connection string in `OnConfiguring`:

:::code language="csharp" source="../../../samples/core/Miscellaneous/Multitenancy/MultiDb/ContactContext.cs" range="40-45":::

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.
JeremyLikness marked this conversation as resolved.
Show resolved Hide resolved

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](xref:core/performance/advanced-performance-topics).

## 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](https://github.com/dotnet/EntityFramework.Docs/issues/new) and reference this document.
3 changes: 3 additions & 0 deletions entity-framework/core/querying/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ The following example shows how to use Global Query Filters to implement multi-t
> [!TIP]
> You can view this article's [sample](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Querying/QueryFilters) on GitHub.

> [!NOTE]
> Multi-tenancy is used here as a simple example. There is also an article with comprehensive guidance for [multi-tenancy in EF Core applications](xref:core/miscellaneous/multitenancy).

First, define the entities:

[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/Entities.cs#Entities)]
Expand Down
4 changes: 4 additions & 0 deletions entity-framework/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@
href: core/miscellaneous/connection-resiliency.md
- name: Connection strings
href: core/miscellaneous/connection-strings.md
- name: Context pooling
JeremyLikness marked this conversation as resolved.
Show resolved Hide resolved
href: core/miscellaneous/context-pooling.md
- name: Support multi-tenant databases
href: core/miscellaneous/multitenancy.md

- name: Database providers
items:
Expand Down
1 change: 1 addition & 0 deletions samples/core/Miscellaneous/Multitenancy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/*.sqlite*
9 changes: 9 additions & 0 deletions samples/core/Miscellaneous/Multitenancy/Common/Common.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
32 changes: 32 additions & 0 deletions samples/core/Miscellaneous/Multitenancy/Common/Contact.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Common;
public class Contact
{
public Guid Id { get; set; }
public string Name {get; set;}
public bool IsUnicorn {get; set; }

public static Contact[] GeneratedContacts =>
new []
{
new Contact
{
Name = "Magic Unicorns",
IsUnicorn = true
},
new Contact
{
Name = "Unicorns Running",
IsUnicorn = true
},
new Contact
{
Name = "SQL Server DBA",
IsUnicorn = false
},
new Contact
{
Name = "Azure Cosmos DB Admin",
IsUnicorn = false
}
};
}
13 changes: 13 additions & 0 deletions samples/core/Miscellaneous/Multitenancy/Common/ITenantService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Common
{
public interface ITenantService
{
string Tenant { get; }

void SetTenant(string tenant);

string[] GetTenants();

event TenantChangedEventHandler OnTenantChanged;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Common
{
public class TenantChangedEventArgs : EventArgs
{
public TenantChangedEventArgs(string? oldTenant, string newTenant)
{
OldTenant = oldTenant;
NewTenant = newTenant;
}

public string? OldTenant { get; private set; }

public string NewTenant { get; private set; }
}
}
32 changes: 32 additions & 0 deletions samples/core/Miscellaneous/Multitenancy/Common/TenantService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Common
{
public delegate void TenantChangedEventHandler(object source, TenantChangedEventArgs args);
public class TenantService : ITenantService
{
public TenantService() => _tenant = GetTenants()[0];

public TenantService(string tenant) => _tenant = tenant;

private string _tenant;

public event TenantChangedEventHandler OnTenantChanged = null!;

public string Tenant => _tenant;

public void SetTenant(string tenant)
{
if (tenant != _tenant)
{
var old = _tenant;
_tenant = tenant;
OnTenantChanged?.Invoke(this, new TenantChangedEventArgs(old, _tenant));
}
}

public string[] GetTenants() => new[]
{
"TenantA",
"TenantB",
};
}
}
12 changes: 12 additions & 0 deletions samples/core/Miscellaneous/Multitenancy/MultiDb/App.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
47 changes: 47 additions & 0 deletions samples/core/Miscellaneous/Multitenancy/MultiDb/ContactContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Common;
using Microsoft.EntityFrameworkCore;

namespace MultiDb
{
public class ContactContext : DbContext
{
private readonly ITenantService _tenantService;
private readonly IConfiguration _configuration;

public ContactContext(
DbContextOptions<ContactContext> opts,
IConfiguration config,
ITenantService service)
: base(opts)
{
_tenantService = service;
_configuration = config;
}

public DbSet<Contact> Contacts { get; set; } = null!;

public void CheckAndSeed()
{
if (Database.EnsureCreated())
{
foreach (var contact in Contact.GeneratedContacts)
{
var isTenantA = _tenantService.Tenant == "TenantA";
if (isTenantA == contact.IsUnicorn)
{
Contacts.Add(contact);
}
}

SaveChanges();
}
}

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var tenant = _tenantService.Tenant;
var connectionStr = _configuration.GetConnectionString(tenant);
optionsBuilder.UseSqlite(connectionStr);
}
}
}
18 changes: 18 additions & 0 deletions samples/core/Miscellaneous/Multitenancy/MultiDb/MultiDb.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\TenantControls\TenantControls.csproj" />
</ItemGroup>

</Project>
37 changes: 37 additions & 0 deletions samples/core/Miscellaneous/Multitenancy/MultiDb/MultiDb.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.31911.260
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDb", "MultiDb.csproj", "{A5F0571D-647C-4100-AADF-AECDDF9F02FA}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "..\Common\Common.csproj", "{C2E619F6-2404-429D-8966-DB89D3A29314}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TenantControls", "..\TenantControls\TenantControls.csproj", "{640C6DAF-5C00-463C-8E54-9D042C6F1C23}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A5F0571D-647C-4100-AADF-AECDDF9F02FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5F0571D-647C-4100-AADF-AECDDF9F02FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5F0571D-647C-4100-AADF-AECDDF9F02FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5F0571D-647C-4100-AADF-AECDDF9F02FA}.Release|Any CPU.Build.0 = Release|Any CPU
{C2E619F6-2404-429D-8966-DB89D3A29314}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C2E619F6-2404-429D-8966-DB89D3A29314}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C2E619F6-2404-429D-8966-DB89D3A29314}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C2E619F6-2404-429D-8966-DB89D3A29314}.Release|Any CPU.Build.0 = Release|Any CPU
{640C6DAF-5C00-463C-8E54-9D042C6F1C23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{640C6DAF-5C00-463C-8E54-9D042C6F1C23}.Debug|Any CPU.Build.0 = Debug|Any CPU
{640C6DAF-5C00-463C-8E54-9D042C6F1C23}.Release|Any CPU.ActiveCfg = Release|Any CPU
{640C6DAF-5C00-463C-8E54-9D042C6F1C23}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8AD843ED-6A67-4384-BC8B-5A39647AB713}
EndGlobalSection
EndGlobal
Loading