DEV Community

Cover image for Multi-Tenant SaaS Architecture with Entity Framework
Developer Partners
Developer Partners

Posted on • Originally published at developerpartners.com

Multi-Tenant SaaS Architecture with Entity Framework

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:

Single-Tenant Software 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:

Multi-Tenant Software 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; }
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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.
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Configure the primary key of all tables that will have tenant data to be a composite key consisted of the TenantId and Id columns. The Id column should be an identity column that increments every time we add a new row. The TenantId column should also be a foreign key referencing the Tenants table.
  2. 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.
  3. When adding, editing, and deleting data, set the TenantId column value to the tenant ID value we got from step 2.
  4. 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)

Collapse
 
rasheedmozaffar profile image
Rasheed K Mozaffar

This guide is incredibly comprehensive πŸ’―
Thank you so much for sharing 🀩

Collapse
 
developerpartners profile image
Developer Partners

Thank you for your comment.