Most articles on multi-tenant architecture focus on what services to use. In reality, the harder part is what happens after the system is live. In this post, I’ll walk through a common Azure-based design and then focus on the problems that actually show up in real systems.
Azure Multi-Tenant Architecture (B2C Scenario)
Let’s start with a reference design commonly used in Azure-based systems.
A pretty standard setup looks something like this:
- Microsoft Entra External ID (Azure AD B2C) for authentication
- Azure API Management as the entry layer
- App Service or Functions for the compute layer
- Cosmos DB or SQL for storage
- Redis for caching
- Service Bus for async processing
- Application Insights for monitoring
If you’ve worked on Azure systems, nothing here is surprising.
On paper, this architecture is clean, scalable, and “multi-tenant ready”.
But once traffic starts flowing and tenants behave differently, things start breaking in subtle ways.
1. Tenant Context Propagation Across Services
-
A request doesn’t stay in one place. It moves across:
- API layer
- queues/topics
- background workers
What I’ve seen happen multiple times:
- tenant ID is present in the API, but missing in async flows
- background jobs process data without knowing which tenant it belongs to
- logs become useless because you can’t tie actions back to a tenant
The fix is simple in theory, but often missed in implementation:
Every message should carry tenant context. No exceptions.
If you rely on “it will be available somewhere”, it won’t be, especially in distributed systems.
Ensure tenant context is explicitly carried everywhere:
public class TenantMessage
{
public string TenantId { get; set; }
public string Payload { get; set; }
}
Every message, event, and async operation should include tenant scope.
2. Data Isolation in Shared Databases
Most teams start with a shared database model with tenant-based partitioning.
It works well initially.
Problems start creeping in later:
-
- someone forgets to add a tenant filter in a query
- a query suddenly scans across partitions
- one large tenant starts slowing down others
A simple query like this becomes critical:
var query = container.GetItemQueryIterator<Order>(
new QueryDefinition("SELECT * FROM c WHERE c.tenantId = @tenantId")
.WithParameter("@tenantId", tenantId)
);
The tricky part is not writing it once, it’s making sure it’s applied everywhere, every time.
3. Authorization Beyond Tenant Boundaries
At the beginning, access control is simple:
“Users can access data from their own tenant.”
But then requirements grow:
- admin access
- cross-tenant visibility
- reporting across firms or regions
And this is where things usually get messy.
Different services start implementing their own logic, and over time you end up with inconsistent behavior.
A simple check:
public bool CanAccess(string userTenant, string resourceTenant, bool isGlobalAdmin)
{
if (isGlobalAdmin) return true;
return userTenant == resourceTenant;
}
becomes much harder to manage when duplicated across multiple services.
One thing that helps a lot here is centralizing authorization logic early.
4. Caching as a Hidden Risk
Caching is usually added later for performance.
And that’s exactly why it becomes risky.
I’ve seen scenarios where:
- cached data from one tenant is returned to another
- because the cache key didn’t include tenant information
Fixing it is straightforward:
public string BuildCacheKey(string tenantId, string key)
{
return $"{tenantId}:{key}";
}
Cache keys must always include tenant boundaries
5. Resource Contention (Noisy Neighbor Problem)
All tenants share resources:
- compute
- database throughput
- messaging
What happens in practice:
- one high-load tenant impacts others
- latency becomes unpredictable
- system behavior differs per tenant
You start adding controls like:
if (RequestsPerTenant[tenantId] > 100)
{
return StatusCode(429);
}
And gradually move towards:
- throttling
- workload isolation
- prioritization
This is less of a design problem and more of an operational reality.
6. Observability in Multi-Tenant Systems
Logging works great, until you scale.
Then suddenly:
- logs from all tenants are mixed
- debugging becomes slow
- it’s hard to answer basic questions like “which tenant failed?”
A small change makes a huge difference:
_logger.LogInformation(
"Tenant={TenantId} Action=ProcessOrder OrderId={OrderId}",
tenantId,
orderId
);
It sounds obvious, but it’s often inconsistent across services.
7. Backup and Restore Considerations
Taking backups is easy.
Restoring a single tenant isn’t.
In most shared database setups:
- restore is done at database level
- which affects all tenants
So if one tenant has a problem, recovery is not straightforward.
This is one of those areas where decisions made early in design matter a lot later.
Final Thoughts
Designing a multi-tenant system is not just about choosing Azure services.
The real challenges come from:
- how tenant context flows
- how isolation is enforced
- how systems behave under uneven load
Most issues don’t show up on day one.
They appear gradually as tenants grow, scale, and behave differently.
References and Further Reading
If you want to explore these concepts in more depth, here are some useful official resources: