A multi-tenant application is adept at serving multiple tenants the same code base. In other words, this architecture can serve many different clients or tenants using a single source of code. This article talks about multi-tenant applications and discusses how multi-tenant applications can be architected and implemented in ASP.NET 5.

What's a Tenant?

In a multi-tenant architecture, one instance of the application can be used to provide access to the application to a group of users known as customers or tenants. A tenant is comprised of a group of users that share the same data, configuration information, and user management information. Each tenant has a specific identity and the application should be adept enough to respond to each tenant differently. It should be noted that in a multi-tenant architecture, each tenant is integrated physically but is logically separated from one another. Each tenant may even be physically isolated from the others.

A tenant shares access to the hardware and has the ability to customize certain parts of the application - this can be the look and feel of the user interface or even customizing business rules. A tenant can't customize the source code of the application.

What's a Single-Tenant Architecture?

A single-tenant architecture is built out of a common code-base and is one in which a single instance of the application can serve a single customer or tenant. Contrary to a multi-tenant application, in a single-tenant application, each tenant maintains its own copy of the database as well as an application instance.

A single-tenant application is considered reliable and you can quickly restore the data in the event of loss due to a disaster. If there's a data breach with one tenant, the other tenants aren't affected because the tenants use separate instances of the application. A tenant can also choose when to install the updates.

Customization, set up, maintenance, and upgrading a single-tenant application can be costly both in terms of time and in money. Although single-tenant applications can have access to resources in abundance and can be customized easily, regular maintenance and management of such applications is an uphill task. Although single-tenant applications provide more resources, with a single customer for your application only, this comes at a premium cost.

What's a Multi-Tenant Architecture?

Multi-tenancy is a style of software architecture in which an application instance is shared between multiple tenants of the application with each tenant having its own share of the instance, which is isolated for the purpose of performance, data security, etc.

Multi-tenancy is an ideal architecture to leverage the cloud environment in the best possible way. In essence, it provides a shared platform that you can use to make the most of the cloud infrastructure - it evolves continuously to keep pace with the demands of all the tenants using the application. A multi-tenant application can improve the ROI and reduce the development and deployment costs considerably by sharing the same resources with multiple tenants while still being modular and scalable. Incidentally, Google, Facebook, Amazon, etc. are completely multi-tenant.

Take advantage of multi-tenant applications in ASP.NET 5 to be able to serve multiple tenants using the same codebase.

Multi-Tenant Architecture: Benefits

The benefits of multi-tenant architecture are:

  • Easy onboarding: Multi-tenant architecture is a perfect choice for applications that need easier startup experiences or easier onboarding and fewer hardware requirements.
  • Maintenance: Improved ease of maintenance is a benefit of multi-tenant application.
  • Reduced Cost: Contrary to single-tenant architecture, multi-tenant architecture is relatively cheap due to shared infrastructure and helps build applications with lower maintenance costs and better computing capacities.
  • Resource Usage: Multi-tenant architecture ensures better use of the available resources compared to single-tenant architecture.
  • Easier upgrade: In a multi-tenant application, upgrades are seamless and relatively simple.

Multi-Tenant Architecture: Downsides

The potential downsides of multi-tenant architecture are:

  • Isolation: You'd need to isolate data, configuration, and non-functional requirements such as performance and logging between the tenants. This isolation poses challenges and introduces additional complexities in the application's source code as well as the database schema.
  • Complexity: Unlike a single-tenant application, a multi-tenant application is more complex primarily because you have multiple tenants with isolated data and configuration storage for each of them. When your multi-tenant application is down due to unavailability of the database, all tenants using the application are impacted.
  • Security: Although other tenants can't have access to your data, your organization's data is stored in a single database where all users pertaining to your organization have access.

Features of a Multi-Tenant Application

These are the salient features of multi-tenant architecture:

  • Isolation of tenant data: Per-tenant data isolation is one of the most important features of multi-tenant applications. What this means is that each tenant has access to its data and only its data. In other words, data in a multitenant application is logically and physically isolated on a per-tenant basis with one tenant not having access to the data pertaining to another tenant. You might have to configure your application differently for each tenant. Such configuration data might typically include authentication keys, database connection strings, etc.
  • Tenant resolution strategy: A tenant resolution strategy implies which tenant context a particular request should be executed on. Tenant resolution strategies should consider the database to be accessed by the tenant, the configuration to be used, etc. In a multi-tenant application, you should have your tenant resolution strategy in place.

Multi-Tenant Database

A multi-tenant database is a shared database with shared schema in which data pertaining to multiple tenants is stored. To isolate data pertaining to different tenants, a tenant identifier column is usually used. A multi-tenant application follows one of the three database architectures discussed in the next section.

Types of Multi-Tenant Databases

You can achieve multi-tenancy using either logical isolation or physical isolation. The tenants should be logically isolated but the degree to which they are physically isolated might vary. You should also be aware of two issues when implementing logical segregation of tenants, such as data segregation and cross-tenant access. The former relates to the need of segregating data of a particular tenant and the latter implies access to data for users who are a part of several tenants.

Depending on access to the data, there are three types of approaches you can follow to design your multi-tenant databases:

  • Multiple databases: with a single database per tenant
  • A single database with separate schemas per tenant
  • A single database with shared schema

Multiple Databases: Single Database per Tenant

The single database per tenant design provides the maximum level of data safety. The databases are physically separated and one tenant can't access the data pertaining to another tenant, so you have better isolation of data as well. This approach is flexible and you can easily restore data of a tenant if needed but you'll have to pay for additional servers. In other words, you have better isolation of data but this comes at the cost of additional complexity - complexity of management, maintenance, and scalability because you've to deploy multiple databases.

  • The shared schema approach is a good choice when many different clients each have the same schema. You can use the separate database approach for all other use cases. Using a separate schema inside the same database isn't a good choice primarily because maintenance of such databases is difficult.

Single Database with Separate Schema per Tenant

In this model, a single database is used but there are multiple schemas: one per tenant. Each tenant has its own schema inside the database, which is typically comprised of a set of tables. You can opt for this design if you're willing to lower the operating costs of the database layer as well as the complexity of the server infrastructure. However, the downside to this approach is that you'll need additional effort for data backup and restoration, especially if the data pertaining to one tenant has been corrupted.

Single Database with Shared Schema

This is a simple design that's easy to implement; you have a shared schema that's used by all tenants. In essence, the schema for all tenants is identical, i.e., the tenants all use the same database tables. You don't need to create a schema per tenant or run additional servers for the databases and you'd typically want to use a tenant ID to retrieve data pertaining to a particular tenant. However, the downside is that over time, as more and more tenants use the application, it becomes increasingly difficult to query or update the data.

Tenant Identification

Applications built using multi-tenant architecture are adept at responding differently to the various customers or tenants. A multi-tenant application can identify which tenant a particular request has originated from. In other words, tenant identification determines which tenant is involved based on the available information such as a hostname, source IP, or a custom HTTP header. Each tenant has a specific unique identity and the application behaves differently from one tenant to another. Such changes may include UI changes, changes in data including configuration parameters, and changes in behavior.

The identity of one tenant differs from another tenant. Two tenants may differ on one or more of the following:

  • Data: Each tenant has access only to its own data. Each tenant has its own configuration data, i.e., connection strings, workflows, domain name, etc.
  • Behavior: One tenant may behave differently from another tenant based on the features it has access to.
  • User interface: The appearance might include style sheets, images, etc.

Multi-Tenant Architecture: Challenges and Solutions

This section talks about some of the challenges faced in multi-tenant architecture and the possible solutions.

  • Security: Security in a multi-tenant application is extremely critical. Take advantage of the right security algorithms and apply the highest level of security available to protect the application and its data. There is a chance that human error might compromise data security and an unauthorized person might get access to the application and the database. There is also the threat of hackers; no matter how secure an encryption algorithm is, a clever hacker might break the security. Have a proper security policy in place to combat this - don't bank on just one security algorithm.
    • Some companies have certain regulatory restrictions that might prevent storing data in a shared infrastructure. Added to this, if the infrastructure hasn't been configured correctly, the corrupt data of one tenant might spread across other tenants.
  • Performance: If one tenant is using an inordinate amount of computing power, that might have a detrimental effect on the performance of other tenants.
  • Loose Coupling: If you're using a single code base to cater to multiple tenants, your application's code should be loosely coupled and configurable as well. You might want to decide if you should have a customer-specific source code branch or provide this flexibility in a single code base.
  • Complexity: A multi-tenant application is much more complex compared to a single-tenant application. Database administrators need the right tools and knowledge to maximize capacity while reducing costs. To ensure that you're meeting the service level agreements (SLAs), it's imperative that proper monitoring for delivery and availability be in place.

Building a Multi-Tenant Application in ASP.NET 5

In this section, I'll examine how you can implement a multi-tenant application in ASP.NET 5.

You should have Visual Studio 2019 Preview installed in your system. You can download a copy of it from here: https://visualstudio.microsoft.com/downloads

Tenant Resolution

Tenant resolution is the technique by which you can match a request to a tenant. There are several ways of doing this; your application might store tenant identification data in one of the following ways:

  • Host header
  • Request path
  • Request headers

In this example, I'll take advantage of the last strategy, that is, the tenant identification data will be passed in the request header and read from there by the application.

Getting Started

First, create a new ASP.NET 5 project in Visual Studio. There are several ways to create a project in Visual Studio. When you launch Visual Studio, you'll see the Start window. You can choose “Continue without code” to launch the main screen of the Visual Studio 2019 IDE. Or you can use the method below, which is better for this example.

To create a new ASP.NET 5 project in Visual Studio:

Figure 1: Select the project template and specify authentication and the target framework.
Figure 1: Select the project template and specify authentication and the target framework.

A new ASP.NET 5 Web application project is created. I'll use this project throughout this article.

Creating the Database

To keep things simple, I'll use a database having a simple design. The database is named Demo and contains two tables: Tenant and Customer. Although the former contains information related to tenants, the latter contains customer data; there are a few fields in both tables to keep it simple.

The following code snippet illustrates the database script. You can run it to create the two tables.

CREATE DATABASE Demo 
GO 
USE ProductDb
GO
CREATE TABLE dbo.Tenant(
    Id uniqueidentifier NOT NULL,
    ApiKey uniqueidentifier NOT NULL,
    TenantName nvarchar(200) NOT NULL,
    IsActive bit NOT NULL

CONSTRAINT PK_Tenant
    PRIMARY KEY
    CLUSTERED (Id ASC) 
    )
GO

CREATE TABLE dbo.Customer(
    Id uniqueidentifier NOT NULL,  
    TenantId uniqueidentifier NOT NULL,  
    CustomerName nvarchar(50) NOT NULL,  
    IsActive bit NOT NULL

CONSTRAINT PK_Customer
    PRIMARY KEY
    CLUSTERED (Id  ASC),
    CONSTRAINT FK_Customer_Tenant
    FOREIGN KEY (TenantId)
    REFERENCES dbo.Tenant(Id)
    )
GO

Next, let's insert some data in these tables. You can use the following script to insert data into the Tenant and Customer tables.

USE [Demo] 
GO

INSERT INTO dbo.Tenant(Id, ApiKey, 
    TenantName,  IsActive)
    VALUES('f3cfe18a-369b-4630-89b0-ed121d083c59', 
        'e71c77c8-e18a-4114-b659-006af444c2dc',
        'Tenant A', 1)

INSERT INTO dbo.Tenant(Id, ApiKey, 
    TenantName, IsActive)
    VALUES('47df0eb1-aef2-4208-8d03-ac88ae6c8330', 
        'a8ac37d4-715f-41e9-ae36-292cc6615e73',
        'Tenant B', 2)

INSERT INTO dbo.Tenant(Id, ApiKey, 
    TenantName, IsActive)
    VALUES('a9121c5e-a37e-4c77-bd11-0cddaae46d4b', 
        'cab37f08-10aa-473d-9f26-16d9d4a958e5',
        'Tenant C', 3)
GO

INSERT dbo.Customer(Id,
    TenantId, CustomerName, IsActive)
    VALUES ('169d0df3-688a-4864-8b11-078f2d1b1c24', 
        'f3cfe18a-369b-4630-89b0-ed121d083c59', 
        'Customer 1', 1)
GO

INSERT dbo.Customer(Id, 
    TenantId, CustomerName, IsActive)
    VALUES ('8b9b5299-f718-4eeb-93ba-f1acc62356ab', 
        '47df0eb1-aef2-4208-8d03-ac88ae6c8330', 
        'Customer 2', 1)
GO

INSERT dbo.Customer(Id, 
    TenantId, CustomerName, IsActive)
    VALUES ('7fc8a49f-1a15-46fb-b30b-d8b9c479e7cc', 
        'a9121c5e-a37e-4c77-bd11-0cddaae46d4b', 
        'Customer 3', 10)
GO

INSERT dbo.Customer(Id, 
    TenantId, CustomerName, IsActive)
    VALUES ('e6cb4033-d215-4a5a-9e61-6af1e69a074c', 
        'f3cfe18a-369b-4630-89b0-ed121d083c59', 
        'Customer 4', 1)
GO

INSERT dbo.Customer(Id, 
    TenantId, CustomerName, IsActive)
    VALUES ('a5f7ab05-9862-409f-8b29-5d7406cd15f3', 
        'a9121c5e-a37e-4c77-bd11-0cddaae46d4b', 
        'Customer 5', 1)
GO

Creating the Models

In this example, you'll need only two model classes: the Tenant class and the Customer class. Here's the complete source code of these two classes.

public class Tenant
{   
    public Guid Id { get; set; }   
    public Guid ApiIKey { get; set; }   
    public string TenantName { get; set; }   
    public bool IsActive { get; set; }
}

public class Customer
{   
    public Guid Id { get;  set; }   
    public Guid TenantId { get; set; }
    public string CustomerName { get; set; }   
    public bool IsActive { get; set; }
}

Creating the Repository Classes

Create a Solution folder named Repositories and create two files inside: TenantRepository and CustomerRepository. The former is a class used to access the Tenant database table, and the latter is used to work with the Customer table of the demo database. For the sake of simplicity, I'll use ADO.NET here - no object relational mappers (ORMs). Listing 1 shows the TenantRepository class.

Listing 1: TenantRepository Class

public class TenantRepository 
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private ISession _session =>_httpContextAccessor.HttpContext.Session;
    private readonly IConfiguration _configuration;
    
    public TenantRepository(IConfiguration configuration, IHttpContextAccessor httpContextAccessor) 
    {
        _configuration = configuration;
        _httpContextAccessor = httpContextAccessor;
    }
    
    public async Task < string > GetTenantId(Guid apiKey) 
    {
        string tenantId = null;
        try 
        {
            using(var connection = new SqlConnection(_configuration["ConnectionStrings:TenantDbConnection"])) 
            {
                await connection.OpenAsync();
                using(var command = new SqlCommand("SELECT Id  FROM Tenant WHERE ApiKey = @apiKey", connection)) 
                {
                    command.Parameters.AddWithValue("@apiKey", apiKey);
                    var reader = await command.ExecuteReaderAsync();
                    if (reader.Read()) 
                    {
                        tenantId = reader["Id"].ToString();
                    }
                    
                    if (!reader.IsClosed) await reader.CloseAsync();
                    if (connection.State != ConnectionState.Closed) await connection.CloseAsync();
                    
                    return tenantId;
                }
            }
        } 
        catch 
        {
            return null;
        }
    }
    
    public async Task < string > GetTenantId() 
    {
        return await Task.FromResult(_session.GetString("TenantId"));
    }
}

The CustomerRepository class is given in Listing 2.

Listing 2: CustomerRepository Class

public class CustomerRepository 
{
    private readonly IConfiguration _configuration;
    
    public CustomerRepository(IConfiguration configuration) 
    {
        _configuration = configuration;
    }
    
    public async Task < List < Customer >> GetAllCustomers(string tenantId) 
    {
        try 
        {
            List<Customer> customers = new List<Customer>();
            
            using(var connection = new SqlConnection(_configuration["ConnectionStrings:TenantDbConnection"])) 
            {
                await connection.OpenAsync();
                using(var command = new SqlCommand("SELECT * FROM dbo.Customer Where TenantId = @TenantId", connection)) 
                {
                    SqlParameter param = new SqlParameter();
                    param.ParameterName = "@TenantId";
                    param.Value = tenantId;
                    command.Parameters.Add(param);
                    
                    var reader = command.ExecuteReader();
                    while (reader.Read()) 
                    {
                        Customer customer = new Customer();
                        customer.Id = Guid.Parse(reader["Id"].ToString());
                        customer.TenantId = Guid.Parse(reader["TenantId"].ToString());
                        customer.CustomerName = reader["CustomerName"].ToString();
                        customer.IsActive = bool.Parse(reader["IsActive"].ToString());
                        customers.Add(customer);
                    }
                    
                    if (!reader.IsClosed) await reader.CloseAsync();
                }
                
                if (connection.State != ConnectionState.Closed) await connection.CloseAsync();
                return customers;
            }
        } 
        catch 
        {
            return null;
        }
    }
}

Creating the Tenant Middleware

Now take advantage of middleware to read the API Key from the request header and use it to retrieve the Tenant ID from the database. Middleware is a component that can handle requests and responses. You can write your custom code in the middleware and register the middleware with the request processing pipeline. Lastly, the Tenant ID retrieved is stored in the session. Listing 3 shows the TenantSecurityMiddleware class.

Listing 3: The TenantSecurityMiddleware Class

public class TenantSecurityMiddleware 
{
    private readonly RequestDelegate next;
    
    public TenantSecurityMiddleware(RequestDelegate next) 
    {
        this.next = next;
    }
    
    public async Task Invoke(HttpContext context, IConfiguration configuration, IHttpContextAccessor httpContextAccessor) 
    {
        string tenantIdentifier = context.Session.GetString("TenantId");
        if (string.IsNullOrEmpty(tenantIdentifier)) 
        {
            var apiKey = context.Request.Headers["X -Api-Key"].FirstOrDefault();
            if (string.IsNullOrEmpty(apiKey)) 
            {
                return;
            }
            
            Guid apiKeyGuid;
            if (!Guid.TryParse(apiKey, out apiKeyGuid)) 
            {
                return;
            }
            
            TenantRepository _tenentRepository = new TenantRepository(configuration, httpContextAccessor);
            string tenantId = await _tenentRepository.GetTenantId(apiKeyGuid);
            context.Session.SetString("TenantId", tenantId);
        }
        await next.Invoke(context);
    }
}

As evident in the Listing 3, the API Key is passed in the request header. This key is then used to retrieve the Tenant ID. The TenantId is then stored in the session. The UseTenant extension method adds the middleware to the request processing pipeline.

public static class TenantSecurityMiddlewareExtension 
{
    public static IApplicationBuilder UseTenant(this IApplicationBuilder app) 
    {
        app.UseMiddleware<TenantSecurityMiddleware>();
        return app;
    }
}

You can now register this middleware in the Configure method of the Startup class, as shown below:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 
{
    //Usual code
    
    app.UseTenant();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
    });
}

Adding the Dependencies

The repository classes are added as scoped services so they can be used in the controllers using dependency injection. An instance of the HttpContextAccessor class is added as a singleton service. This is needed because you'll be using HttpContext in a non-controller class: that is, inside the middleware class named TenantSecurityMiddleware shown in Listing 3.

public void ConfigureServices(IServiceCollection services) 
{
    services.AddControllersWithViews();
    services.AddScoped<TenantRepository, TenantRepository>();
    services.AddScoped<CustomerRepository, CustomerRepository>();
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddSession(options =>
    {
        options.IdleTimeout = TimeSpan.FromMinutes(1);
    });
}

Creating the Controller

You create only one controller class here: the customer controller. Listing 4 shows how the CustomersController class looks.

Listing 4: The CustomersController Class

