In this article, we are going to learn how to build a multitenant application with ASP.NET Core.

To download the source code for this article, you can visit our GitHub repository.

What is Multitenancy

To better understand a multitenant application model, let’s first explain a single tenant application model. In single-tenancy, each application instance serves a single customer. Each instance needs a separate infrastructure, resources, and data storage. Each revision to the software needs re-deployment to all customer ends. So, we can realize that this model involves a lot of repetition in terms of infrastructure setup, deployment, and maintenance.

In contrast, a multitenant model focuses on sharing these components fully or partially among the customers. In this architecture, the application tier is usually scaled up vertically by adding more resources. And the data tier is scaled out using dedicated and/or shared databases depending on the tenant’s preference and data usage volume: 

single-tenant vs multitenant application

As we see in the diagram for single-tenant architecture, each of tenants A, B and C is served separately. So in total, we need three application instances, three data storage, and infrastructures. On the other hand, the multitenant design uses a single instance of the application with higher resources and shared data storage for B and C. This design offers great benefits such as reduced operational costs, resource optimization, better scalability options, and fewer maintenance overheads. SaaS (Software-as-a-Service) applications are a prime example of such architecture.

For a real-life example, let’s consider an inventory management app for a supply-chain company. The company has several branches in different locations, each with its own goodies and budget. So, a typical approach could be deploying a standalone app instance for each branch. And to deploy a new instance whenever the company opens a new branch. But this is highly inefficient as there is no functional change in all these instances of the app. A better solution is to design a multitenant system where each branch is a tenant with its own dataset. This allows deployment of the software once in a single place and scales up with new branches.

Strategies for Multitenant Application

The tenancy model is usually focused on how stored data is organized. So, there can be several strategies. A strategy does not alter the functional behavior, but likely impacts other aspects of the overall solution. Hence, it’s important to choose the best strategy in light of various assessment factors like scalability, data isolation, cost, performance, need for change, etc.

The easiest way to introduce multitenancy is to have a single database where data is isolated on the row level. This means all tenants share the same tables where each row is associated with a specific tenant by an identifier column.

Instead of having isolation on row-level, we can have schema-level isolation. In such cases, all tenants share the same database but each one has a separate set of tables. This scenario is particularly useful when tenants have significant variations in their data models.

While sharing at best is the ideal strategy for multitenancy, this is not always desired for storage. A tenant may opt for complete data isolation for its integrity and security reasons. Also, a data-hungry tenant may significantly affect the performance of others. For such cases, a database per tenant is the way to go.

Many multitenant applications prefer a hybrid approach. This means tenants with heavy workloads can have dedicated databases and others may go for shared storage.

Build Multitenant Application with ASP.NET Core Web API

So, let’s think about the effective strategy for the inventory app we’re talking about. We’re going to design it for three branches: A, B, and C. 

Let’s assume A runs with enormous goods and workload whereas B and C operate with low budgets and few goods. So, a shared database is a cost-effective choice for B and C. On the other hand, the heavy data flow of A will likely hamper the performance of other branches if shared. So a separate database is the better choice here. This design exactly resembles the multitenant illustration we’ve seen before.

Next, we will use a row-level isolation strategy for the data isolation in shared scenarios for B and C. 

As a standard practice, we are going to split the whole application into three layers. The Core project holds the domain models and abstractions. The Infrastructure contains the concrete implementations and the data layer. And lastly, our entry point, the Web API project necessarily holds the API endpoints, configuration files, and DI setup.

Setup Domain Models

So, it’s time to put the building blocks in the Core library project.

The base class:

public abstract class EntityBase
{
    public int Id { get; set; }

    public string TenantId { get; set; } = null!;
}

The Goods class:

public class Goods : EntityBase
{
    public string Name { get; set; } = null!;

    public decimal Price { get; set; }
}

The base class holds a TenantId property that plays a key role in implementing row-level data isolation. And, Goods is the only entity we have with a few basic properties. 

We also need some models to define the necessary settings and data sources for tenants.

The Tenant class:

public class Tenant
{
    public string Name { get; set; } = null!;

    public string Secret { get; set; } = null!;

