| name | xaf-multi-tenant |
| description | XAF Multi-tenancy (v24.2+) - separate database per tenant, AddMultiTenancy setup, ITenantProvider, built-in tenant resolvers (TenantByEmailResolver), custom resolvers, connection string per tenant, custom Tenant class, data isolation, host vs tenant UI, user-tenant association, scoped services, common pitfalls. Use when building multi-tenant SaaS applications with DevExpress XAF Blazor. |
XAF: Multi-Tenancy
Overview
XAF (v24.2+) built-in multi-tenancy for SaaS apps. One deployed instance serves multiple independent organizations (tenants) with strict database isolation (separate DB per tenant).
Key characteristics:
- Blazor Server (ASP.NET Core) — primary supported platform
- Two operating modes: Host UI (tenant management) and Tenant UI (tenant data)
- Tenant connection strings stored in the host database
- Built-in tenant resolver infrastructure
Approaches
| Approach | Isolation | Notes |
|---|
| Separate DB per tenant | Full | Default XAF approach; maximum isolation |
| Shared DB with discriminator | Partial | Not natively supported — must implement manually |
| Hybrid | Mixed | Shared config in host DB; operational data in tenant DBs |
Setup (Blazor / v24.2+)
dotnet add package DevExpress.ExpressApp.MultiTenancy
builder.Services.AddXaf(builder.Configuration, b => {
b.UseApplication<MyBlazorApplication>();
b.AddObjectSpaceProviders(providers => {
providers.UseEntityFramework(ef => {
ef.DefaultDatabaseConnection("HostConnection", p => p
.UseDbContext<HostDbContext>());
});
});
b.Security
.UseIntegratedMode(options => {
options.RoleType = typeof(PermissionPolicyRole);
options.UserType = typeof(ApplicationUser);
})
.AddPasswordAuthentication();
b.AddMultiTenancy()
.WithTenantResolver<TenantByEmailResolver>();
});
Built-in Tenant Resolvers
| Resolver | Login Pattern | Example |
|---|
TenantByEmailResolver | {User}@{Tenant} | john@acme → tenant = "acme" |
TenantByUserNameResolver | Custom regex | acme//john → tenant = "acme" |
Custom Tenant Resolver
using DevExpress.ExpressApp.MultiTenancy;
public class TenantBySubdomainResolver : ITenantResolver {
readonly IHttpContextAccessor _httpContext;
public TenantBySubdomainResolver(IHttpContextAccessor httpContext) {
_httpContext = httpContext;
}
public Task<string?> GetTenantNameAsync(string? loginName) {
var host = _httpContext.HttpContext?.Request.Host.Host ?? "";
var subdomain = host.Split('.').FirstOrDefault();
return Task.FromResult<string?>(subdomain);
}
}
b.AddMultiTenancy().WithTenantResolver<TenantBySubdomainResolver>();
ITenantProvider Interface
public interface ITenantProvider {
Guid? TenantId { get; }
string? TenantName { get; }
object? TenantObject { get; }
}
Getting the Current Tenant
From ObjectSpace:
var tenantProvider = objectSpace.ServiceProvider.GetService<ITenantProvider>();
Guid? tenantId = tenantProvider?.TenantId;
string? tenantName = tenantProvider?.TenantName;
In Controllers via DI:
public class TenantAwareController : ViewController {
ITenantProvider _tenantProvider;
[ActivatorUtilitiesConstructor]
public TenantAwareController(IServiceProvider serviceProvider) : base() {
_tenantProvider = serviceProvider.GetRequiredService<ITenantProvider>();
}
protected override void OnActivated() {
base.OnActivated();
var tenantName = _tenantProvider.TenantName;
}
}
Tenant-Aware Connection Strings
Each tenant's connection string is stored as a property of the Tenant object in the host DB. XAF uses IConnectionStringProvider to route requests to the correct tenant DB automatically.
var connectionStringProvider = serviceProvider.GetService<IConnectionStringProvider>();
string? connStr = connectionStringProvider?.GetConnectionString();
Custom Tenant Class
public class CustomTenant : Tenant {
public virtual string? LogoUrl { get; set; }
public virtual string? PrimaryColor { get; set; }
public virtual int MaxUsers { get; set; }
}
b.AddMultiTenancy()
.WithCustomTenantType<CustomTenant>()
.WithTenantResolver<TenantByEmailResolver>();
Access:
var tenant = tenantProvider.TenantObject as CustomTenant;
string? logoUrl = tenant?.LogoUrl;
Data Isolation
var orders = objectSpace.GetObjects<Order>();
if (objectSpace.CanInstantiate(typeof(Order))) {
}
No built-in cross-tenant query. For aggregated data across tenants, implement outside XAF's ObjectSpace.
User-Tenant Association
- Host DB: super admin accounts, tenant list, shared objects
- Tenant DB: tenant-specific users, roles, permissions, business data
var tenantUser = objectSpace.CreateObject<ApplicationUser>();
tenantUser.UserName = "newuser";
tenantUser.SetPassword("SecurePassword1!");
tenantUser.Roles.Add(defaultRole);
objectSpace.CommitChanges();
Admin Roles
| Role | DB | Responsibilities |
|---|
| Super Administrator | Host DB | Creates/deletes tenants, host config |
| Tenant Administrator | Tenant DB | Users, roles, permissions within tenant |
| Tenant User | Tenant DB | Normal end-user access |
var superAdminRole = objectSpace.CreateObject<PermissionPolicyRole>();
superAdminRole.Name = "SuperAdministrators";
superAdminRole.IsAdministrative = true;
var tenantAdminRole = objectSpace.CreateObject<PermissionPolicyRole>();
tenantAdminRole.Name = "TenantAdministrators";
tenantAdminRole.IsAdministrative = true;
Modules in Multi-Tenant Apps
| Module | Host UI | Tenant UI |
|---|
| Business data modules | No | Yes |
| Dashboards | No | Yes |
| File Attachments | No | Yes |
| Reports V2 | No | Yes |
| Scheduler | No | Yes |
| Security module | Yes | Yes |
Modules that register persistent types cannot operate in Host UI mode.
Common Pitfalls
| Pitfall | Solution |
|---|
| Singleton services holding tenant state | Use scoped services; never store tenant data in singletons |
| Same connection string for host and tenant | Always different connection strings — framework enforces this |
| Shared in-memory cache leaking across tenants | Use per-tenant cache keys: $"{tenantId}:{cacheKey}" |
| Querying business types in Host UI | Guard with objectSpace.CanInstantiate(typeof(T)) |
| Missing DB migration per tenant | Run migration per tenant DB via XAF's updater infrastructure |
services.AddSingleton<IMyService, MyService>();
services.AddScoped<IMyService, MyService>();
string cacheKey = $"{tenantProvider.TenantId}:ProductList";
cache.Set(cacheKey, products);
Source Links