[Route("api/[controller]")]
[ApiController] 
public class CustomersController : ControllerBase 
{
    private readonly CustomerRepository _customerRepository;
    private readonly TenantRepository _tenantRepository;
    public CustomersController(CustomerRepository customerRepository, 
    
    TenantRepository tenantRepository) 
    {
        _customerRepository = customerRepository;
        _tenantRepository = tenantRepository;
        
    }
    
    [HttpGet] 
    public async Task<List<Customer>> GetAll() 
    {
        string tenantId = await _tenantRepository.GetTenantId();
        return await _customerRepository.GetAllCustomers(tenantId);
    }
}

The CustomersController class uses dependency injection to create instances of the CustomerRepository and TenantRepository classes. These instances are then used in the GetAll method to retrieve all customer records.

When you execute the application, the customer records pertaining to the specific tenant is returned, as shown in Figure 2.

Figure 2: The Customer records pertaining to a particular tenant is returned.
Figure 2: The Customer records pertaining to a particular tenant is returned.

Retrieving All Customers for All Tenants

What if you were to retrieve all customer data pertaining to a particular tenant and then repeat the same for all tenants in the database? Let's explore how to retrieve this type of hierarchical data.

Create a new controller class named TenantController and write the following code in there.

[Route("api/[controller]")][ApiController]
public class TenantController : ControllerBase 
{
    private readonly TenantRepository _tenantRepository;
    
    public TenantController(CustomerRepository customerRepository, TenantRepository tenantRepository) 
    {
        _tenantRepository = tenantRepository;
    } 
    
    [HttpGet] 
    public async Task < string > Get() 
    {
        return await _tenantRepository.GetAllTenantsAndCustomers();
    }
}

Listing 5 shows the GetAllTenantsAndCustomer method of the TenantRepository class that retrieves the data you need.

Listing 5: The GetAllTenantsAndCustomers method

public async Task<string> GetAllTenantsAndCustomers() 
{
    string result = null;
    
    try 
    {
        using(var connection = new SqlConnection(_configuration["ConnectionStrings:TenantDbConnection"])) 
        {
            await connection.OpenAsync();
            
            using(var command = new SqlCommand("SELECT Tenant.Id, Tenant.ApiKey, Tenant.TenantName, Customer.CustomerName FROM Tenant LEFT JOIN Customer ON Tenant.Id = Customer.TenantId  FOR JSON AUTO, INCLUDE_NULL_VALUES", connection)) 
            {
                var reader = await command.ExecuteReaderAsync();
                if (reader.Read()) 
                {
                    result = reader[0].ToString();
                }
                
                if (!reader.IsClosed) await reader.CloseAsync();
                if (connection.State != ConnectionState.Closed) await connection.CloseAsync();
                
                return FormatJsonData(result);
            }
        }
    }
    catch 
    {
        return null;
    }
}

The FormatJsonData method is used to format the JSON data retrieved from the database.

private string 
FormatJsonData(string json)
{ 
    dynamic data = JsonConvert.DeserializeObject(json); 
    return JsonConvert.SerializeObject(data, Newtonsoft.Json.Formatting.Indented);
}

When you execute the application from Postman and invoke the HttpGet endpoint of the TenantController, the tenant and customer data will be displayed, as shown in Figure 3.

Figure 3: Displaying all Customer records pertaining to all Tenants
Figure 3: Displaying all Customer records pertaining to all Tenants

Summary

The quest for lower operating costs while, at the same time, providing more business value over time has given rise to many technologies and architectures over the past few decades. One such architectural style is multi-tenant architecture. Multi-tenant architecture is a feature in serverless computing and cloud computing both. Every architecture has its strengths, weaknesses, and challenges, and multi-tenant architecture is no exception.

In a multi-tenant application as the degree of multitenancy increases, the customer's costs get lower. In essence, a multi-tenant application provides more value over time. Even with all the benefits they offer, the flexibility and security of multi-tenant applications is still a challenge. A multi-tenant application might not be a good choice if your application needs a lot of customization. Despite the challenges and drawbacks, multi-tenant architecture is highly desirable because of its cost-efficiency and the business value it provides.