# Multi-Tenancy Architecture
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Tenant Aggregate Root Design](#tenant-aggregate-root-design)
3. [User Aggregate Root Adjustment](#user-aggregate-root-adjustment)
4. [TenantContext Service](#tenantcontext-service)
5. [EF Core Global Query Filter](#ef-core-global-query-filter)
6. [Tenant Resolution Middleware](#tenant-resolution-middleware)
7. [Database Schema](#database-schema)
8. [Application Layer Commands/Queries](#application-layer-commandsqueries)
9. [Security Protection](#security-protection)
10. [Testing Strategy](#testing-strategy)
---
## Architecture Overview
### Multi-Tenancy Pattern
ColaFlow uses the **Shared Database with Discriminator** pattern:
- **Single Database**: All tenants share the same PostgreSQL database
- **Tenant Isolation**: Every table has a `tenant_id` column for data segregation
- **Automatic Filtering**: EF Core Global Query Filters ensure tenant isolation
- **Performance**: Optimized with composite indexes on (tenant_id + business_key)
```mermaid
graph TB
A[User Request] --> B{Subdomain Detection}
B --> C[acme.colaflow.com]
B --> D[beta.colaflow.com]
C --> E[Extract tenant_slug='acme']
D --> F[Extract tenant_slug='beta']
E --> G[Query tenants table]
F --> G
G --> H[Inject tenant_id into JWT]
H --> I[EF Core Global Filter]
I --> J[WHERE tenant_id = current_tenant]
```
### System Components
```
┌─────────────────────────────────────────────────────────────┐
│ HTTP Request Layer │
│ (Subdomain: acme.colaflow.com / Header: X-Tenant-Id) │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────────┐
│ TenantResolutionMiddleware │
│ 1. Parse subdomain or custom header │
│ 2. Query tenants table by slug │
│ 3. Validate tenant status (Active/Suspended) │
│ 4. Inject TenantContext (Scoped) │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────────┐
│ TenantContext Service │
│ - CurrentTenantId: Guid │
│ - CurrentTenantSlug: string │
│ - CurrentTenantPlan: SubscriptionPlan │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────────┐
│ EF Core Global Query Filter │
│ - Intercepts ALL queries │
│ - Automatically appends: WHERE tenant_id = {current} │
│ - Applied to: Users, Projects, Issues, Documents, etc. │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────────┐
│ Database Layer │
│ - All tables have tenant_id column (NOT NULL) │
│ - Composite indexes: (tenant_id, created_at), etc. │
│ - Foreign keys include tenant validation │
└──────────────────────────────────────────────────────────────┘
```
---
## Tenant Aggregate Root Design
### Tenant Entity (Domain Layer)
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/Tenant.cs`
```csharp
using ColaFlow.Domain.Common;
using ColaFlow.Domain.Aggregates.TenantAggregate.Events;
using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
namespace ColaFlow.Domain.Aggregates.TenantAggregate;
///
/// Tenant aggregate root - represents a single organization/company in the system
///
public sealed class Tenant : AggregateRoot
{
// Properties
public TenantName Name { get; private set; }
public TenantSlug Slug { get; private set; }
public TenantStatus Status { get; private set; }
public SubscriptionPlan Plan { get; private set; }
public SsoConfiguration? SsoConfig { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? SuspendedAt { get; private set; }
public string? SuspensionReason { get; private set; }
// Settings
public int MaxUsers { get; private set; }
public int MaxProjects { get; private set; }
public int MaxStorageGB { get; private set; }
// Private constructor for EF Core
private Tenant() { }
// Factory method for creating new tenant
public static Tenant Create(
TenantName name,
TenantSlug slug,
SubscriptionPlan plan = SubscriptionPlan.Free)
{
var tenant = new Tenant
{
Id = TenantId.CreateUnique(),
Name = name,
Slug = slug,
Status = TenantStatus.Active,
Plan = plan,
CreatedAt = DateTime.UtcNow,
MaxUsers = GetMaxUsersByPlan(plan),
MaxProjects = GetMaxProjectsByPlan(plan),
MaxStorageGB = GetMaxStorageByPlan(plan)
};
tenant.AddDomainEvent(new TenantCreatedEvent(tenant.Id, tenant.Slug));
return tenant;
}
// Business methods
public void UpdateName(TenantName newName)
{
if (Status == TenantStatus.Cancelled)
throw new InvalidOperationException("Cannot update cancelled tenant");
Name = newName;
UpdatedAt = DateTime.UtcNow;
}
public void UpgradePlan(SubscriptionPlan newPlan)
{
if (newPlan <= Plan)
throw new InvalidOperationException("New plan must be higher than current plan");
if (Status != TenantStatus.Active)
throw new InvalidOperationException("Only active tenants can upgrade");
Plan = newPlan;
MaxUsers = GetMaxUsersByPlan(newPlan);
MaxProjects = GetMaxProjectsByPlan(newPlan);
MaxStorageGB = GetMaxStorageByPlan(newPlan);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantPlanUpgradedEvent(Id, newPlan));
}
public void ConfigureSso(SsoConfiguration ssoConfig)
{
if (Plan == SubscriptionPlan.Free)
throw new InvalidOperationException("SSO is only available for Pro and Enterprise plans");
SsoConfig = ssoConfig;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantSsoConfiguredEvent(Id, ssoConfig.Provider));
}
public void Suspend(string reason)
{
if (Status == TenantStatus.Cancelled)
throw new InvalidOperationException("Cannot suspend cancelled tenant");
Status = TenantStatus.Suspended;
SuspendedAt = DateTime.UtcNow;
SuspensionReason = reason;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantSuspendedEvent(Id, reason));
}
public void Reactivate()
{
if (Status != TenantStatus.Suspended)
throw new InvalidOperationException("Only suspended tenants can be reactivated");
Status = TenantStatus.Active;
SuspendedAt = null;
SuspensionReason = null;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantReactivatedEvent(Id));
}
public void Cancel()
{
Status = TenantStatus.Cancelled;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TenantCancelledEvent(Id));
}
// Plan limits
private static int GetMaxUsersByPlan(SubscriptionPlan plan) => plan switch
{
SubscriptionPlan.Free => 5,
SubscriptionPlan.Pro => 50,
SubscriptionPlan.Enterprise => int.MaxValue,
_ => throw new ArgumentOutOfRangeException(nameof(plan))
};
private static int GetMaxProjectsByPlan(SubscriptionPlan plan) => plan switch
{
SubscriptionPlan.Free => 3,
SubscriptionPlan.Pro => 100,
SubscriptionPlan.Enterprise => int.MaxValue,
_ => throw new ArgumentOutOfRangeException(nameof(plan))
};
private static int GetMaxStorageByPlan(SubscriptionPlan plan) => plan switch
{
SubscriptionPlan.Free => 2,
SubscriptionPlan.Pro => 100,
SubscriptionPlan.Enterprise => 1000,
_ => throw new ArgumentOutOfRangeException(nameof(plan))
};
}
```
### Value Objects
**File**: `src/ColaFlow.Domain/Aggregates/TenantAggregate/ValueObjects/TenantId.cs`
```csharp
using ColaFlow.Domain.Common;
namespace ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects;
public sealed class TenantId : ValueObject
{
public Guid Value { get; }
private TenantId(Guid value)
{
Value = value;
}
public static TenantId CreateUnique() => new(Guid.NewGuid());
public static TenantId Create(Guid value)
{
if (value == Guid.Empty)
throw new ArgumentException("Tenant ID cannot be empty", nameof(value));
return new TenantId(value);
}
protected override IEnumerable