ASP.NET Core – Building a Multi Tenant SaaS Application

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:

  • Office 365 Online: Microsoft Office 365 online i.e. www.office.com is prime examples of SaaS that doesn’t require setting up and multiple organizations are using the same application without disclosing their data.
  • Google Drive and Dropbox: cloud file hosting services offer secure and easy-to-access storage over the Internet.
  • Shopify: multiple tenants use Shopify to host their platforms while sharing the database with other website owners.

Single Tenant V/S Multi Tenant

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, Shared Schema

Single Database, Separate Schema:

Separate tables for each tenant, each set under a tenant-specific schema.

Single Database, Separate 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.

  • Query String: This is a simple mechanism, where the tenant can be identified using a Query string. It’s as simple as passing ‘?TenantId=alpha’ in the request URL and the application gets to know that this request is meant for tenant alpha. As this strategy has several vulnerabilities, it’s advised to use this approach only for Test and Development purposes.

  • Request IP Address: Here, we assume that each tenant request will be generated from a particular IP range. Once an IP range is set for a tenant, the application can detect which tenant it belongs to. Although this is a secure approach, it’s not always convenient to use this.

  • Request Header: This is a more robust strategy to identity Tenants. Each of the requests will have a Header with TenantID in it. The application then serves the request for that particular tenant. This approach is recommended to be used when requesting Authentication Tokens only. We will be using this approach in our tutorial.

  • Claims: A more secure way to detect Tenants. In systems where JWT Tokens are involved for Authentication, the tenantId of the user can be encoded into the claims of the Token. This approach ensures that the request is both authenticated and belongs to a user from the mentioned tenant.

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.

  • TenantSettings will have a Default Configuration which includes the DBProvider (MSSQL will be used in this tutorial), and a Connection String to the default shared database.
  • Tenant Setting will also have a definition for the List of Tenants that are supposed to have access to the system.
  • A Tenant Object will have Name, TID and Tenant Specific Connection String.
  • In cases where the tenant needs to use the shared database, the idea is to leave the Connection string of the tenant blank.

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;
    }
}        

  • It is assumed that, whenever the application tries to access the database via EFCore context, there is a valid tenantID present in the request header, or atleast provided by the Tenant Service.
  • Line 14 – This is the part where we define the Global Query Filter for the DBContext. Everytime a new request is passed to the DBContext, the applicationDbContext will be smart enough to work with the data the is relevant to the current tenantId only.
  • Line 16 to 27 – Here, everytime a new instance of ApplicationContext is invoked, the connection string is pulled from the tenant settings and set to EFCore Context.
  • Finally, in Line 28 to 42, we override the SaveChanges method. In this method, whenever there is a modification of the Entity of type IMustHaveTenant, TenantId is written to the entity during the Save process.

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

  • Create DBs for each tenant on startup
  • Update the new Databases with available Migrations
  • Register the ApplicationDbContext.

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;
    }
}        

  • Line 5 to 7 – Gets default settings from appsettings.json file
  • Line 10 – Registers ApplicationDbContext using the SQLServer package.
  • Line 13 to 31 – Iterates through the list of tenants configured. If a tenant has no Connection String declared, it assigns the default connection string to it. From there, we extract the DBContext Service, set it’s connection to the tenant’s connection string and finally perform migrations.
  • Line 34 to 42 – This is a generic method to Get the configuration from AppSettings.json in a static file.

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

  • Multi Tenancy can be extended to use Identity as well. We can create Identity users for each tenant. In this way we can restrict the access of the each tenant to the corresponding users / user group only. This is true Multi Tenancy. We will discuss that in my next post.
  • In this implementation, we have stored the Tenant Settings to a simple appsettings file. In advanced cases, we can maintain a separate Database Table to Manage Tenants and their settings.


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







要查看或添加评论,请登录

社区洞察

其他会员也浏览了