    public string? ConnectionString { get; set; }
}

The User class:

public class User
{
    public string Name { get; set; } = null!;

    public string Secret { get; set; } = null!;

    public string TenantId { get; set; } = null!;
}

And the TenantOptions class:

public class TenantOptions
{
    public string? DefaultConnection { get; set; }

    public Tenant[] Tenants { get; set; } = Array.Empty<Tenant>();

    public User[] Users { get; set; } = Array.Empty<User>();
}

Usually, tenant setups come from a separate database or a registry system. But we are going to keep it simple by using an appsettings.json configuration file:

{  
  "AllowedHosts": "*",
  "TenantOptions": {
    "DefaultConnection": "Data Source=(localdb)\\mssqllocaldb;Initial Catalog=Inventory;Integrated Security=True;MultipleActiveResultSets=True",
    "Tenants": [
      {
        "Name": "BranchA",
        "ConnectionString": "Data Source=(localdb)\\mssqllocaldb;Initial Catalog=BranchA;Integrated Security=True;MultipleActiveResultSets=True"
      },
      {
        "Name": "BranchB"
      },
      {
        "Name": "BranchC"
      }
    ],
    "Users": [
      {
        "Name": "UserA",
        "Secret": "secretA",
        "TenantId": "BranchA"
      },
      {
        "Name": "UserB",
        "Secret": "secretB",
        "TenantId": "BranchB"
      },
      {
        "Name": "UserC",
        "Secret": "secretC",
        "TenantId": "BranchC"
      }
    ]
  }
}

As we see, the TenantOptions section holds the information of registered tenants including their connection strings and users. By leaving the ConnectionString unspecified for BranchB and BranchC, we intend to use the shared DefaultConnection for them.

Implement TenantRegistry

We now have all the necessary models to implement the registry of tenants:

public class TenantRegistry : ITenantRegistry
{
    private readonly TenantOptions _tenantOptions;

    public TenantRegistry(IConfiguration configuration)
    {
        _tenantOptions = configuration.GetSection("TenantOptions").Get<TenantOptions>();

        foreach(var tenant in _tenantOptions.Tenants.Where(e => string.IsNullOrEmpty(e.ConnectionString)))
        {
            tenant.ConnectionString = _tenantOptions.DefaultConnection;
        }
    }

    public Tenant[] GetTenants() => _tenantOptions.Tenants;

    public User[] GetUsers() => _tenantOptions.Users;
}

With the help of IConfiguration injection, we get a deserialized TenantOptions instance from the appsettings.json file. Subsequently, we ensure a default connection string for the tenants that do not have a specific connection string. So, we can now look for a registered tenant whenever we need it in other parts.

Implement TenantResolver

Next, we need a way to resolve the caller tenant from the request pipeline. There are several ways to achieve this like using query parameter, request header, request IP mapping, etc. But the most realistic approach is to use the authentication middleware. As for authentication, we will configure the JWT Authentication scheme at the entry point. However, our TenantResolver implementation does not need a tight coupling to a particular authentication but just the opposite.

A standard authentication system works on a basic principle: associate the authenticated user to a ClaimPrincipal including all supporting user information as Claims. This is what we are going to utilize here:

public class TenantResolver : ITenantResolver
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ITenantRegistry _tenantRegistry;

    public TenantResolver(IHttpContextAccessor httpContextAccessor, ITenantRegistry tenantRegistry)
    {
        _httpContextAccessor = httpContextAccessor;
        _tenantRegistry = tenantRegistry;
    }

    public Tenant GetCurrentTenant()
    {
        var claim = _httpContextAccessor.HttpContext.User.Claims
            .FirstOrDefault(e => e.Type == ClaimConstants.TenantId);
        if (claim is null)
            throw new UnauthorizedAccessException("Authentication failed");

        var tenant = _tenantRegistry.GetTenants().FirstOrDefault(t => t.Name == claim.Value);
        if (tenant is null)
            throw new UnauthorizedAccessException($"Tenant '{claim.Value}' is not registered.");

        return tenant;
    }
}

The IHttpContextAccessor injection is the means to look into the HTTP request pipeline. Getting access to the current HttpContext allows us to retrieve the user claims.

From there, we can look for our intended claim of TenantId (https://schemas.microsoft.com/identity/claims/tenantid). This claim holds the Name of the current tenant as we will supply it during the authentication process. The rest is just to retrieve the tenant by name from the registry.

Setup InventoryDbContext

Let’s move on to the data layer part. Needless to say, the code-first approach is our way to go here. 

Since we are concerned about data isolation, we have to associate the context with a particular tenant. We can achieve this by injecting ITenantResolver:

public class InventoryDbContext : DbContext
{
    private readonly Tenant _tenant;

    public InventoryDbContext(DbContextOptions options, ITenantResolver tenantResolver)
        : base(options)
    {
        _tenant = tenantResolver.GetCurrentTenant();

        if (_tenant.ConnectionString is { } connectionString)
            Database.SetConnectionString(connectionString);
    }

    public DbSet<Goods> Goods { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Goods>().HasKey(e => e.Id);
        modelBuilder.Entity<Goods>().Property(e => e.Name).IsRequired().HasMaxLength(100);
        modelBuilder.Entity<Goods>().Property(e => e.TenantId).IsRequired();
        
        modelBuilder.Entity<Goods>().HasQueryFilter(e => e.TenantId == _tenant.Name);
    }
}

As planned, we hold a reference to the current tenant and use the connection string accordingly.

At its core, InventoryDbContext holds only a single DbSet for Goods entity. Also, we opt out to keep the entity model clean. So, we use the fluent API to shape up the database schema. This is done inside the OnModelCreating method. The most vital part of this method is the last line where we apply a global filter on Goods. This ensures that we will always get a filtered dataset concerning the current tenant no matter whatever query we perform on the Goods dataset.

Talking about assigning TenantId to a record, the proper way is to do it right before saving new goods into the data store:

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    foreach(var entry in ChangeTracker.Entries<EntityBase>().Where(e => e.State == EntityState.Added))
    {
        entry.Entity.TenantId = _tenant.Name;
    }

    return await base.SaveChangesAsync(cancellationToken);
}

We override the SaveChangesAsync method to assign the current tenant’s id to all new entities. As simple as that.

To complete the database setup, we applied the necessary migrations. Check out our EF Core and Using Multiple Databases via EF Core to know how to do this.

Implement IGoodsRepository

As we have the DbContext ready, let’s implement the GoodsRepository:

public class GoodsRepository : IGoodsRepository
{
    private readonly InventoryDbContext _dbContext;

    public GoodsRepository(InventoryDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Goods> AddAsync(GoodsDto goodsDto)
    {
        var goods = new Goods { Name = goodsDto.Name, Price = goodsDto.Price };

        await _dbContext.Goods.AddAsync(goods);
        await _dbContext.SaveChangesAsync();

        return goods;
    }

    public async Task<IReadOnlyList<Goods>> GetAllAsync()
    {
        return await _dbContext.Goods.ToListAsync();
    }
}

Again for the sake of simplicity we just add basic insertion and read operations. The AddAsync method accepts a DTO model to allow only the logical input fields. We also have GetAllAsync method to retrieve all Goods. Though we expose all of the Goods dataset, the underneath database query will honor the global filter and limit the result to the current tenant only.

Setup Services of a Multitenant Application

With all the planned components in hand, let’s proceed to our entry Program class in the API project. Here, within the boilerplate code, we can configure our services before the builder.Build() line:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = JwtHelper.GetTokenParameters();
    });

builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ITenantRegistry, TenantRegistry>();
builder.Services.AddScoped<ITenantResolver, TenantResolver>();
builder.Services.AddTransient<IGoodsRepository, GoodsRepository>();
builder.Services.AddDbContext<InventoryDbContext>(o =>
{
    o.UseSqlServer(options => options.MigrationsAssembly(typeof(InventoryDbContext).Assembly.FullName));
});

DatabaseHelper.EnsureLatestDatabase(builder.Services);

At first, we configure the JWT Authentication. This section has nothing to do with multitenancy. So, we opt out of exploring in detail. Check out our JWT Authentication article to know how this works. Of course, you can always inspect the source code of this article to see an entire implementation.

After that, we add a default implementation of the IHttpContextAccessor service. Next to it, the TenantRegistry is added as a singleton as it remains the same over the application lifetime. On the other hand, the TenantResolver is scoped per request. We also configure the DbContext which is by default a scoped service.

Finally, by calling DatabaseHelper.EnsureLatestDatabase() helper method we ensure auto-migrations of tenants’ databases. Here, we use auto-migration for a complete demonstration. However, in practice, migration should be a part of the deployment, not of the live application.

Authentication Endpoint

All set. Now is the time to add the necessary endpoints to our API service.

First and foremost, we have a Login endpoint in AuthController. This is essentially a typical login implementation using JSON Web Token (JWT). For brevity, we are not exploring in depth. Just one thing is crucial here, we have to include the necessary claim in the token – the same claim that we have used in TenantResolver:

public static string GenerateToken(Tenant tenant)
{
    var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SECURITY_KEY));
    var signinCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);

    var tokenOptions = new JwtSecurityToken(
        issuer: ISSUER,
        audience: AUDIENCE,
        claims: new List<Claim>() { new(ClaimConstants.TenantId, tenant.Name ?? string.Empty) },
        expires: DateTime.Now.AddMinutes(10),
        signingCredentials: signinCredentials
    );

    return new JwtSecurityTokenHandler().WriteToken(tokenOptions);
}

Implementing Goods API

Now, let’s implement endpoints for our Goods in the GoodsController.

First, we add an endpoint that will create goods in the system:

[ApiController]
[Route("[controller]")]
[Authorize]
public class GoodsController : ControllerBase
{
    private readonly IGoodsRepository _goodsRepository;

    public GoodsController(IGoodsRepository goodsRepository)
    {
        _goodsRepository = goodsRepository;
    }

    [HttpPost]
    public async Task<IActionResult> AddAsync(GoodsDto goodsDto)
    {
        var goods = await _goodsRepository.AddAsync(goodsDto);

        return Created(string.Empty, goods);
    }
}

Then we implement the endpoint to retrieve a list of goods:

[HttpGet("list")]
public async Task<IActionResult> GetListAsync()
{
    var list = await _goodsRepository.GetAllAsync();

    return Ok(list);
}

Nothing special here, just defining the routes and calling the IGoodsRepository accordingly.

Testing the Multitenant Application

Our application is now ready to support multiple tenants. Let’s verify it using Postman.

After we log in as a user of BranchA, we send a POST request to create a Goods:

{
    "Name": "A001",
    "Price": 11
}

And receive the response:

{
    "name": "A001",
    "price": 11,
    "id": 1,
    "tenantId": "BranchA"
}

As we see, goods “A001” has been stored with the tenant-id of BranchA. Likewise, we create other goods “A002” for BranchA.

Next, while logged in as a user of BranchB, we create goods “B001” and “B002”. Similarly, we have “C001” and “C002” for BranchC.

Now after sending a GET request as a user of BranchA, we get the list of goods as a response:

[
    {
        "name": "A001",
        "price": 11.00,
        "id": 1,
        "tenantId": "BranchA"
    },
    {
        "name": "A002",
        "price": 12.00,
        "id": 2,
        "tenantId": "BranchA"
    }
]

As expected, the result contains only the goods of BranchA.

The same happens when we send a request for BranchB:

[
    {
        "name": "B001",
        "price": 21.00,
        "id": 1,
        "tenantId": "BranchB"
    },
    {
        "name": "B002",
        "price": 22.00,
        "id": 2,
        "tenantId": "BranchB"
    }
]

And BranchC:

[
    {
        "name": "C001",
        "price": 31.00,
        "id": 3,
        "tenantId": "BranchC"
    },
    {
        "name": "C002",
        "price": 32.00,
        "id": 4,
        "tenantId": "BranchC"
    }
]

Also, it’s noticeable that auto-incremental “id” values are consecutive in BranchB and BranchC. This confirms that both branches are sharing the same table.

Conclusion

In this article, we have learned how to build a multi-tenant application with ASP.NET Core Web API. We have also discussed various strategic factors in this regard.