Building a Multi-Tenant Application Architecture in .NET MAUI
๐ข Building a Multi-Tenant Application Architecture in .NET MAUI
Modern enterprise mobile applications frequently serve multiple organizations, customers, or business units through a single application.
Imagine building a .NET MAUI application used by several companies. Each organization may have its own users, branding, configuration, permissions, and business data.
Creating a separate application for every organization quickly becomes difficult to maintain.
A better approach is multi-tenancy.
Multi-tenant architecture allows a single application to support multiple organizations while maintaining logical separation between their configurations, identities, and data.
In this article, we'll explore how to design a multi-tenant architecture in .NET MAUI, covering tenant resolution, authentication, API integration, dependency injection, secure storage, and tenant switching.
Our goal is to build an architecture that is scalable, maintainable, and suitable for enterprise applications. ๐
- ๐ง Understanding Multi-Tenancy
A tenant represents an independent organization or customer using your application.
For example, imagine a mobile application serving three companies:
.NET MAUI Application
|
+-----------+-----------+
| | |
Tenant A Tenant B Tenant C
| | |
Users A Users B Users C
Data A Data B Data C
Config A Config B Config C
All tenants use the same application codebase, but their business contexts remain isolated.
A tenant may have its own:
- Users and roles
- Business data
- Feature availability
- Branding and themes
- API configuration
- Authentication policies
- Subscription plans
- Regional settings
Important: In a mobile architecture, the .NET MAUI application is not the security boundary between tenants. The backend must enforce tenant isolation independently for every authenticated request.
- ๐๏ธ Multi-Tenant Architecture Models
There are several ways to implement multi-tenancy.
Shared Database, Shared Schema
All tenants share the same database tables, with a TenantId identifying ownership.
Products
---------------------------------
Id TenantId Name
1 tenant-a Product A
2 tenant-b Product B
3 tenant-a Product C
This approach is cost-effective but requires strict tenant filtering and authorization.
Shared Database, Separate Schemas
Each tenant uses a separate database schema.
Database
|
+-- TenantA.Products
+-- TenantB.Products
+-- TenantC.Products
This provides stronger structural separation but increases database management complexity.
Database per Tenant
Each tenant has an independent database.
API
|
+-- Tenant A Database
+-- Tenant B Database
+-- Tenant C Database
This model provides greater isolation and deployment flexibility, but requires additional infrastructure and maintenance.
These are primarily backend architecture decisions.
The mobile application should interact with a consistent API contract regardless of how tenant data is stored.
- ๐ฆ Defining the Tenant Model
Let's start by defining a tenant configuration model.
public sealed record TenantConfiguration
{
public required string TenantId { get; init; }
public required string DisplayName { get; init; }
public required Uri ApiBaseUrl { get; init; }
public string? PrimaryColor { get; init; }
public bool EnableReports { get; init; }
public bool EnableNotifications { get; init; }
}
This model represents the configuration required to customize the application for a particular organization.
For example:
var tenant = new TenantConfiguration
{
TenantId = "company-a",
DisplayName = "Company A",
ApiBaseUrl = new Uri("https://api.example.com/"),
PrimaryColor = "#2563EB",
EnableReports = true,
EnableNotifications = false
};
Tenant configuration should normally be retrieved from a trusted backend.
Do not treat tenant identifiers, API endpoints, or feature flags supplied by the client as authorization evidence.
- ๐ Implementing Tenant Resolution
Before accessing tenant-specific resources, the application must identify the active tenant.
Common approaches include:
- Tenant selection during login.
- Tenant assignment through authenticated user claims.
- Tenant discovery through an organization code.
- Tenant selection from a list of authorized organizations.
Let's create an abstraction:
public interface ITenantResolver
{
Task<TenantConfiguration> ResolveAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
A backend implementation could retrieve the configuration from a discovery endpoint.
public sealed class TenantResolver : ITenantResolver
{
private readonly HttpClient _httpClient;
public TenantResolver(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<TenantConfiguration> ResolveAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var encodedId = Uri.EscapeDataString(tenantId);
var tenant = await _httpClient
.GetFromJsonAsync<TenantConfiguration>(
$"api/tenants/{encodedId}/configuration",
cancellationToken);
return tenant
?? throw new InvalidOperationException(
"Tenant configuration was not found.");
}
}
The discovery service must validate tenant membership before returning sensitive configuration.
A public discovery endpoint should expose only information safe for unauthenticated users.
- ๐งฉ Creating a Tenant Context
Once a tenant is selected, the application needs a centralized way to access its configuration.
public interface ITenantContext
{
TenantConfiguration? CurrentTenant { get; }
void SetTenant(TenantConfiguration tenant);
void Clear();
}
Implementation:
public sealed class TenantContext : ITenantContext
{
private TenantConfiguration? _currentTenant;
public TenantConfiguration? CurrentTenant =>
Volatile.Read(ref _currentTenant);
public void SetTenant(TenantConfiguration tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
Volatile.Write(ref _currentTenant, tenant);
}
public void Clear()
{
Volatile.Write(ref _currentTenant, null);
}
}
Register it through dependency injection:
builder.Services.AddSingleton<
ITenantContext,
TenantContext>();
Now ViewModels and services can access the active tenant without manually passing configuration through every page.
However, a mutable singleton is only a convenient representation of the active UI context.
It is not sufficient by itself for protecting concurrent operations during tenant switching. Requests should capture their tenant identity when they start, rather than relying on a mutable global value throughout their execution.
- ๐ Making HTTP Requests Tenant-Aware
Most enterprise applications communicate with backend APIs.
Every tenant-specific request must carry enough information for the backend to identify the intended organization.
A common approach is a custom HTTP header:
GET /api/products HTTP/1.1
Authorization: Bearer eyJ...
X-Tenant-Id: company-a
We can implement this through a DelegatingHandler.
public sealed class TenantHttpHandler : DelegatingHandler
{
private readonly ITenantContext _tenantContext;
public TenantHttpHandler(ITenantContext tenantContext)
{
_tenantContext = tenantContext;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var tenant = _tenantContext.CurrentTenant
?? throw new InvalidOperationException(
"No active tenant.");
request.Headers.Remove("X-Tenant-Id");
request.Headers.TryAddWithoutValidation(
"X-Tenant-Id",
tenant.TenantId);
return base.SendAsync(
request,
cancellationToken);
}
}
Register the handler:
builder.Services.AddTransient<TenantHttpHandler>();
builder.Services
.AddHttpClient("TenantApi")
.AddHttpMessageHandler<TenantHttpHandler>();
This centralizes tenant metadata instead of requiring every service to add headers manually.
๐ Critical Security Consideration
The backend must never trust X-Tenant-Id by itself.
It must verify that:
- The access token is valid.
- The authenticated user belongs to the requested tenant.
- The user has permission to access the requested resource.
- Database queries are restricted to the authorized tenant.
A malicious client can modify HTTP headers. Tenant isolation must therefore be enforced server-side.
Also, the handler shown above reads the active tenant when a request is sent. A production application should coordinate tenant switching with request cancellation and use immutable request-scoped tenant information for workflows that span multiple requests.
- ๐ Tenant-Aware Authentication
Authentication becomes more complex when a user belongs to multiple organizations.
For example:
User: developer@example.com
|
+-----+-----+
| |
Tenant A Tenant B
| |
Admin Viewer
The same user may have different roles in different tenants.
A robust authentication architecture should support tenant-scoped authorization.
A possible access token contains claims such as:
{
"sub": "user-123",
"tenant_id": "company-a",
"role": "Administrator",
"exp": 1790000000
}
When switching tenants, the application may need to obtain a new tenant-scoped access token.
Do not simply change the active tenant identifier while continuing to use an access token authorized only for the previous organization.
The exact behavior depends on the identity provider and backend authorization model.
- ๐ Implementing Tenant Switching
Tenant switching should be treated as an application-level operation, not merely a property update. Consider the following scenario:
Tenant A Active
|
v
User Selects Tenant B
|
v
Cancel Tenant A Requests
|
v
Clear Tenant A UI State
|
v
Validate Tenant B Access
|
v
Load Tenant B Configuration
|
v
Acquire Tenant B Credentials
|
v
Initialize Tenant B Services
|
v
Navigate to Tenant B Home
A coordinator can centralize this workflow.
public interface ITenantSwitchCoordinator
{
Task SwitchAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
The implementation should coordinate authentication, cancellation, state cleanup, and navigation.
For production systems, consider a two-phase approach:
Prepare: Validate access, resolve configuration, and acquire credentials for the target tenant.
Commit: Stop the previous tenant's operations, clear sensitive UI state, activate the new context, and navigate.
If preparation fails, the application can preserve the previous tenant session rather than leaving the user in an inconsistent state.
- ๐๏ธ Isolating Local Data
Multi-tenant applications often store local data for offline access.
This introduces a critical risk: accidentally displaying one organization's cached data while another tenant is active.
A straightforward approach is to use tenant-specific database files.
AppData/
|
+-- tenant-a.db
+-- tenant-b.db
+-- tenant-c.db
For example:
public static string GetTenantDatabasePath(
string tenantId)
{
var safeName = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(tenantId)));
return Path.Combine(
FileSystem.AppDataDirectory,
$"tenant-{safeName}.db");
}
Using a derived filename avoids directly placing untrusted tenant identifiers into filesystem paths.
However, separate database files do not automatically provide encryption or access control.
For sensitive enterprise data, consider encryption at rest, protected key storage, user-specific data separation, and explicit cleanup policies.
A tenant database should also be associated with the authenticated user where necessary, especially on shared devices.
- ๐จ Applying Tenant-Specific Branding
Multi-tenant applications frequently require different visual identities.
For example:
Tenant A
Primary Color: Blue
Logo: Company A
Reports: Enabled
Tenant B
Primary Color: Green
Logo: Company B
Reports: Disabled
In .NET MAUI, dynamic resources provide a convenient way to apply tenant-specific themes.
public static void ApplyTenantTheme(
TenantConfiguration tenant)
{
if (string.IsNullOrWhiteSpace(
tenant.PrimaryColor))
{
return;
}
MainThread.BeginInvokeOnMainThread(() =>
{
Application.Current!.Resources[
"TenantPrimaryColor"] =
Color.FromArgb(tenant.PrimaryColor);
});
}
Then reference the resource in XAML:
<Button
Text="Continue"
BackgroundColor="{DynamicResource TenantPrimaryColor}"
TextColor="White" />
When the tenant changes, the resource is updated and controls using DynamicResource can react automatically.
For production, validate colors, logos, and theme configuration before applying them.
- ๐ฉ Tenant-Specific Feature Availability
Different tenants may have access to different features depending on their subscriptions or business requirements. For example:
public sealed class TenantFeatureService
{
private readonly ITenantContext _tenantContext;
public TenantFeatureService(
ITenantContext tenantContext)
{
_tenantContext = tenantContext;
}
public bool IsReportsEnabled()
{
return _tenantContext
.CurrentTenant?
.EnableReports == true;
}
}
A ViewModel can use this information:
public bool CanViewReports =>
_tenantFeatureService.IsReportsEnabled();
This makes it possible to build a single application that adapts to multiple subscription tiers.
Feature visibility is not authorization. The backend must independently enforce permissions and subscription entitlements.
- ๐งช Testing Multi-Tenant Isolation
Multi-tenancy requires more than ordinary functional testing.
The most important tests verify that data and operations never cross tenant boundaries.
Consider these scenarios:
| Test | Expected Behavior |
|---|---|
| Switch from Tenant A to B | Tenant A data disappears |
| Request Tenant B data using Tenant A token | Backend rejects access |
| Load cached data | Only active tenant data is returned |
| Tenant switching during API request | Previous operation is canceled or isolated |
| Tenant B feature disabled | Feature is hidden and backend denies unauthorized use |
| Logout | Sensitive session data is cleared |
| Restart application | Tenant session is restored only after validation |
A particularly valuable integration test attempts to access a resource belonging to another tenant using a valid token for the current tenant.
The expected result is an authorization failure, not a successful response with filtered UI content.
- ๐๏ธ Recommended Project Structure
A maintainable implementation could follow this structure:
MultiTenantApp/
|
+-- Core/
| +-- Models/
| | +-- TenantConfiguration.cs
| |
| +-- Abstractions/
| +-- ITenantContext.cs
| +-- ITenantResolver.cs
| +-- ITenantSwitchCoordinator.cs
|
+-- Services/
| +-- TenantContext.cs
| +-- TenantResolver.cs
| +-- TenantSwitchCoordinator.cs
| +-- TenantFeatureService.cs
|
+-- Networking/
| +-- TenantHttpHandler.cs
|
+-- Security/
| +-- AuthenticationService.cs
| +-- TenantTokenProvider.cs
|
+-- Storage/
| +-- TenantDatabaseProvider.cs
|
+-- ViewModels/
| +-- TenantSelectionViewModel.cs
| +-- DashboardViewModel.cs
|
+-- Views/
| +-- TenantSelectionPage.xaml
| +-- DashboardPage.xaml
|
+-- Platforms/
This separates tenant identity, configuration, networking, authentication, and persistence.
As the application grows, tenant-specific capabilities can be implemented as independent modules without duplicating the entire application.
- โ ๏ธ Common Multi-Tenancy Mistakes
Trusting tenant identifiers supplied by the client A tenant header is useful for routing, but it is not proof of authorization.
Using a single local cache without tenant separation This can expose data belonging to another organization after switching tenants.
Reusing authentication tokens across unauthorized tenants Tenant switching may require new credentials or tenant-specific claims.
Changing the active tenant while requests are still running Responses from the previous tenant may arrive after the UI has switched, resulting in incorrect state updates.
Hardcoding tenant configuration Tenant branding, capabilities, and endpoints should be managed centrally where possible.
Treating feature flags as security controls Hiding a button does not prevent a malicious client from calling the corresponding API.
- ๐ Best Practices
For production-ready multi-tenant .NET MAUI applications:
- ๐ข Centralize tenant resolution and configuration.
- ๐ Enforce tenant isolation on the backend.
- ๐ชช Use tenant-aware authentication and authorization.
- ๐ Attach tenant metadata consistently to API requests.
- ๐ Coordinate tenant switching as a controlled workflow.
- ๐๏ธ Partition local databases and caches by tenant.
- ๐งน Clear sensitive UI state when switching organizations.
- ๐จ Apply branding through dynamic resources.
- ๐ฉ Separate feature visibility from authorization.
- ๐งช Include cross-tenant security tests.
- ๐ Add tenant identifiers to diagnostic traces without exposing sensitive data.
- ๐ก๏ธ Never trust client-controlled tenant information as a security boundary.
๐ฏ Conclusion
Multi-tenancy is more than displaying different company names or changing application themes.
A robust multi-tenant architecture requires careful coordination between identity, configuration, networking, local persistence, authorization, and application lifecycle.
In .NET MAUI, dependency injection, HTTP handlers, dynamic resources, and modular services provide a solid foundation for building applications that support multiple organizations from a single codebase.
However, the most important architectural principle remains unchanged:
Tenant isolation must be enforced by trusted backend services, while the mobile application ensures that tenant-specific state, credentials, and local resources are managed correctly.
By designing these boundaries from the beginning, you can build enterprise mobile applications that scale across organizations without sacrificing maintainability, user experience, or security. ๐ข๐๐
Was this useful?
Sign in to react. Guest comments are still welcome.




Comments (0)
No approved comments yet.