SaaS solutions are very common. They are the go-to model for software products because of many reasons such as having little maintenance overhead, cost effectiveness, and easy customer onboarding. However, the software architecture you pick for your SaaS solution may decide your success in the business. But before we dive into code for creating a multi-tenant application with Entity Framework and .NET Core, let's understand what the multi-tenant architecture is. To do that, we have to first understand what the single-tenant architecture is.
What is Single-Tenant Architecture
In the single-tenant software architecture, each customer gets their own version of the compiled application, database, hosting, etc. If there are software updates or patches to push, they have to be done for each customer separately. The following diagram describes the single-tenant architecture:
The best way to describe what the single-tenant architecture is by an example. Imagine you started a business of creating websites for your clients. You find a few clients and start development of their websites. You create a database for each client. Then you buy hosting for each of your clients and install their websites on those web servers. Everything goes great until you find a bug in your code. How will you fix that bug for all clients? If you have to make database schema changes for fixing that bug, you will have to update each client's database with those changes. You will have to update your source code, then push the patch to each client's web server. It may be easy to do for just a few clients, but it will add a lot of maintenance overhead over time as your customer base grows. Imagine your business is doing good, and you have 50 clients now. If there are any changes in your software, you will have to push those changes to all those 50 client websites. I think it's easy to imagine how much time you have to spend maintaining all those different instances of client websites.
A single-tenant architecture is not bad at all. It just has it's pros and cons. It may not be the best architecture for a lot of solutions, but there are times when the single-tenant architecture brings a lot of benefits. It all just depends on the nature of your business.
What is Multi-Tenant Architecture
There are different ways to implement the multi-tenant architecture, but the most common way and the one that we are going to create in this article is the type where all customers use the same compiled application, database, hosting, etc. All customer data is stored in the same database and all customers share the same application. It's almost like they are renting space in your website that is why the term "tenant" is used, so customers or clients are often referred as tenants in multi-tenant applications. If you have thousands of customers and you need to push an update for your solution, you have to do that only for one database and one website, and all customers will get the update. The following diagram describes the multi-tenant architecture:
Design the Database
If we are going to put the data of all customers in the same database, we have to somehow be able to tell which records belong to which customers. There are different ways to do that. For example, we can create a new database schema for each customer. We can use the customer's name for each schema, so if "Nice Printing" and "Best Insurance" are our customers, all the tables of the "Nice Printing" customer can be placed in the "nice_printing" schema, and all the tables of the "Best Insurance" customer can be placed in the "best_insurance" schema. This will work as long as the number of customers is small and manageable. If there are going to be thousands of customers, this approach can cause maintenance problems.
Another approach is putting data of all customers in the same tables, but assigning each row a unique customer ID that will tell which customer each row belongs to. Then if we want to query data of a specific customer, we can filter it by the customer ID. This approach will be easier to maintain because we can put data of thousands of customers in the same set of tables, and if we have to make updates to our database design, we don't have to do them separately for each customer. This is the database design that we are going to use in this article.
First, let's create a table called Tenants
. We are going to store the names of all customers in that table. We will name that table Tenants
instead of Customers to make it clear that this table is where we are going to store the information about tenants of our multi-tenant application. The following is the Entity Framework model for the Tenants
table:
[Index(nameof(Tenant.Domain), IsUnique = true)]
public class Tenant
{
[Key]
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; }
[Required]
[StringLength(100)]
public string Domain { get; set; }
}
The Id
column is going to be the primary key of the Tenants
table. The Name
column is going to have the name of the customer. The Domain
column is going to have the domain of that tenant. Every tenant is going to have their own domain in our application. For example, if the tenant's name is Best Insurance, they are going to use the https://best-insurance.com URL to access their site. If the tenant's name is Nice Printing, they are going to use the https://nice-printing.com URL to access their site. The Domain
column of the Tenants
table is going to have the "best-insurance.com" and "nice-printing.com" values for each of those tenants respectively. The Domain
column will have a unique index on it because we are going to locate each tenant by their domain.
All our tables are going to have a column called TenantId
which will tell what rows belong to what tenants. The primary keys of those tables are going to be composite keys consisted of the TenantId
and Id
columns. The Id
column is going to be an Identity column which will auto-increment with every row we add in the database. The TenantId
column, aside from being part of the primary key, is also going to be a foreign key referencing the Tenants
table. Since all tables are going to have the TenantId
and Id
columns, it makes a lot of sense to create a base abstract class for them:
public abstract class TenantModel
{
public int TenantId { get; set; }
public Tenant Tenant { get; set; }
public int Id { get; set; }
}
Next, we have to tell Entity Framework that we want to make the primary key of all model classes inherited from the TenantModel
class a composite key consisted of the TenantId
and Id
columns and make the Id
an Identity column that auto-increments whenever we add a new row. For that, we have to override the OnModelCreating
method of our DbContext
class and use the following code:
public class AppDbContext : DbContext
{
public DbSet<Tenant> Tenants { get; set; }
private void SetupTenantModels(ModelBuilder modelBuilder)
{
var tenantModels = modelBuilder
.Model
.GetEntityTypes()
.Where(e => typeof(TenantModel).IsAssignableFrom(e.ClrType));
foreach (var model in tenantModels)
{
// Set primary key to a composite key consisted of TenantId and Id columns.
modelBuilder.Entity(model.ClrType)
.HasKey(nameof(TenantModel.TenantId), nameof(TenantModel.Id));
// Make the Id column Identity that auto-increments with every row.
modelBuilder.Entity(model.ClrType)
.Property(nameof(TenantModel.Id))
.ValueGeneratedOnAdd();
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
SetupTenantModels(modelBuilder);
}
}
Now, we can add a table that will actually use the TenantModel
class. Our new table is going to be called Products. The Products table will have data from all tenants but the data will be segregated by the TenantId
column. The following is the code of the Product model for the Products table:
public class Product : TenantModel
{
[Required]
[StringLength(100)]
public string Name { get; set; }
[StringLength(300)]
public string Description { get; set; }
}
The Product
class is pretty simple. It inherits from the TenantModel
class and adds the Name and Description properties to it. The code we added to the AppDbContext
class will automatically make the primary key of the Products table composite key consisted of the TenantId
and Id columns inherited from the TenantModel
class and it will also make the TenantId
column a foreign key referencing the Tenants table. Every time we create a new class that inherits from TenantModel
, our code will ensure it has a correct primary key for our multi-tenant application.
Segregate Tenant Data
We wrote code that will add a TenantId
column to all tables in our database. Next, we have to write code that will actually set the TenantId
column value when adding or updating rows in those tables. Every time we are about to add or update data in the database, we have to do that with the correct tenant ID which raises the question how we are going to determine what tenant ID to use for each request in our website. We can extract the domain from the website URL and use that for querying the Tenants
table by the Domain
column. When we find a Tenants
table row that matches the domain in question, we can use the ID of that row. Let's create an ASP.NET Core filter that will do what we need.
public class TenantFilter : IActionFilter
{
private readonly AppDbContext _dbContext;
private readonly IHostEnvironment _environment;
private readonly ITenantProviderService _tenantProviderService;
public TenantFilter(
AppDbContext dbContext,
IHostEnvironment environment,
ITenantProviderService tenantProviderService)
{
_dbContext = dbContext;
_environment = environment;
_tenantProviderService = tenantProviderService;
}
private string GetCallingDomain(HttpRequest request)
{
var callingUrl = $"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}";
var uri = new Uri(callingUrl);
return _environment.IsDevelopment()
? $"{uri.Host}:{uri.Port}"
: uri.Host;
}
public void OnActionExecuting(ActionExecutingContext context)
{
var domain = GetCallingDomain(context.HttpContext.Request);
var tenant = _dbContext
.Tenants
.SingleOrDefault(t => t.Domain == domain);
_tenantProviderService.TenantId = tenant.Id;
}
public void OnActionExecuted(ActionExecutedContext context)
{
// Do nothing here.
}
}
The TenantProviderService.TenantId
property that we are setting in the code snippet above on line 42 is a simple property with a getter and setter. We can just register the TenantProviderService
class as a Scoped service which means that once the TenantId
property is set in our TenantFilter
class, it will be available both when serving the HTTP request and response. In the following code snippet, we are globally registering the TenantFilter
ASP.NET Core filter to set the TenantId
property on the TenantProviderService
class for every request and registering the TenantProviderService
class as a Scoped service to keep the TenantId
property for serving both the HTTP request and response once we set it in the TenantFilter
class.
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add(typeof(TenantFilter));
});
builder.Services.AddScoped<ITenantProviderService, TenantProviderService>();
// Irrelevant code omitted for brevity
}
}
The TenantProviderService.TenantId
property will now have the correct tenant ID for each request. Now, we have to use that tenant ID when adding, editing, and deleting records in the database. We can do that by overriding the SaveChanges
and SaveChangesAsync
Entity Framework DbContext
methods and setting the TenantId
property of the modified entities to the value of the TenantProviderService.TenantId
property.
public class AppDbContext : DbContext
{
// Irrelevant code omitted for brevity
private void SetTenantId()
{
foreach (var entry in ChangeTracker.Entries<TenantModel>())
{
entry.Property(e => e.TenantId).CurrentValue = _tenantProviderService.TenantId;
}
}
public override int SaveChanges()
{
SetTenantId();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
SetTenantId();
return base.SaveChangesAsync(cancellationToken);
}
}
In the SetTenantId
method of the code snippet above, we are iterating through all entities in EF change tracker that inherit from the TenantModel
class and setting the TenantId
value of those entities to the value of TenantId
of the TenantProviderService
service. Then we are calling the SetTenantId
method from the overridden SaveChanges
and SaveChangesAsync
methods.
The last thing we have to do is to filter all the data by the current tenant ID when we are reading data from the database. Every time a user needs to see some data, we have to only show the records that belong to the tenant (customer) whose website they are browsing. We can do that by adding a global Entity Framework Core filter.
public class AppDbContext : DbContext
{
private void FilterByTenantId(ModelBuilder modelBuilder, IMutableEntityType model)
{
Expression<Func<TenantModel, bool>> filterExpression = t => t.TenantId == _tenantProviderService.TenantId;
var newParam = Expression.Parameter(model.ClrType);
var newBody = ReplacingExpressionVisitor.Replace(filterExpression.Parameters.Single(), newParam, filterExpression.Body);
LambdaExpression lambdaExpression = Expression.Lambda(newBody, newParam);
modelBuilder.Entity(model.ClrType)
.HasQueryFilter(lambdaExpression);
}
private void SetupTenantModels(ModelBuilder modelBuilder)
{
var tenantModels = modelBuilder
.Model
.GetEntityTypes()
.Where(e => typeof(TenantModel).IsAssignableFrom(e.ClrType));
foreach (var model in tenantModels)
{
// Set primary key to a composite key consisted of TenantId and Id columns.
modelBuilder.Entity(model.ClrType)
.HasKey(nameof(TenantModel.TenantId), nameof(TenantModel.Id));
// Make the Id column Identity that auto-increments with every row.
modelBuilder.Entity(model.ClrType)
.Property(nameof(TenantModel.Id))
.ValueGeneratedOnAdd();
// Globally filter all queries by TenantId
FilterByTenantId(modelBuilder, model);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
SetupTenantModels(modelBuilder);
}
}
In the code snippet above, we are calling the new FilterByTenantId
method from the SetupTenantModels
method. We are defining a filter expression that will keep only the rows where TenantId
column equals to the value of the TenantProviderService.TenantId
column. We are registering that filter expression for each table that is created from a class inherited from the TenantModel
base class. The code that uses expression trees and expression visitors is just for adjusting the filter expression the way that Entity Framework likes it.
Conclusion
If you are developing a SaaS solution, it makes a lot of sense to use the multi-tenant architecture because it makes the maintenance and adding new features really easy for new and existing clients. There are different ways to implement this architecture. The most common way and the one we used in this article is using one database and one website for all tenants (customers). To implement the multi-tenant architecture with the one database and one website for all tenants approach, you have to do the following:
- Configure the primary key of all tables that will have tenant data to be a composite key consisted of the
TenantId
andId
columns. The Id column should be an identity column that increments every time we add a new row. TheTenantId
column should also be a foreign key referencing the Tenants table. - Determine the
TenantId
for each request. In an ASP.NET Core application, we can create a global filter that will get the tenant ID from our database based on the domain of the request URL. - When adding, editing, and deleting data, set the
TenantId
column value to the tenant ID value we got from step 2. - When reading data from the database, filter the data by the tenant ID value that we got from step 2.
Please note that the TenantModel
base class we used in this article will not work for the many-to-many database table relationships. We didn't cover configuring the TenantId
column for that type of relationships in this article for keeping it short and easy to understand. However, if you are interested in configuring the TenantId
column for the many-to-many relationships, please consider reading our article about many-to-many relationships in Entity Framework Core. It doesn't cover the multi-tenancy, but it can be a good starting point.
Many-To-Many Relationships in Entity Framework Core
Top comments (2)
This guide is incredibly comprehensive π―
Thank you so much for sharing π€©
Thank you for your comment.