-
Notifications
You must be signed in to change notification settings - Fork 2k
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
JeremyLikness
merged 19 commits into
dotnet:main
from
JeremyLikness:ef-goes-multi-tenant
Mar 7, 2022
Merged
Add multitenancy #3632
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
f48fc16
Add multitenancy
JeremyLikness c595975
fix metadata
JeremyLikness 14e027e
grammar and fixes to examples
JeremyLikness fd46d36
Update multitenancy.md
JeremyLikness b71260b
Update multitenancy.md
JeremyLikness 003f92c
Add samples to reference
JeremyLikness 6c12e0a
Merge branch 'main' into ef-goes-multi-tenant
JeremyLikness b73160a
Add multi-tenency examples to Samples build
JeremyLikness 8113036
Build web-based sample
JeremyLikness b51f25f
Evolving samples
JeremyLikness 7159071
Multiple database example
JeremyLikness 2335172
Sync vs async list, remove old examples, add new (multitenancy)
JeremyLikness 1bab8cd
Fix markdown dependency
JeremyLikness 719c4fd
update based on new samples
JeremyLikness 9c0c6bc
Merge branch 'main' into ef-goes-multi-tenant
JeremyLikness 1b7064e
Address Shay feedback
JeremyLikness 0178943
Merge branch 'ef-goes-multi-tenant' of https://github.com/JeremyLikne…
JeremyLikness 18c5800
Update multitenancy.md
JeremyLikness 5a73892
Update filters.md
JeremyLikness File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
``` | ||
|
||
> [!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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
**/*.sqlite* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
13
samples/core/Miscellaneous/Multitenancy/Common/ITenantService.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
15 changes: 15 additions & 0 deletions
15
samples/core/Miscellaneous/Multitenancy/Common/TenantChangedEventArgs.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
32
samples/core/Miscellaneous/Multitenancy/Common/TenantService.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
47
samples/core/Miscellaneous/Multitenancy/MultiDb/ContactContext.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
18
samples/core/Miscellaneous/Multitenancy/MultiDb/MultiDb.csproj
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
37
samples/core/Miscellaneous/Multitenancy/MultiDb/MultiDb.sln
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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