In my previous post about global query filters in Entity Framework 2.0 I proposed an idea how to automatically apply query filters to all domain entities when model is built so enitites are always coming from the same tenant. This post digs deeper to possible solutions for detecting current tenant in ASP.NET Core we applications and proposes some tenant providers to use as starting point for multi-tenancy support in real-life applications.
- Global query filters in Entity Framework Core 2.0
- Implementing tenant providers on ASP.NET Core
- Implementing database per tenant strategy on ASP.NET Core
- Handling missing tenants in ASP.NET Core
- Unit testing multi-tenant database provider
- Defensive database context for multi-tenant ASP.NET Core applications
- Tenant-based dependency injection in multi-tenant ASP.NET Core applications
- Using configurable composite command in multi-tenant ASP.NET Core application
NB! Please read my previous post Global query filters in Entity Framework Core 2.0 as this post is continuation for it and expects that reader is familiar with solution I provided for multi-tenancy. Also the mehtods for applying multi-tenancy rules to all domain entities are taken from my previous global query filters post and not duplicated here.
How to detect current tenant?
Here’s the situation. Data context is built when request comes in and during building a model global query filters are applied. One of these filters is about current tenant. Tenant ID is needed in code the model is not ready yet. Same time tenant ID may be available only in database. What to do?
Some ideas:
- Use database connection in data context and make direct query to tenants table
- Use separate data context for tenant information and operations
- Keep tenants information available on blob storage
- Use hash of domain name as tenant ID
NB! I expect in this post that tenants are detected by host header in web application.
Tenants table I’m using in this post is given on the following image.
NB! Depending on solution tenant ID can be also something else and not int like shown on image above.
Using database connection of data context
This is perhaps the most lightweight solution to problem as there is no need to add additional classes and there’s also no need for tenant provider anymore. It is easy to get current host header using IHttpContextAccessor.
public class PlaylistContext : DbContext
{
private int _tenantId;
private string _tenantHost;
public DbSet<Playlist> Playlists { get; set; }
public DbSet<Song> Songs { get; set; }
public PlaylistContext(DbContextOptions<PlaylistContext> options,
IHttpContextAccessor accessor)
: base(options)
{
_tenantHost = accessor.HttpContext.Request.Host.Value;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var connection = Database.GetDbConnection();
using (var command = connection.CreateCommand())
{
connection.Open();
command.CommandText = "select ID from Tenants where Host=@Host";
command.CommandType = CommandType.Text;
var param = command.CreateParameter();
param.ParameterName = "@Host";
param.Value = _tenantHost;
command.Parameters.Add(param);
_tenantId = (int)command.ExecuteScalar();
connection.Close();
}
foreach (var type in GetEntityTypes())
{
var method = SetGlobalQueryMethod.MakeGenericMethod(type);
method.Invoke(this, new object[] { modelBuilder });
}
base.OnModelCreating(modelBuilder);
}
// Other methods follow
}
The code above creates command based on database connection held by data context and runs SQL-command against database to get tenant ID by host header.
This solution is smallest when considering code but it pollutes data context with internal details of host name detection.
Using separate data context for tenants
Second approach is to use separate web application specific tenants context. It’s possible to write tenant provider (refer back to my EF Core global query filters post) and inject it to main data context.
Let’s start with tenant class based on table given in the beginning of this post.
public class Tenant
{
public int Id { get; set; }
public string Name { get; set; }
public string Host { get; set; }
}
Now let’s build tenants data context. This context has no dependencies to other dependencies to custom interfaces and classes. It only uses Tenant model. Notice that Tenants set is private and other classes can only query tenant ID by host header.
public class TenantsContext : DbContext
{
private DbSet<Tenant> Tenants { get; set; }
public TenantsContext(DbContextOptions<TenantsContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Tenant>().HasKey(e => e.Id);
}
public int GetTenantId(string host)
{
var tenant = Tenants.FirstOrDefault(t => t.Host == host);
if(tenant == null)
{
return 0;
}
return tenant.Id;
}
}
Now it’s time to bring back in ITenantProvider and write implementation that uses tenants data context. This provider holds all the logic about detecting host header and getting tenant ID. In practice it will be more complex but here I go with simple version of it.
public class WebTenantProvider : ITenantProvider
{
private int _tenantId;
public WebTenantProvider(IHttpContextAccessor accessor,
TenantsContext context)
{
var host = accessor.HttpContext.Request.Host.Value;
_tenantId = context.GetTenantId(host);
}
public int GetTenantId()
{
return _tenantId;
}
}
Now there’s everything needed to detect tenant and find it’s ID. It’s time to rewrite main data context so it uses new tenant provider.
public class PlaylistContext : DbContext
{
private int _tenantId;
public DbSet<Playlist> Playlists { get; set; }
public DbSet<Song> Songs { get; set; }
public PlaylistContext(DbContextOptions<PlaylistContext> options,
ITenantProvider tenantProvider)
: base(options)
{
_tenantId = tenantProvider.GetTenantId();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var type in GetEntityTypes())
{
var method = SetGlobalQueryMethod.MakeGenericMethod(type);
method.Invoke(this, new object[] { modelBuilder });
}
base.OnModelCreating(modelBuilder);
}
// Other methods follow
}
In startup class of web application all dependencies must be defined for framework-level dependency injection. It is done in ConfigureServices() method.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
var connection = Configuration["ConnectionString"];
services.AddEntityFrameworkSqlServer();
services.AddDbContext<PlaylistContext>(options => options.UseSqlServer(connection));
services.AddDbContext<TenantsContext>(options => options.UseSqlServer(connection));
services.AddScoped<ITenantProvider, WebTenantProvider>();
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
}
This solution is more elegant as it moves tenant related functionalities out from main data context. ITenantProvider is the only thing that main data context must know and now it is usable also in other projects that are not necessarily web application ones.
Keeping tenants information on blob storage
One my now say that tenants are not coming and going all the time and instead of tenant provider that queries database it’s possible to cache tenants information somewhere and update it when needed. Considering cloud scenarios it’s good idea to keep tenants information where it is accessible for multiple instances of web application. My choice is blob storage.
Let’s start with simple tenants file in JSON-format and let’s expect it’s the responsibility of some internal application or background task to keep this file up to date. This the sample file I’m using.
[
{
"Id": 2,
"Name": "Local host",
"Host": "localhost:30172"
},
{
"Id": 3,
"Name": "Customer X",
"Host": "localhost:3331"
},
{
"Id": 4,
"Name": "Customer Y",
"Host": "localhost:33111"
}
]
To read files from blob storage application needs to know storage account connection string, container name and blob name. Blob is tenants file. I use ITenantProvider interface again and create a new implementation of it for Azure blob storage. I call it BlobStorageTenantProvider. It is simple and doesn’t take care of many real-life aspects like refreshing of tenants information and taking care of locks.
public class BlobStorageTenantProvider : ITenantProvider
{
private static IList<Tenant> _tenants;
private int _tenantId = 0;
public BlobStorageTenantProvider(IHttpContextAccessor accessor, IConfiguration conf)
{
if(_tenants == null)
{
LoadTenants(conf["StorageConnectionString"], conf["TenantsContainerName"], conf["TenantsBlobName"]);
}
var host = accessor.HttpContext.Request.Host.Value;
var tenant = _tenants.FirstOrDefault(t => t.Host.ToLower() == host.ToLower());
if(tenant != null)
{
_tenantId = tenant.Id;
}
}
private void LoadTenants(string connStr, string containerName, string blobName)
{
var storageAccount = CloudStorageAccount.Parse(connStr);
var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference(containerName);
var blob = container.GetBlobReference(blobName);
blob.FetchAttributesAsync().GetAwaiter().GetResult();
var fileBytes = new byte[blob.Properties.Length];
using (var stream = blob.OpenReadAsync().GetAwaiter().GetResult())
using (var textReader = new StreamReader(stream))
using (var reader = new JsonTextReader(textReader))
{
_tenants = JsonSerializer.Create().Deserialize<List<Tenant>>(reader);
}
}
public int GetTenantId()
{
return _tenantId;
}
}
The provider’s code is perhaps not very nice but it performs better than previous ones as there’s no need for additional database call and tenant ID-s are served from memory.
Using host header hash as tenant ID
Third method is simplest one but it means that tenant ID is same as host header or derived from it. I don’t like this idea because if customer wants to change host header then changes are spread all over the database. It’s possible that customer wants to start with custom host name given automatically by service and later use their own subdomain.
Code for tenant ID as host name is here.
public class PlaylistContext : DbContext
{
private string _tenantId;
public DbSet<Playlist> Playlists { get; set; }
public DbSet<Song> Songs { get; set; }
public PlaylistContext(DbContextOptions<PlaylistContext> options,
IHttpContextAccessor accessor)
: base(options)
{
_tenantId = accessor.HttpContext.Request.Host.Value;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var type in GetEntityTypes())
{
var method = SetGlobalQueryMethod.MakeGenericMethod(type);
method.Invoke(this, new object[] { modelBuilder });
}
base.OnModelCreating(modelBuilder);
}
// Other methods follow
}
It’s possible to use MD5 has instead of host name but it doesn’t change the problem of host as key anyway.
Wrapping up
This post was about making real use of global query filters in Entity Framework Core 2.0. Although code artifacts represented here were simple and not up to needs for live environments they still work as an good examples and starting points when building real solutions. I tried to keep with solutions as close to good architecture principles as possible. I think solutions provided here with my opinions about hteir pros and cons will help readers start with their own multi-tenant applications.
View Comments (26)
Nice post Mr.Gunnar, Instead of tenant provider can we create that as middleware and create some cache, so that we can inject the Tenant object wherever need and don't need to hit the database for subsequent request.
Well, tenant provider introduces specific component that takes care of detecting current tenant. As solutions are very different and also the possible caches developers decide to use, I left these decisions currently out from here. What is okay for one solution is not probably okay for another one.
If multi-tenancy is only about users, a good way of providing tenant id is user claims.
Andriy Savin, how would you get the tenant id from a user token claim? Is the user token validated before the DbContext creation? Would you be able to use dependency injection to get the calim with the tenant id? Thank you in advance
I followed your code sample and my host is always null. The reason for this is that I get this error;
Microsoft.AspNetCore.Http.IHttpContextAccessor.HttpContext.get returned null.
Any idea why this might be happening?
Good article. I think it would be better to introduce some abstraction to keep web dependencies from being referenced directly in the data layer. Like introduce an interface IResolveTenantKey that the data layer depends on. The implemntation of that can come from another class library and it can use IHttpContextAccessor but the data layer doesn't know about IHttpContextAccessor and doesn't have to reference any aspnetcore dependencies. From the data layer perspective hostname is just a string key it needs to lookup the tenant and the interface implementation can provide that without any webby dependencies needed in the data layer.
Rachel, this error happens if there is no current HttpContext to take. On possible case is when you run apply EF migrations from command line or package manager console. Do you get HttpContext as null when running web application?
Also make sure you have this line added to ConfigureServices() method of Startup class:
services.AddSingleton();
This is easily the most straightforward and helpful walk through I've found on multi-tenancy in .net core. Thanks for your work.
Mr Peipman,
I have same problem as Rachel, the problem occurred during Add-Migration. If I comment out that chunk of code, the migration will work fine but will it affect anything? Is there any way to get around this? FYI, I added the singleton in ConfigureServices already. There is no error during runtime tho, just during migrations.
Migrations need EF Core model and context but they don't use any multi-tenancy functionalities. I think adding check for null in WebTenantProvider is enough. If it doesn't get HttpContext then set tenant ID to empty GUID.
I like the implementation. Very simple way to achieve this.
Has anyone been able to get this to work end-to-end specifically with the TenantsBaseDbContext?
I'm substituting SQL for Mongo but if you are willing to share your working sample with SQL that will be a great help for me.
Much thanks!
Hi, G. Peipman Sir,
I am beginner for multi-tenant application , i am not finding any demo application that follows SAAS architecture , Is there any demo application , if possible please give me a reference link so that i can get started.
Thanks in advance.
Hi,
I have something available in Github: https://github.com/gpeipman/AspNetCoreMultitenant
Hope it helps. If you have any questions then please feel free to ask.
Hi, G.P sir,
https://github.com/gpeipman/AspNetCoreMultitenant , When i am running the solution it pointing me to missing tenant url, And one more doubt in Startup class
this line "services.AddDbContext(options => {});" i am unable to understand why its blank.
In short if possible please mention steps to run this solution by using Sql server.
By seeing code it nicely organised ,please don't mind if i am asking silly thing , i am just beginner .
Hi,
Tenant provider loads tenant information from tenants.json file: https://github.com/gpeipman/AspNetCoreMultitenant/blob/master/AspNetCoreMultitenant/AspNetCoreMultitenant.Web/tenants.json AddDbContext options are empty because I configure DbContext in OnConfiguring() method override.
For demos during presentations I have three mappings active in my hosts file:
127.0.0.1 sme1
127.0.0.1 sme2
127.0.0.1 bigcorp
sme1 and sme2 hosts use SQL Server LocalDB, bigcorp tenant uses local MySQL.
PM> Update-Database
System.NullReferenceException: Object reference not set to an instance of an object.
at AspNetCoreMultitenant.Web.Extensions.FileTenantProvider..ctor(IHttpContextAccessor accessor) in D:\PracticeProjects\AspNetCoreMultitenant-master\AspNetCoreMultitenant-master\AspNetCoreMultitenant\AspNetCoreMultitenant.Web\Extensions\FileTenantProvider.cs:line 15
--- End of stack trace from previous location where exception was thrown ---
i tried above command it throws exception, is there any other way to create the database?
Currently this solution doesn't support migrations from package manager console. When migration is run there's no HttpContext available because migration related commands are run by Visual Studio tooling and not by web application.
There's one option I found. You can use environment variable for connection string when updating database: https://stackoverflow.com/a/52343344 You can also run migrations in Configure() method of Startup class if you want to automate it.
Personally I prefer to do all database updates manually to make sure that unexpected issues doesn't take down all live databases.
Hi G.P sir,
I wanted to run this demo application if you have db script for this project to run so i can debug the application , personally i also like to go manually while doing any db operations .
I added database script for MSSQL to repository: https://github.com/gpeipman/AspNetCoreMultitenant/blob/master/AspNetCoreMultitenant/sme-script.sql I included also test data. If you don't need it then delete all the insert statements.
thank you so much , finally i am able to run the solution.
This is a great article following the article on Global Query Filters. I was looking for this kind of an example. Thanks.
I appreciate something that exemplifies that this isn't really that hard to do, I personally plan on using some sort of organization or tenant id stored in user information. EF Core has its shortcomings but they really nailed a lot, it's increasingly a pleasure to work with.
Hi GP,
Can you please help me find current tenant in Configure Method? Using ControllableTenantProvider, we can access connection strings for all the tenants but how to get the connection string for current tenant?
My god! I have been tackling and fighting with implementing basic multitenant (shared db) in my app for months. I needed to take data from a claim (incoming request) and use that claim to go to the DB to get the tenantId. And then use that tenantId in all other DB relate operations. The crucks of my issue turned out to be that I was using the same context to manage tenant data, as I was for the app data. Once I split the tenant data out into its own db context following you're instructions above, this is now all appearing to work swimmingly!
So happy I found this. Thanks Gunnar