docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/Post.md
Multi-tenancy is one of those architectural decisions that looks simple in a slide deck and becomes very real the moment your SaaS application gets its second serious customer.
At that point, questions start piling up:
ABP Framework gives you a strong foundation for all of this. Instead of building tenant resolution, data filters, tenant-aware repositories, and host/tenant boundaries from scratch, you get a consistent multi-tenancy model integrated into the framework.
This guide explains both the concepts and the implementation details. We will start with the architecture, then move into configuration, entity design, tenant resolution, seeding, authentication, database-per-tenant setups, and advanced production concerns. Along the way, we will build a practical SaaS CRM example to show how the pieces fit together.
Multi-tenancy means a single application serves multiple customers, where each customer is a tenant. Those tenants share the application platform, but their data, configuration, users, and operational boundaries must remain isolated.
In ABP terms:
CurrentTenant is null.CurrentTenant.Id identifies the active tenant.A single-tenant system usually means:
A multi-tenant system usually means:
Multi-tenancy matters because it directly affects:
ABP helps because multi-tenancy is not treated as an afterthought. It is built into the framework through:
ICurrentTenantIMultiTenantThat combination removes a lot of repetitive plumbing and reduces the chance of subtle isolation bugs.
ABP’s multi-tenancy architecture is centered around tenant context propagation. The framework resolves the tenant from the incoming request, stores it in the current execution context, and applies that context to repositories, filters, services, and modules.
A tenant is a customer boundary. In a CRM SaaS product, each company using the system is a tenant.
Examples:
Acme LogisticsNorthwind RetailContoso HealthEach tenant may have:
The host side is the platform owner context.
Typical host responsibilities:
In ABP, host-side entities often have TenantId = null.
The tenant side is the customer-facing application context.
Typical tenant responsibilities:
ICurrentTenantICurrentTenant is the main runtime service for reading the active tenant context.
It exposes:
IdNameIsAvailableThis service is used everywhere from application services to background jobs.
ABP resolves the tenant before your application logic runs. It uses configured contributors such as:
Then app.UseMultiTenancy() sets the current tenant context.
IMultiTenantEntities implementing IMultiTenant gain a Guid? TenantId property. ABP uses this to apply automatic filtering.
If TenantId is:
null: the entity belongs to the host side.ABP automatically filters IMultiTenant entities so tenant queries only see their own data by default.
This is one of the most important safety features in the framework.
ABP provides tenant management infrastructure through its tenant management module and ITenantStore abstraction.
This is used to:
flowchart LR
A[Incoming HTTP Request] --> B[Tenant Resolution Contributors]
B --> C[Multi-Tenancy Middleware]
C --> D[ICurrentTenant]
D --> E[Application Service]
E --> F[Repository]
F --> G[Data Filter IMultiTenant]
G --> H[(Database)]
flowchart TD
H[Host Context CurrentTenant = null]
T1[Tenant A Context]
T2[Tenant B Context]
H --> HM[Tenant Management]
H --> FM[Feature Management]
H --> SM[Subscription Management]
T1 --> D1[Tenant A Data]
T1 --> U1[Tenant A Users]
T2 --> D2[Tenant B Data]
T2 --> U2[Tenant B Users]
sequenceDiagram
participant Req as Request
participant MW as Multi-Tenancy Middleware
participant CT as ICurrentTenant
participant Repo as Repository
participant DB as Database
Req->>MW: Resolve tenant
MW->>CT: Set TenantId
CT->>Repo: Current tenant context available
Repo->>DB: SELECT ... WHERE TenantId = @CurrentTenantId
DB-->>Repo: Tenant-scoped rows
ABP supports the three models most SaaS teams actually use: shared database, database per tenant, and hybrid.
All tenants share the same physical database. Tenant-specific rows are separated by TenantId.
IMultiTenant.Each tenant gets its own physical database. The host may also have a separate database.
ITenantStore resolves tenant configuration.Some tenants share a database, while larger or regulated tenants get dedicated databases.
This is often the most realistic model when:
| Model | Isolation | Cost | Operational Complexity | Best For |
|---|---|---|---|---|
| Shared Database | Logical | Low | Low | Early-stage SaaS, many small tenants |
| Database Per Tenant | Physical | High | High | Enterprise SaaS, regulated workloads |
| Hybrid | Mixed | Medium to High | High | Growing SaaS with mixed tenant profiles |
| Concern | Shared Database | Database Per Tenant | Hybrid |
|---|---|---|---|
| Migrations | Simple | Complex | Complex |
| Cross-tenant reporting | Easy | Harder | Mixed |
| Performance isolation | Limited | Strong | Selective |
| Tenant onboarding | Fast | Slower | Depends on tier |
| Compliance flexibility | Moderate | Strong | Strong |
In most ABP solutions, multi-tenancy is enabled through a shared constant and framework options.
MultiTenancyConstsA typical template includes a constant like this:
namespace Acme.Crm;
public static class MultiTenancyConsts
{
public const bool IsEnabled = true;
}
This constant is often referenced across layers so the application has one central switch.
AbpMultiTenancyOptionsIn your module:
using Volo.Abp.MultiTenancy;
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = MultiTenancyConsts.IsEnabled;
options.UserSharingStrategy = TenantUserSharingStrategy.Isolated;
});
}
UserSharingStrategy matters when you decide whether users are isolated per tenant or shared across tenants.
In your HTTP pipeline:
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var app = context.GetApplicationBuilder();
var env = context.GetEnvironment();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseAuthentication();
app.UseMultiTenancy();
app.UseAuthorization();
app.UseConfiguredEndpoints();
}
The exact middleware order can vary by solution template, but UseMultiTenancy() must be present so tenant resolution runs.
For a shared database setup:
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=CrmShared;Trusted_Connection=True;TrustServerCertificate=True"
}
}
For a host database plus tenant metadata:
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=CrmHost;Trusted_Connection=True;TrustServerCertificate=True"
}
}
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp;
using Volo.Abp.Modularity;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm;
[DependsOn(
typeof(AbpMultiTenancyModule)
)]
public class CrmHttpApiHostModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = MultiTenancyConsts.IsEnabled;
options.UserSharingStrategy = TenantUserSharingStrategy.Isolated;
});
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var app = context.GetApplicationBuilder();
app.UseRouting();
app.UseAuthentication();
app.UseMultiTenancy();
app.UseAuthorization();
app.UseConfiguredEndpoints();
}
}
Tenant resolution is where multi-tenancy becomes real. The framework must determine which tenant the request belongs to before your application logic executes.
ABP supports multiple strategies, and you can combine them.
ABP can resolve tenants from:
__tenant,using Volo.Abp.AspNetCore.MultiTenancy;
using Volo.Abp.MultiTenancy;
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpTenantResolveOptions>(options =>
{
options.AddDomainTenantResolver("{0}.mycrm.com");
options.TenantResolvers.Add(new HeaderTenantResolveContributor());
});
}
This is common in SaaS products:
acme.mycrm.comnorthwind.mycrm.comConfiguration:
Configure<AbpTenantResolveOptions>(options =>
{
options.AddDomainTenantResolver("{0}.mycrm.com");
});
ABP extracts acme or northwind and uses it as the tenant identifier.
You can also map full domains to tenants, especially for enterprise customers using custom domains.
Examples:
crm.acme-enterprise.comportal.contosohealth.ioThis usually requires custom tenant lookup logic or a domain mapping table in your tenant store.
Useful for APIs, gateways, or internal service-to-service calls.
Example custom contributor:
using System.Threading.Tasks;
using Volo.Abp.MultiTenancy;
public class HeaderTenantResolveContributor : TenantResolveContributorBase
{
public const string HeaderName = "X-Tenant";
public override string Name => "Header";
public override Task ResolveAsync(ITenantResolveContext context)
{
var httpContext = context.GetHttpContext();
if (httpContext == null)
{
return Task.CompletedTask;
}
var tenant = httpContext.Request.Headers[HeaderName].ToString();
if (!tenant.IsNullOrWhiteSpace())
{
context.TenantIdOrName = tenant;
}
return Task.CompletedTask;
}
}
Useful for testing and some integration scenarios.
Example:
/api/products?__tenant=acmeThis is convenient, but usually not the best primary strategy for production browser apps.
Example:
/t/acme/productsThis can work well for APIs or apps where tenant identity is part of the route structure.
If your tenant identification depends on something domain-specific, implement your own contributor.
Example: resolve tenant from an API key prefix.
using System.Threading.Tasks;
using Volo.Abp.MultiTenancy;
public class ApiKeyTenantResolveContributor : TenantResolveContributorBase
{
public override string Name => "ApiKey";
public override Task ResolveAsync(ITenantResolveContext context)
{
var httpContext = context.GetHttpContext();
if (httpContext == null)
{
return Task.CompletedTask;
}
var apiKey = httpContext.Request.Headers["X-Api-Key"].ToString();
if (string.IsNullOrWhiteSpace(apiKey))
{
return Task.CompletedTask;
}
if (apiKey.StartsWith("acme_"))
{
context.TenantIdOrName = "acme";
}
return Task.CompletedTask;
}
}
sequenceDiagram
participant C as Client
participant R as Tenant Resolver Contributors
participant M as UseMultiTenancy Middleware
participant T as ITenantStore
participant CT as ICurrentTenant
participant A as Application Service
C->>R: HTTP request with host/header/query/route
R->>M: TenantIdOrName resolved
M->>T: Load tenant configuration
T-->>M: Tenant info + connection strings
M->>CT: Set current tenant context
CT->>A: Tenant-aware execution begins
The most important rule in ABP multi-tenancy is simple: entities that belong to a tenant should implement IMultiTenant.
using System;
using Volo.Abp.Domain.Entities.Auditing;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Products;
public class Product : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public Guid? TenantId { get; protected set; }
public string Name { get; private set; }
public decimal Price { get; private set; }
protected Product()
{
}
public Product(Guid id, Guid? tenantId, string name, decimal price)
: base(id)
{
TenantId = tenantId;
Name = name;
Price = price;
}
}
using System;
using Volo.Abp.Domain.Entities.Auditing;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Customers;
public class Customer : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public Guid? TenantId { get; protected set; }
public string CompanyName { get; private set; }
public string Email { get; private set; }
protected Customer()
{
}
public Customer(Guid id, Guid? tenantId, string companyName, string email)
: base(id)
{
TenantId = tenantId;
CompanyName = companyName;
Email = email;
}
}
using System;
using Volo.Abp.Domain.Entities.Auditing;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Orders;
public class Order : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public Guid? TenantId { get; protected set; }
public Guid CustomerId { get; private set; }
public DateTime OrderDate { get; private set; }
public decimal TotalAmount { get; private set; }
protected Order()
{
}
public Order(Guid id, Guid? tenantId, Guid customerId, DateTime orderDate, decimal totalAmount)
: base(id)
{
TenantId = tenantId;
CustomerId = customerId;
OrderDate = orderDate;
TotalAmount = totalAmount;
}
}
A few practical rules help avoid trouble:
TenantId on aggregate roots that are tenant-owned.Once an entity implements IMultiTenant, ABP applies the multi-tenant data filter automatically.
That means this repository call:
var products = await _productRepository.GetListAsync();
will only return rows for the current tenant when tenant context is active.
The exact SQL depends on your provider and query, but conceptually it looks like this:
SELECT Id, TenantId, Name, Price
FROM CrmProducts
WHERE TenantId = @CurrentTenantId
In host context, behavior depends on the entity and query shape. Host-owned rows typically have TenantId IS NULL.
TenantIdIMultiTenant defines Guid? TenantId, which is nullable because host-owned entities may exist.
If your entity must always belong to a tenant, you can enforce that in domain logic and EF configuration, but be careful. The framework’s default contract is nullable, and forcing non-null semantics requires deliberate mapping and validation.
ICurrentTenant is the runtime API you will use most often in multi-tenant services.
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Services;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Products;
public class ProductAppService : ApplicationService
{
private readonly ICurrentTenant _currentTenant;
public ProductAppService(ICurrentTenant currentTenant)
{
_currentTenant = currentTenant;
}
public Task<string> GetTenantInfoAsync()
{
var tenantId = _currentTenant.Id?.ToString() ?? "Host";
var tenantName = _currentTenant.Name ?? "Host";
return Task.FromResult($"TenantId: {tenantId}, TenantName: {tenantName}");
}
}
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Products;
public class ProductAppService : ApplicationService
{
private readonly IRepository<Product, Guid> _productRepository;
private readonly ICurrentTenant _currentTenant;
public ProductAppService(
IRepository<Product, Guid> productRepository,
ICurrentTenant currentTenant)
{
_productRepository = productRepository;
_currentTenant = currentTenant;
}
public async Task<Guid> CreateAsync(string name, decimal price)
{
if (!_currentTenant.IsAvailable)
{
throw new BusinessException("Products can only be created in tenant context.");
}
var product = new Product(GuidGenerator.Create(), _currentTenant.Id, name, price);
await _productRepository.InsertAsync(product, autoSave: true);
return product.Id;
}
}
ABP allows temporary tenant switching with CurrentTenant.Change(...).
using System;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Reporting;
public class TenantProductCounter : ITransientDependency
{
private readonly ICurrentTenant _currentTenant;
private readonly IRepository<Products.Product, Guid> _productRepository;
public TenantProductCounter(
ICurrentTenant currentTenant,
IRepository<Products.Product, Guid> productRepository)
{
_currentTenant = currentTenant;
_productRepository = productRepository;
}
public async Task<long> CountForTenantAsync(Guid tenantId)
{
using (_currentTenant.Change(tenantId))
{
return await _productRepository.GetCountAsync();
}
}
}
public async Task CompareTwoTenantsAsync(Guid tenantA, Guid tenantB)
{
using (_currentTenant.Change(tenantA))
{
var countA = await _productRepository.GetCountAsync();
using (_currentTenant.Change(tenantB))
{
var countB = await _productRepository.GetCountAsync();
}
}
}
Nested scopes are useful, but they should remain easy to reason about. If tenant switching becomes deeply nested, move that logic into dedicated services.
ICurrentTenantChange(...) sparingly and intentionally.ABP’s biggest multi-tenancy advantage is that data isolation is integrated into repositories and unit of work.
When an entity implements IMultiTenant, ABP applies the multi-tenant filter automatically.
That means:
ICurrentTenant.You usually do not need separate repositories per tenant. The same repository becomes tenant-aware because the current tenant context changes the filter behavior.
Example:
var customers = await _customerRepository.GetListAsync();
In tenant Acme, this returns only Acme customers.
ABP’s unit of work carries tenant context through the execution flow. This matters because:
Sometimes host-side reporting or maintenance tasks need cross-tenant access.
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
public class CrossTenantProductReader : ITransientDependency
{
private readonly IDataFilter _dataFilter;
private readonly IRepository<Products.Product, Guid> _productRepository;
public CrossTenantProductReader(
IDataFilter dataFilter,
IRepository<Products.Product, Guid> productRepository)
{
_dataFilter = dataFilter;
_productRepository = productRepository;
}
public async Task<List<Products.Product>> GetAllAsync()
{
using (_dataFilter.Disable<IMultiTenant>())
{
return await _productRepository.GetListAsync();
}
}
}
This is powerful and dangerous.
Disabling IMultiTenant filtering means you are bypassing one of your main isolation protections.
Use it only when:
Normal tenant-scoped query:
SELECT *
FROM CrmCustomers
WHERE TenantId = @CurrentTenantId
ORDER BY CompanyName
Cross-tenant query with filter disabled:
SELECT *
FROM CrmCustomers
ORDER BY TenantId, CompanyName
IMultiTenantSeeding is where many multi-tenant applications become inconsistent. The host needs one set of seed data, while each tenant may need its own initialization.
ABP’s IDataSeedContributor makes this manageable.
DataSeedContext.TenantId == nullDataSeedContext.TenantId has a valueCurrentTenant.Change(...)using System;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Data;
public class HostDataSeedContributor : IDataSeedContributor, ITransientDependency
{
private readonly ICurrentTenant _currentTenant;
public HostDataSeedContributor(ICurrentTenant currentTenant)
{
_currentTenant = currentTenant;
}
public async Task SeedAsync(DataSeedContext context)
{
if (context.TenantId != null)
{
return;
}
using (_currentTenant.Change(null))
{
await Task.CompletedTask;
// Seed host-side editions, plans, global settings, etc.
}
}
}
using System;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Data;
public class TenantDataSeedContributor : IDataSeedContributor, ITransientDependency
{
private readonly ICurrentTenant _currentTenant;
private readonly IRepository<Products.Product, Guid> _productRepository;
public TenantDataSeedContributor(
ICurrentTenant currentTenant,
IRepository<Products.Product, Guid> productRepository)
{
_currentTenant = currentTenant;
_productRepository = productRepository;
}
public async Task SeedAsync(DataSeedContext context)
{
if (context.TenantId == null)
{
return;
}
using (_currentTenant.Change(context.TenantId))
{
if (await _productRepository.GetCountAsync() > 0)
{
return;
}
await _productRepository.InsertAsync(
new Products.Product(Guid.NewGuid(), context.TenantId, "Starter Plan", 49),
autoSave: true
);
await _productRepository.InsertAsync(
new Products.Product(Guid.NewGuid(), context.TenantId, "Growth Plan", 99),
autoSave: true
);
}
}
}
A common SaaS flow looks like this:
Authentication is where many multi-tenant systems fail in subtle ways. If tenant resolution happens too late, users may authenticate against the wrong tenant context.
ABP’s identity integration helps because users are tenant-aware.
In ABP:
ABP Identity integrates with multi-tenancy so that user and role management respects tenant boundaries.
Typical outcomes:
The tenant should be resolved before authentication whenever possible.
Typical browser flow:
acme.mycrm.comacmesequenceDiagram
participant U as User Browser
participant D as Domain/Subdomain Resolver
participant MT as Multi-Tenancy Middleware
participant ID as Identity/Auth
participant APP as Application
U->>D: Request acme.mycrm.com/login
D->>MT: Tenant = acme
MT->>ID: Authenticate in tenant context
ID-->>APP: Authenticated tenant user
APP-->>U: Tenant-scoped session
Tenant switching can mean different things:
In most SaaS applications, the cleanest approach is domain-based switching rather than trying to mutate tenant context inside a long-lived UI session.
ABP supports user sharing strategies.
Unless you have a strong product reason, isolated users are usually the safer choice.
Database-per-tenant is where ABP’s abstractions become especially valuable.
At runtime, the application needs to know which database to use for the current tenant.
That information typically comes from ITenantStore.
ITenantStoreITenantStore is the abstraction used to retrieve tenant configuration, including:
ABP provides implementations through tenant management infrastructure. In simpler setups, configuration-based tenant stores can also be used.
For module-specific database usage, you can configure database options like this:
using Volo.Abp.Data;
using Volo.Abp.Modularity;
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpDbConnectionOptions>(options =>
{
options.Databases.Configure("Saas", database =>
{
database.IsUsedByTenants = true;
});
});
}
In a custom store or configuration source, you may keep tenant metadata like this:
{
"Tenants": [
{
"Id": "11111111-1111-1111-1111-111111111111",
"Name": "acme",
"ConnectionStrings": {
"Default": "Server=sql1;Database=Crm_Acme;User Id=app;Password=secret;TrustServerCertificate=True"
}
},
{
"Id": "22222222-2222-2222-2222-222222222222",
"Name": "northwind",
"ConnectionStrings": {
"Default": "Server=sql2;Database=Crm_Northwind;User Id=app;Password=secret;TrustServerCertificate=True"
}
}
]
}
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
public class AppTenantStore : ITenantStore, ITransientDependency
{
private readonly ConcurrentDictionary<string, TenantConfiguration> _tenantsByName;
private readonly ConcurrentDictionary<Guid, TenantConfiguration> _tenantsById;
public AppTenantStore(IOptions<AppTenantStoreOptions> options)
{
_tenantsByName = new ConcurrentDictionary<string, TenantConfiguration>(StringComparer.OrdinalIgnoreCase);
_tenantsById = new ConcurrentDictionary<Guid, TenantConfiguration>();
foreach (var tenant in options.Value.Tenants)
{
_tenantsByName[tenant.Name] = tenant;
if (tenant.Id.HasValue)
{
_tenantsById[tenant.Id.Value] = tenant;
}
}
}
public Task<TenantConfiguration?> FindAsync(string normalizedName)
{
_tenantsByName.TryGetValue(normalizedName, out var tenant);
return Task.FromResult(tenant);
}
public Task<TenantConfiguration?> FindAsync(Guid id)
{
_tenantsById.TryGetValue(id, out var tenant);
return Task.FromResult(tenant);
}
}
public class AppTenantStoreOptions
{
public TenantConfiguration[] Tenants { get; set; } = Array.Empty<TenantConfiguration>();
}
A production-grade tenant provisioning process usually includes:
ABP supports database-per-tenant architecture in general, but UI-based connection string management for tenants is part of the commercial SaaS tooling. In open-source solutions, you typically implement your own management UI or provisioning workflow.
Multi-tenancy affects more than repositories. In production systems, the tricky parts are usually background jobs, events, caching, features, settings, and observability.
If a background job processes tenant data, it must restore the correct tenant context.
using System;
using System.Threading.Tasks;
using Volo.Abp.BackgroundJobs;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
public class RebuildCustomerStatsArgs
{
public Guid TenantId { get; set; }
}
public class RebuildCustomerStatsJob : AsyncBackgroundJob<RebuildCustomerStatsArgs>, ITransientDependency
{
private readonly ICurrentTenant _currentTenant;
public RebuildCustomerStatsJob(ICurrentTenant currentTenant)
{
_currentTenant = currentTenant;
}
public override async Task ExecuteAsync(RebuildCustomerStatsArgs args)
{
using (_currentTenant.Change(args.TenantId))
{
await Task.CompletedTask;
// Recalculate tenant-specific metrics here.
}
}
}
When publishing distributed events, include tenant context explicitly if consumers need it.
Good practice:
TenantId in event payloads,Cache keys should include tenant identity.
Bad:
customer-listBetter:
tenant:{tenantId}:customer-listWithout tenant-aware cache keys, cross-tenant data leaks become very possible.
Features are a natural fit for SaaS plans.
Examples:
ABP feature management supports tenant-level configuration and edition-based grouping.
Settings can be scoped globally, per tenant, or per user.
Examples:
Audit logs should capture tenant context so you can answer questions like:
Tenants often need different localization defaults:
Tenant-level settings are usually the right place for these preferences.
Let’s connect the concepts with a practical example.
Imagine a SaaS CRM built with ABP.
The host side manages:
Each tenant manages:
Tenant users create and manage:
The host assigns plans such as:
These plans map naturally to ABP features and settings.
Host-owned entities:
Tenant-owned entities:
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
namespace Acme.Crm.Customers;
public class CustomerAppService : ApplicationService
{
private readonly IRepository<Customer, Guid> _customerRepository;
private readonly ICurrentTenant _currentTenant;
public CustomerAppService(
IRepository<Customer, Guid> customerRepository,
ICurrentTenant currentTenant)
{
_customerRepository = customerRepository;
_currentTenant = currentTenant;
}
public async Task<Guid> CreateAsync(string companyName, string email)
{
if (!_currentTenant.IsAvailable)
{
throw new BusinessException("Customer creation requires tenant context.");
}
var customer = new Customer(
GuidGenerator.Create(),
_currentTenant.Id,
companyName,
email
);
await _customerRepository.InsertAsync(customer, autoSave: true);
return customer.Id;
}
}
A request to https://acme.mycrm.com/api/customers flows like this:
acmeITenantStore loads tenant metadataICurrentTenant is setIMultiTenant filterThat is the core ABP multi-tenancy story in one request.
Here are practical best practices that hold up well in real systems.
IMultiTenant on every tenant-owned aggregate root. Do not rely on conventions or memory.IDataFilter.Disable<IMultiTenant>() as a privileged operation. Review those code paths carefully.TenantId on large tables. Shared-database performance depends on it.TenantId in cache keys, distributed events, and background job payloads. Ambient context does not cross every boundary.CurrentTenant being null is a valid and important scenario.CurrentTenant.Change(...) in small, obvious scopes.Even experienced teams make the same multi-tenancy mistakes.
Repository filtering is excellent, but it does not replace:
In ABP, host context is a first-class concept. Design for it explicitly.
Too early:
Too late:
You should test:
ABP’s multi-tenancy support is one of the framework’s strongest features because it combines architectural clarity with practical runtime behavior.
You get:
ICurrentTenant for runtime context,IMultiTenant for entity ownership,That does not remove the need for good architecture. You still need to make deliberate choices about isolation, authentication, provisioning, caching, and operations. But ABP gives you a solid default path and a consistent set of abstractions, which is exactly what you want in a serious SaaS platform.
If you are building an enterprise-grade SaaS application on .NET, ABP lets you spend more time on product logic and less time reinventing multi-tenancy infrastructure.
ICurrentTenant, IMultiTenant, tenant resolution middleware, and automatic data filters.