Tenant application in ASP.NET Core
First of all, we have to know what a tenant is. Simply tenant is a customer. Meaning each customer is called a tenant.
Multi-tenant application in ASP.NET Core
Multi-tenancy is an architecture in which a single software application instance serves multiple customers.
In this case, a single software will manage multiple customer databases. But you have needed a tenant database for multiple tenants. This process is also called SaaS (Software as a Service).
Moreover, you can also manage the software design style by multi-tenant architecture. In the case of a single tenant, every software-service instance has a separate database. Otherwise, in the case of multi-tenant only one software service instance, but there are multiple databases.
Tenant Database
I have to maintain a Tenant database to manage multiple customer Databases. I am using two tables, Like
CREATE DATABASE [TenantDB]
USE [TenantDB]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Tenants](
[CustomerId] [int] NOT NULL,
[Customer] [varchar](50) NOT NULL,
[Host] [varchar](50) NULL,
[SubDomain] [varchar](50) NOT NULL,
[Logo] [varchar](50) NULL,
[ThemeColor] [varchar](50) NULL,
[ConnectionString] [varchar](max) NOT NULL,
CONSTRAINT [PK_Customer] PRIMARY KEY CLUSTERED
(
[CustomerId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
USE [TenantDB]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[TenantUsers](
[Id] [int] NOT NULL,
[CustomerId] [int] NOT NULL,
[Email] [varchar](50) NOT NULL,
CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
USE [TenantDB]
GO
INSERT [dbo].[Tenants] ([CustomerId], [Customer], [Host], [SubDomain], [Logo], [ThemeColor], [ConnectionString]) VALUES (1, N'Red Customer', N'localhost:5057', N'rc', NULL, N'Red', N'Server=Rohol;Database=App-DB1; user id=sa; password=123456; MultipleActiveResultSets=true')
GO
INSERT [dbo].[Tenants] ([CustomerId], [Customer], [Host], [SubDomain], [Logo], [ThemeColor], [ConnectionString]) VALUES (2, N'Green Customer', N'localhost:5057', N'gc', NULL, N'Green', N'Server=Rohol;Database=App-DB2; user id=sa; password=123456; MultipleActiveResultSets=true')
GO
INSERT [dbo].[TenantUsers] ([Id], [CustomerId], [Email]) VALUES (1, 1, N'[email protected]')
GO
INSERT [dbo].[TenantUsers] ([Id], [CustomerId], [Email]) VALUES (2, 2, N'[email protected]')
GO
Application Database
Now I will add two application databases; these databases will access by a single web portal. Databases are like App-DB1 and App-DB2. Each database has one table, like Users.
CREATE Database [App-DB1]
GO
USE [App-DB1]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Users](
[UserId] [int] NOT NULL,
[UserName] [varchar](50) NULL,
[UserEmail] [varchar](50) NULL,
[Password] [varchar](50) NULL,
CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED
(
[UserId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
USE [App-DB1]
GO
INSERT [dbo].[Users] ([UserId], [UserName], [UserEmail], [Password]) VALUES (1, N'Red Customer', N'[email protected]', N'123456')
GO
CREATE Database [App-DB2]
GO
USE [App-DB2]
GO
/****** Object: Table [dbo].[Users] Script Date: 04/15/23 5:26:26 PM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Users](
[UserId] [int] NOT NULL,
[UserName] [varchar](50) NULL,
[UserEmail] [varchar](50) NULL,
[Password] [varchar](50) NULL,
CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED
(
[UserId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
USE [App-DB2]
GO
INSERT [dbo].[Users] ([UserId], [UserName], [UserEmail], [Password]) VALUES (1, N'Green Customer', N'[email protected]', N'123456')
GO
Create a Multi-tenant Application
Open visual studio editor 2022 and choose an ASP.Net Core Web App (MVC).
Install SaasKit.Multitenancy
Go to the NuGet Package Manager and install the SaasKit.Multitenancypackage
This SaasKit.Multitenancy package will manage a multi-tenancy strategy.
Now add two classes, Tenants and TenantUsers.
public class Tenants
{
[Key]
public int CustomerId { get; set; }
public string Customer { get; set; }
public string Host { get; set; }
public string SubDomain { get; set; }
public string Logo { get; set; }
public string ThemeColor { get; set; }
public string ConnectionString { get; set; }
}
public class TenantUsers
{
[Key]
public int Id { get; set; }
public int CustomerId { get; set; }
public string Email { get; set; }
}
Now add another class like TenantResolver. Here I will use TenantContext, which is come from SaasKit.Multitenancy package.
public interface ITenantResolver
{
Task<TenantContext<Tenants>> ResolveAsync(HttpContext context);
}
public class TenantResolver : ITenantResolver<Tenants>
{
public async Task<TenantContext<Tenants>> ResolveAsync(HttpContext context)
{
throw new NotImplementedException();
}
}
Service Registration
Go to the program file and register the Tenant class with TenantResolver class
// Multitenancy
builder.Services.AddMultitenancy<Tenants, TenantResolver>();
Middleware Setup
app.UseMuttitenancy<Tenant>();
This Tenant middleware will call with every HTTP request.
Install Entity Framework Core
I will useEFCore ORM to access SQL Database. So, we need to install the required packages. Like
After installing these three packages, I will add Two database contexts. One context is for Tenant-Database and another for App-Database. Like
TenantDBConnection
{
"ConnectionStrings": {
"TenantConnection": "Server=Rohol;Database=TenantDB; user id=sa; password=123456; MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
// Sql Server TenantDb Connection
builder.Services.AddDbContextPool<TenantDbContext>(options => options.
UseSqlServer(builder.Configuration.GetConnectionString("TenantConnection")));
Now add Signin and Users class in the Models folder. Like
public class Signin
{
[Required(ErrorMessage ="email address is required")]
[EmailAddress]
public string Email { get; set; }
[DataType(DataType.Password)]
public string? Password { get; set; }
}
public class Users
{
[System.ComponentModel.DataAnnotations.Key]
public int UserId { get; set; }
[Required]
public string UserName { get; set; }
[Required]
public string UserEmail { get; set; }
[Required]
public string Password { get; set; }
}
DB context Implementation
Now time to implement Db contexts. Like
public class TenantDbContext : DbContext
{
public TenantDbContext(DbContextOptions<TenantDbContext> option) : base(option)
{
}
public DbSet<Tenants> Tenants { get; set; }
public DbSet<TenantUsers> TenantUsers { get; set; }
}
public class AppDbContext : DbContext
{
private readonly Tenants tenant;
public AppDbContext(DbContextOptions<AppDbContext> options, Tenants tenant):base(options)
{
this.tenant = tenant;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(tenant.ConnectionString);
}
public DbSet<Users> Users { get; set; }
}
Add Services
I have used two services for this application. One for tenant operation and another for application operation. Like
namespace MultiTenantApp.Services
{
public interface ITenantService
{
Tenants GetTenantBySubDomain(string subDomain);
Tenants GetTenantByEmail(string email);
}
public class TenantService : ITenantService
{
private readonly TenantDbContext tdbc;
public TenantService(TenantDbContext tdbc)
{
this.tdbc = tdbc;
}
public Tenants GetTenantByEmail(string email)
{
throw new NotImplementedException();
}
public Tenants GetTenantBySubDomain(string subdomain)
{
throw new NotImplementedException();
}
}
}
- GetTenantBySubDomain()- This method will return a tenant by sub-domain.
- GetTenantByEmail ()- This method will return a tenant by email.
namespace MultiTenantApp.Services
{
public interface IAppUserService
{
public string GetTenantByEmail(string email);
public string Signin(Signin model);
}
public class AppUserService : IAppUserService
{
public string GetTenantByEmail(string email)
{
throw new NotImplementedException();
}
public string Signin(Signin model)
{
throw new NotImplementedException();
}
}
}
- GetTenantByEmail()- Here, this method will return a valid URL with a sub-domain.
- Signin()- This method is used to sign in to this portal. It will return a URL as a string.
TenantResolver implementation
public async Task<TenantContext<Tenants>> ResolveAsync(HttpContext context)
{ // get sub-domain form browser current url. if sub-domain is not exists then will set empty string
string subDomainFromUrl = context.Request.Host.Value.ToLower().Split(".")[0] ?? string.Empty;
// checking has any tenant by current sub-domain.
var result = this.tenantService.GetTenantBySubDomain(subDomainFromUrl);
Tenants tenant = new();
// checking has any subdomain is exists in current url
if (!string.IsNullOrEmpty(result.SubDomain))
{
// checking orginal sub-domain and current url sub-domain
if (!result.SubDomain.Equals(subDomainFromUrl)) return null; // if sub-domain is different then return null
else
{
tenant.CustomerId = result.CustomerId;
tenant.Customer = result.Customer;
tenant.Host = result.Host;
tenant.SubDomain = result.SubDomain;
tenant.Logo = result.Logo;
tenant.ThemeColor = result.ThemeColor;
tenant.ConnectionString = result.ConnectionString;
return await Task.FromResult(new TenantContext<Tenants>(tenant));
}
}
else return await Task.FromResult(new TenantContext<Tenants>(tenant));
}
This resolver will resolve a multitenant strategy in each HTTP request. If the tenant is valid, then the HTTP request will execute else. The application will show an error. Here I am checking the sub-domain in each request. So, if the sub-domain exists and is valid, the app will work fine; otherwise shows an error. This is a demo and my logic and implementation so anyone can implement his logic.
Controller Implementation
Now add a Signin action in HomeController for view. Like,
public IActionResult Signin(string emailid="")
{
ViewBag.Email = emailid;
return View();
}
Also, add Signin Post Action for Signin. Like,
[HttpPost]
public IActionResult Signin(Signin model)
{
// checking model state
if (ModelState.IsValid)
{
// checking email at first time
if (model.Password is null)
{
// retrieve tenant information by user email
var result = this.appUserService.GetTenantByEmail(model.Email);
// if valid email then redirect for password
if (result is not null) return Redirect(result + "?emailid=" + model.Email);
else // if email is invalid then clear Email-ViewBag to stay same page and get again email
{
ViewBag.Email = string.Empty;
ViewBag.Error = "Provide valid email";
}
}
else // this block for password verification, when user provide password to signin
{
var result = this.appUserService.Signin(model);
if (result is null) // if password is wrong then again provide valid password
{
ViewBag.Email = model.Email;
ViewBag.Error = "Provide valid password";
}
else return Redirect(result); // if password is valid then portal will open for user access
}
}
else ViewBag.Email = ""; // if email is invalid then clear Email-ViewBag to stay same page and get again email
return View();
}
The detail I have implemented the into the source code. So don't worry; I have provided the source file.
Logout action
public IActionResult Logout()
{
return Redirect("http://localhost:5057");
}
Go to the master _Layout.cshtml page and inject the Tenant class to access the required property. I will use the ThemeColor property to change the theme according to the user's colour. Like,
@inject Tenants tenants;
<body style="background-color:@tenants.ThemeColor">
Same as the index and privacy files. I will use the Customer name from the tenant class. Like
@inject Tenants tenants;
@{
ViewData["Title"] = "Home Page";
}
<h1>@ViewData["Title"]</h1>
<h4>of @tenants.Customer</h4>
Run the project and sign in by different users. Like
First, for the red user
and second for green user