Building Multi-tenant Web API With .NET Core and Best Practices
In this tutorial, you'll learn step-by-step how to build a scalable, multi-tenant web API based on horizontal scaling, with code examples.
Join the DZone community and get the full member experience.
Join For FreeBusinesses need to grow in order to be successful and handle an increasing number of clients and partners, and if a company is not ready to respond to this load then there is a big chance that opportunities will be missed. This brings the topic of scalability into the game, as one of the main requirements that a company should address. One of the possible ways to address this requirement is to build a multi-tenant solution. And as this topic gains more importance, lots of options are available to achieve this, for example, using Microsoft Elastic database (elastic tools). However, in particular cases, like the case I faced on my project, not all of the product requirements could be satisfied with the available options. This brought me to the idea of gathering my experience on this topic and presenting it below.
As we all are aware, there are two main approaches to tackling application scaling – horizontal and vertical. Horizontal scaling will bring you the benefit of scaling on the fly and will imply dealing with multiple databases, as each tenant has its own database/shard. The vertical approach to scaling presumes that you have one database that serves several tenants.
In my article, I will address the approach of horizontal scaling with a step-by-step guide on how to build a multi-tenant web API application.
If you would like to refresh on some aspects of multi-tenant architecture or what are the pros and cons it brings to the project, then I recommend visiting these resources:
- Why Cloud Architecture Matters: The Multi-Instance Advantage over Multi-Tenant
- Why Multi-Tenant Application Architecture Matters in 2017
- Design patterns for multi-tenant SaaS applications and Azure SQL Database
Architecture
Let’s briefly take a look at the architecture first. The example below is designed based on N-tire architecture and has the following layers:
- Presentation layer or Web API.
- Service layer that will accommodate all the business logic.
- Data access layer that is implemented using UnitOfWork and Repository patterns. As an ORM, in this example, I used Entity Framework Core.
The key component of tenant separation is ContextFactory that contains logic to get the tenant id from the HTTP header, retrieve a tenant database name using DataBaseManager, and replace a database name in the connection string. As a result, a database context (EF context) is created.
The diagram below demonstrates this architecture.
Implementation
As you can see, the architecture is not that complicated here, and, skimming through it, I’d suggest you focus on the steps to implement it.
1. Create ContextFactory
As I mentioned before, ContextFactory is a key component of the whole architecture. It constructs the Entity Framework context (in the current example, DeviceApiContext), specific to the tenant database.
/// <summary>
/// Entity Framework context service
/// (Switches the db context according to tenant id field)
/// </summary>
/// <seealso cref="IContextFactory" />
public class ContextFactory: IContextFactory {
private
const string TenantIdFieldName = "tenantid";
private
const string DatabaseFieldKeyword = "Database";
private readonly HttpContext httpContext;
private readonly IOptions <ConnectionSettings> settings;
private readonly IDataBaseManager dataBaseManager;
public ContextFactory(
IHttpContextAccessor httpContentAccessor,
IOptions <ConnectionSettings> connectionSetting,
IDataBaseManager dataBaseManager) {
this.httpContext = httpContentAccessor.HttpContext;
this.settings = connectionSetting;
this.dataBaseManager = dataBaseManager;
}
public IDbContext DbContext {
get {
var dbOptionsBuidler = this.ChangeDatabaseNameInConnectionString();
// Add new (changed) database name to db options
var bbContextOptionsBuilder = new DbContextOptionsBuilder<DeviceContext>();
bbContextOptionsBuilder.UseSqlServer(dbOptionsBuidler.ConnectionString);
return new DevicesApiContext(bbContextOptionsBuilder.Options);
}
}
// Gets tenant id from HTTP header
private string TenantId {
get {
if (this.httpContext == null) {
throw new ArgumentNullException(nameof(this.httpContext));
}
string tenantId = this.httpContext.Request.Headers[TenantIdFieldName].ToString();
if (tenantId == null) {
throw new ArgumentNullException(nameof(tenantId));
}
return tenantId;
}
}
private SqlConnectionStringBuilder ChangeDatabaseNameInConnectionString() {
var sqlConnectionBuilder = new SqlConnectionStringBuilder(this.settings.Value.DefaultConnection);
string dataBaseName = this.dataBaseManager.GetDataBaseName(this.TenantId);
if (dataBaseName == null) {
throw new ArgumentNullException(nameof(dataBaseName));
}
// Remove old DataBase name from connection string AND add new one
sqlConnectionBuilder.Remove(DatabaseFieldKeyword);
sqlConnectionBuilder.Add(DatabaseFieldKeyword, dataBaseName);
return sqlConnectionBuilder;
}
}
The source code of ContextFactory is available here.
3. Add the Database Manager
The database manager orchestrates all tenants' metadata, such as tenant database name, the activation status of tenants (activated/deactivated), and a bunch of other properties. To demonstrate a base principle I used a dictionary in the current solution. Later on, the dictionary should be replaced with more appropriate solutions, like a SQL or NoSQL database that contains the tenant's metadata. This idea is similar to the shard map manager that is used in Microsoft Elastic Tools. Also, the tenant's metadata may include fields to store the database name, options to activate/deactivate tenants, even tenant styles for front-end applications based on CSS/SASS/LESS files, etc.
/// <summary>
/// Contains all tenants database mappings and options
/// </summary>
public class DataBaseManager : IDataBaseManager
{
/// <summary>
/// IMPORTANT NOTICE: The solution uses simple dictionary for demo purposes.
/// The Best "Real-life" solutions would be creating 'RootDataBase' with
/// all Tenants Parameters/Options like: TenantName, DatabaseName, other configuration.
/// </summary>
private readonly Dictionary<Guid, string> tenantConfigurationDictionary = new Dictionary<Guid, string>
{
{
Guid.Parse("b0ed668d-7ef2-4a23-a333-94ad278f45d7"), "DeviceDb"
},
{
Guid.Parse("e7e73238-662f-4da2-b3a5-89f4abb87969"), "DeviceDb-ten2"
}
};
/// <summary>
/// Gets the name of the data base.
/// </summary>
/// <param name="tenantId">The tenant identifier.</param>
/// <returns>db name</returns>
public string GetDataBaseName(string tenantId)
{
var dataBaseName = this.tenantConfigurationDictionary[Guid.Parse(tenantId)];
if (dataBaseName == null)
{
throw new ArgumentNullException(nameof(dataBaseName));
}
return dataBaseName;
}
}
/// with all tenant's options, databases etc.
/// </summary>
private readonly Dictionary < Guid, string > dataBasesDictionary = new Dictionary < Guid, string > {
{
Guid.Parse("b0ed668d-7ef2-4a23-a333-94ad278f45d7"), "tenant-db1"
},
{
Guid.Parse("e7e73238-662f-4da2-b3a5-89f4abb87969"),
"tenant-db2"
}
};
/// <summary>
/// Gets the name of the data base.
/// </summary>
/// <param name="tenantId">The tenant identifier.</param>
/// <returns>db name</returns>
public string GetDataBaseName(string tenantId) {
return this.dataBasesDictionary[Guid.Parse(tenantId)];
}
}
4.Add Unit of Work Class (Contains Commit to Specific Context Method)
UnitOfWork solves two tasks. It commits all changes made by the Entity Framework in entities and disposes of specific contexst.
/// <summary>
/// The Entity Framework implementation of UnitOfWork
/// </summary>
public sealed class UnitOfWork : IUnitOfWork
{
/// <summary>
/// The DbContext
/// </summary>
private IDbContext dbContext;
/// <summary>
/// Initializes a new instance of the <see cref="UnitOfWork"/> class.
/// </summary>
/// <param name="context">The object context</param>
public UnitOfWork(IDbContext context)
{
this.dbContext = context;
}
/// <summary>
/// Saves all pending changes
/// </summary>
/// <returns>The number of objects in an Added, Modified, or Deleted state</returns>
public int Commit()
{
// Save changes with the default options
return this.dbContext.SaveChanges();
}
/// <inheritdoc/>
public int Commit(IDbContext context)
{
// Change context
this.dbContext = context;
// Save changes with the default options
return this.dbContext.SaveChanges();
}
/// <summary>
/// Disposes the current object
/// </summary>
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(obj: this);
}
/// <summary>
/// Disposes all external resources.
/// </summary>
/// <param name="disposing">The dispose indicator.</param>
private void Dispose(bool disposing)
{
if (disposing)
{
if (this.dbContext != null)
{
this.dbContext.Dispose();
this.dbContext = null;
}
}
}
}
The source code of UnitOfWork is available here.
5. Add a Generic Repository Class
The repository will make changes in EF entities and the Unit of Work will commit changes to the tenant's database. Be aware that EF is making changes in memory, using Tracking Mechanism.
/// <summary>
/// Generic repository, contains CRUD operation of EF entity
/// </summary>
/// <typeparam name="T">Entity type</typeparam>
public class Repository < T > : IRepository < T >
where T: class {
/// <summary>
/// Used to query and save instances of
/// </summary>
private readonly DbSet < T > dbSet;
/// <summary>
/// Gets the EF context.
/// </summary>
/// <value>
/// The context.
/// </value>
public IDbContext Context {
get;
}
/// <summary>
/// Initializes a new instance of the <see cref="Repository{T}" /> class.
/// </summary>
/// <param name="contextFactory">The context service.</param>
public Repository(IContextFactory contextFactory) {
this.Context = contextFactory.DbContext;
this.dbSet = this.Context.Set < T > ();
}
/// <inheritdoc />
public void Add(T entity) {
return this.dbSet.Add(entity);
}
/// <inheritdoc />
public T Get < TKey > (TKey id) {
return this.dbSet.Find(id);
}
/// <inheritdoc />
public async Task < T > GetAsync < TKey > (TKey id) {
return await this.dbSet.FindAsync(id);
}
/// <inheritdoc />
public T Get(params object[] keyValues) {
return this.dbSet.Find(keyValues);
}
/// <inheritdoc />
public IQueryable < T > FindBy(Expression < Func < T, bool >> predicate) {
return this.dbSet.Where(predicate);
}
/// <inheritdoc />
public IQueryable < T > FindBy(Expression < Func < T, bool >> predicate, string include) {
return this.FindBy(predicate).Include(include);
}
/// <inheritdoc />
public IQueryable < T > GetAll() {
return this.dbSet;
}
/// <inheritdoc />
public IQueryable < T > GetAll(string include) {
return this.dbSet.Include(include);
}
/// <inheritdoc />
public bool Exists(Expression < Func < T, bool >> predicate) {
return this.dbSet.Any(predicate);
}
/// <inheritdoc />
public void Delete(T entity) {
return this.dbSet.Remove(entity);
}
/// <inheritdoc />
public void Update(T entity) {
return this.dbSet.Update(entity);
}
}
The source code is available here.
5. Add Tenant Header Operation Filter
The TenantHeaderOperationFilter class will add a tenant id field to all API calls (as an HTTP header). In solutions which use OIDS services, e.g. IdentityServer or auth0.com, tenants can be injected to a JWT token.
/// <summary>
/// Adds Tenant Id field to API endpoints
/// </summary>
/// <seealso cref="Swashbuckle.AspNetCore.SwaggerGen.IOperationFilter" />
public class TenantHeaderOperationFilter: IOperationFilter {
/// <summary>
/// Applies the specified operation.
/// </summary>
/// <param name="operation">The operation.</param>
/// <param name="context">The context.</param>
public void Apply(Operation operation, OperationFilterContext context) {
if (operation.Parameters == null) {
operation.Parameters = new List < IParameter > ();
}
operation.Parameters.Add(new NonBodyParameter {
Name = "tenantid",
In = "header",
Description = "tenantid",
Required = true,
Type = "string",
});
}
}
The source code is available here.
This is how the API will look after the filter is applied:
6. Service Layer Example
The current example of a service class (DeviceService.cs) contains functions for retrieving a device by its id and adding a new device for specific tenants. The source code of the service layer is available here.
Conclusion
In this article, I explained how to build a "ready to go" multi-tenant solution and gave some suggestions on how it can be used in your product/business. As I mentioned before, this solution is ready to go so it can be used as a "Boilerplate" project or as part of a larger project.
Source Code
The project's source code available on my Git repository here.
Published at DZone with permission of Boris Zaikin. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments