ASP.NET Core – Building a Multi Tenant SaaS Application
Subhankar Roy
Certified Azure Solution Architect Expert | Azure Integration Services | Full Stack Technical Architect | Microsoft Power Platform| .NET | Angular | React
Multitenancy! This article describes how to use Entity Framework Core to implement multi-tenancy in ASP.NET Core in a very simple way. This is another topic that is not very well documented on the Internet. Creating a multi-tenant application from scratch is very difficult. But there is an easy way to do this
What is Multi Tenancy?
Multi-tenancy is an architecture in which a single instance of a software application serves multiple customers. Each customer is called a tenant. Tenants may be given the ability to customize some parts of the application, such as the color of the user interface (UI) or business rules, but they cannot customize the application. SAAS Products are a prime example of Multitenant architecture.
The examples of a multi-tenant architecture include such services as:
Single Tenant V/S Multi Tenant
Multi Tenant Data Models
Single Database, Shared Schema:
Tables that contain tenant-specific data include a column to identify which tenant each row belongs to
Single Database, Separate Schema:
Separate tables for each tenant, each set under a tenant-specific schema.
Database Per Tenant:
Entity framework Core Support: “Single Database, Separate Schema” scenario is not directly supported by EF Core and is not a recommended solution.
Identifying Tenants
Now let’s learn how ASP.NET Core application can identify tenants from the incoming requests.
Getting Started with Multi Tenancy in ASP.NET Core
As mentioned earlier, we will be building a Multitenant ASP.NET Core 6.0 WebApi in this tutorial. I will be using Visual Studio 2022 as my IDE for development.
Let’s start by creating a new ASP.NET Core WebApi Project. Make sure to use the .NET >= 6.0 Framework
Next up, let’s add in 2 C# Class Library Projects, namely Core and Infrastructure. Your Solution would look like this now.
Building the Entity
Let’s build a simple Product Entity first! In your Core project, add a new folder named Entities and add in a new class, and name it BaseEntity.
namespace Core.Entities
{
public abstract class BaseEntity
{
public int Id { get; private set; }
}
}
As you know, this is the Base Abstract class that our Entities will inherit to obtain the ID field. We will be using this in our Product Class.
Next comes the important part, where we need to have a contract/interface that will be implemented for Entities that need Multi Tenancy support. You can simply add a new property to each tenant called TenantId, but here is a cleaner way to do it.
Create a new folder in the Core Project and name it Contracts. Here, add a new interface and name it IMustHaveTenant. As it sounds, all the entity classes that implement this interface will have a Tenant Id. You will see the importance of having this contract later on in this guide.
public interface IMustHaveTenant
{
public string TenantId { get; set; }
}
After, create a new class in the Entities Folder and name Product.
public class Product : BaseEntity, IMustHaveTenant
{
public Product(string name, string description, int rate)
{
Name = name;
Description = description;
Rate = rate;
}
protected Product()
{
}
public string Name { get; private set; }
public string Description { get; private set; }
public int Rate { get; private set; }
public string TenantId { get; set; }
}
As said earlier, our Product class will inherit from BaseEntity Class and implement the IMustHaveTenant interface, which in turn adds the TenantId property to the class. Also, I have followed a simple DDD pattern for creating the class where all the properties of the Entity have a private setter and have a constructor that can create objects by accepting the name, description, and rate properties.
Tenant Settings – Explained
First, let’s add a Settings class which will be used for the IOptions Pattern later on in this guide. In the Core project, add a new folder named Settings and add a new class TenantSettings.
using System.Collections.Generic;
namespace Core.Options
{
public class TenantSettings
{
public Configuration Defaults { get; set; }
public List<Tenant> Tenants { get; set; }
}
public class Tenant
{
public string Name { get; set; }
public string TID { get; set; }
public string ConnectionString { get; set; }
}
public class Configuration
{
public string DBProvider { get; set; }
public string ConnectionString { get; set; }
}
}
Remember that we will be using the same structure in the appsettings.json as well.
Let’s add a sample settings node in the appsettings.json to understand the setup even better. Open up your appsettings.json from the API project and add in the following.
"TenantSettings": {
"Defaults": {
"DBProvider": "mssql",
"ConnectionString": "Data Source=(localdb)\\mssqllocaldb;Initial Catalog=sharedTenantDb;Integrated Security=True;MultipleActiveResultSets=True"
},
"Tenants": [
{
"Name": "alpha",
"TID": "alpha",
"ConnectionString": "Data Source=(localdb)\\mssqllocaldb;Initial Catalog=alphaTenantDb;Integrated Security=True;MultipleActiveResultSets=True"
},
{
"Name": "beta",
"TID": "beta",
"ConnectionString": "Data Source=(localdb)\\mssqllocaldb;Initial Catalog=betaTenantDb;Integrated Security=True;MultipleActiveResultSets=True"
},
{
"Name": "charlie",
"TID": "charlie"
},
{
"Name": "java",
"TID": "java"
}
]
}
In this demo settings, we mention that we have 4 Tenants set up to use our API. Tenant alpha and beta wish to have a separate Database, while tenant charlie and java are ok to use the shared database which is mentioned in the default section of the tenant settings.
领英推荐
Now, that we got our requirements and concepts clear, let’s how to proceed further.
Tenant Service
We need to devise a way to identify the tenant from the incoming requests. To achieve this, let’s start by creating an ITenantService interface in the Core Project. Create a new folder named Interfaces in the Core project and add a new interface, ITenantService.
public interface ITenantService
{
public string GetDatabaseProvider();
public string GetConnectionString();
public Tenant GetTenant();
}
Basically, this interface should return the current DBProvider, Connection string, and Tenant Data. Let’s implement this interface in the Infrastructure project next.
Create a new folder in the Infrastructure project and name it Services. Here, add a new class and name it TenantService.
public class TenantService : ITenantService
{
private readonly TenantSettings _tenantSettings;
private HttpContext _httpContext;
private Tenant _currentTenant;
public TenantService(IOptions<TenantSettings> tenantSettings, IHttpContextAccessor contextAccessor)
{
_tenantSettings = tenantSettings.Value;
_httpContext = contextAccessor.HttpContext;
if (_httpContext != null)
{
if (_httpContext.Request.Headers.TryGetValue("tenant", out var tenantId))
{
SetTenant(tenantId);
}
else
{
throw new Exception("Invalid Tenant!");
}
}
}
private void SetTenant(string tenantId)
{
_currentTenant = _tenantSettings.Tenants.Where(a => a.TID == tenantId).FirstOrDefault();
if (_currentTenant == null) throw new Exception("Invalid Tenant!");
if (string.IsNullOrEmpty(_currentTenant.ConnectionString))
{
SetDefaultConnectionStringToCurrentTenant();
}
}
private void SetDefaultConnectionStringToCurrentTenant()
{
_currentTenant.ConnectionString = _tenantSettings.Defaults.ConnectionString;
}
public string GetConnectionString()
{
return _currentTenant?.ConnectionString;
}
public string GetDatabaseProvider()
{
return _tenantSettings.Defaults?.DBProvider;
}
public Tenant GetTenant()
{
return _currentTenant;
}
}
Line 10 to 20 – We first check if HTTP context is not null, then we try to read the tenant key from the header of the request. If a tenant value is found, we set the tenant using the SetTenant(string tenantId) method.
Line 22 to 30 – Here, we take in tenant from the request header and compare it against the data we have already set in the appsettings of the application. If the matching tenant is not found, it throws an exception. If the found tenant doesn’t have a connection string defined, we simply take the default connection string and attach it to the connection string property of the current tenant, as simple as that.
The remaining methods are quite straightforward.
Note that we have not yet registered any of the services of settings into the DI Container of the application. We will be doing it towards the end of the tutorial only.
Extended ApplicationDBContext
Now that we have our Tenant Service ready, let’s do the DbContext part.
In the Infrastructure project, create a new folder named Persistence and add in a new class named ApplicationDbContext.
public class ApplicationDbContext : DbContext
{
public string TenantId { get; set; }
private readonly ITenantService _tenantService;
public ApplicationDbContext(DbContextOptions options, ITenantService tenantService) : base(options)
{
_tenantService = tenantService;
TenantId = _tenantService.GetTenant()?.TID;
}
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>().HasQueryFilter(a => a.TenantId == TenantId);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var tenantConnectionString = _tenantService.GetConnectionString();
if (!string.IsNullOrEmpty(tenantConnectionString))
{
var DBProvider = _tenantService.GetDatabaseProvider();
if (DBProvider.ToLower() == "mssql")
{
optionsBuilder.UseSqlServer(_tenantService.GetConnectionString());
}
}
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
{
foreach (var entry in ChangeTracker.Entries<IMustHaveTenant>().ToList())
{
switch (entry.State)
{
case EntityState.Added:
case EntityState.Modified:
entry.Entity.TenantId = TenantId;
break;
}
}
var result = await base.SaveChangesAsync(cancellationToken);
return result;
}
}
Product Service
Let’s quickly write a Product Service Implementation that takes care of Creating a new product, Getting all Products and Getting Product Details by Id. First, let’s create a new interface in the Core Project, IProductService.
public interface IProductService
{
Task<Product> CreateAsync(string name, string description, int rate);
Task<Product> GetByIdAsync(int id);
Task<IReadOnlyList<Product>> GetAllAsync();
}
Now, in the Infrastructure/Services folder, let’s create a new class and name it ProductService.
public class ProductService : IProductService
{
private readonly ApplicationDbContext _context;
public ProductService(ApplicationDbContext context)
{
_context = context;
}
public async Task<Product> CreateAsync(string name, string description, int rate)
{
var product = new Product(name, description, rate);
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product;
}
public async Task<IReadOnlyList<Product>> GetAllAsync()
{
return await _context.Products.ToListAsync();
}
public async Task<Product> GetByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}
}
This is a very straightforward usage of DBContext to perform some simple CRUD Operations. Let’s proceed to the fun part next.
Automated Migrations
We will build an extension method that can
Under the Infrastructure Project, create a new folder named Extensions. Here add a new static class named ServiceCollectionExtensions
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAndMigrateTenantDatabases(this IServiceCollection services, IConfiguration config)
{
var options = services.GetOptions<TenantSettings>(nameof(TenantSettings));
var defaultConnectionString = options.Defaults?.ConnectionString;
var defaultDbProvider = options.Defaults?.DBProvider;
if (defaultDbProvider.ToLower() == "mssql")
{
services.AddDbContext<ApplicationDbContext>(m => m.UseSqlServer(e => e.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)));
}
var tenants = options.Tenants;
foreach (var tenant in tenants)
{
string connectionString;
if (string.IsNullOrEmpty(tenant.ConnectionString))
{
connectionString = defaultConnectionString;
}
else
{
connectionString = tenant.ConnectionString;
}
using var scope = services.BuildServiceProvider().CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
dbContext.Database.SetConnectionString(connectionString);
if (dbContext.Database.GetMigrations().Count() > 0)
{
dbContext.Database.Migrate();
}
}
return services;
}
public static T GetOptions<T>(this IServiceCollection services, string sectionName) where T : new()
{
using var serviceProvider = services.BuildServiceProvider();
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var section = configuration.GetSection(sectionName);
var options = new T();
section.Bind(options);
return options;
}
}
Setting up the Controller
With that done, let’s create a new API controller for our test purpose.
Under the API Project, Create a ProductsController in the Controllers folder.
namespace Multitenant.Api.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
public ProductsController(IProductService service)
{
_service = service;
}
[HttpGet]
public async Task<IActionResult> GetAsync(int id)
{
var productDetails = await _service.GetByIdAsync(id);
return Ok(productDetails);
}
[HttpPost]
public async Task<IActionResult> CreateAsync(CreateProductRequest request)
{
return Ok(await _service.CreateAsync(request.Name, request.Description, request.Rate));
}
}
public class CreateProductRequest
{
public string Name { get; set; }
public string Description { get; set; }
public int Rate { get; set; }
}
}
Service Registrations
Finally, let’s finish off this implementation by adding the Services to the ASP.NET Core DI Container. Open up the Startup.cs file of the API project and modify the ConfigureServices method as below.
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Multitenant.Api", Version = "v1" });
});
services.AddTransient<ITenantService, TenantService>();
services.AddTransient<IProductService, ProductService>();
services.Configure<TenantSettings>(config.GetSection(nameof(TenantSettings)));
services.AddAndMigrateTenantDatabases(config);
}
There you go. This is probably the simplest way to achieve Multi Tenancy in ASP.NET CoreApplications. Test the application using Postman.
Further Enhancements
All above mentioned source code along with Hangfire for Multi-tenant scenario can be found here :https://github.com/demonking99/Hangfire-Multi-tenant-ASP.NET-Core