Advanced Multi-Tenancy Patterns
This guide covers advanced patterns for working with PearDrop's multi-tenant system, including TenantId propagation, testing strategies, and common implementation patterns.
TenantId: The Foundation
Every tenant-scoped entity has a TenantId (Guid) column that identifies which tenant owns the data:
public class Task : Entity, IAggregateRoot
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public Guid TenantId { get; private set; } // ← Links to tenant
public void AssignTenant(Guid tenantId)
{
this.TenantId = tenantId;
## Pattern: Manual Query Filters (Legacy - Deprecated)
}
}
This simple column enables powerful isolation patterns.
Pattern: Assigning Tenants in Commands
Always assign the current tenant when creating new entities:
public sealed class CreateTaskCommandHandler :
AuditableCommandHandler<CreateTaskCommand, CreateTaskCommandResult>
{
private readonly ITenantIdentifier tenantIdentifier;
public CreateTaskCommandHandler(
IEnumerable<IValidator<CreateTaskCommand>> validators,
ILogger<CreateTaskCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<Task> repositoryFactory,
ITenantIdentifier tenantIdentifier) // ← Inject tenant context
: base(validators, logger, commandStore, repositoryFactory)
{
this.tenantIdentifier = tenantIdentifier;
}
protected override async Task<CommandResult<CreateTaskCommandResult>>
HandleInternalWithRepository(
CreateTaskCommand request,
CancellationToken cancellationToken)
{
// Create aggregate
var task = Task.Create(request.Title, request.Description);
// CRITICAL: Assign current tenant BEFORE saving
task.AssignTenant(this.tenantIdentifier.CurrentTenantId);
// Add to repository and save
this.Repository.Add(task);
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return result.IsSuccess
? CommandResult<CreateTaskCommandResult>.Succeeded(
new CreateTaskCommandResult(task.Id))
: CommandResult<CreateTaskCommandResult>.Failed(result.Error!);
}
}
Never Skip This Step:
- If you don't assign TenantId before saving, the record belongs to no tenant
- Global query filters won't find it properly
- Security bugs occur when TenantId is null
Pattern: Global Query Filters (Standardized)
PearDrop uses a standardized base class for tenant filtering on read models (projections). This ensures consistent tenant isolation without manual filter implementation.
Read Model Configuration Pattern
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PearDrop.Database.Contracts;
public sealed class TaskProjectionTypeConfiguration :
TenantFilteredEntityTypeConfigurationBase<TaskProjection>
{
public TaskProjectionTypeConfiguration(Func<string, object?> getFilterData)
: base(getFilterData)
{
}
protected override void ConfigureEntity(EntityTypeBuilder<TaskProjection> builder)
{
// Entity mappings, relationships, keys, indexes
builder.ToView("vw_task", "myapp");
builder.HasKey(task => task.Id);
builder.Property(task => task.Title)
.HasMaxLength(200)
.IsRequired();
}
protected override Expression<Func<TaskProjection, Guid>> GetTenantIdExpression()
{
// Direct TenantId property access
return task => task.TenantId;
}
}
Read DbContext Registration
public sealed class QueryableTask
{
public Guid Id { get; init; }
public Guid TenantId { get; init; }
public class MyAppReadDbContext : PearDropReadDbContextBase<MyAppReadDbContext>
protected override void OnModelCreating(ModelBuilder modelBuilder)
// Pass getFilterData function to each configuration
modelBuilder.ApplyConfiguration(
new TaskProjectionTypeConfiguration(this.GetFilterData));
modelBuilder.ApplyConfiguration(
new UserProjectionTypeConfiguration(this.GetFilterData));
// Base class automatically applies tenant filters
}
}
Navigation Property Filtering
For entities filtered through navigation properties:
public sealed class TaskAssignmentProjectionTypeConfiguration :
TenantFilteredEntityTypeConfigurationBase<TaskAssignmentProjection>
{
public TaskAssignmentProjectionTypeConfiguration(Func<string, object?> getFilterData)
: base(getFilterData)
}
protected override void ConfigureEntity(EntityTypeBuilder<TaskAssignmentProjection> builder)
builder.ToView("vw_task_assignment", "myapp");
builder.HasKey(ta => ta.Id);
builder.HasKey(e => e.Id);
// Define navigation relationships
builder.HasOne(ta => ta.Task)
.WithMany()
.HasForeignKey(ta => ta.TaskId);
}
protected override Expression<Func<TaskAssignmentProjection, Guid>> GetTenantIdExpression()
{
// Filter through parent entity's TenantId
return assignment => assignment.Task!.TenantId;
e.TenantId == tenantId); // Otherwise filter to current tenant
}
}
- Framework extracts current tenant from HTTP context → stored as
"tenantId"filter data TenantFilteredEntityTypeConfigurationBasebuilds query filter:tenantId == Guid.Empty || entity.TenantId == tenantId- Filter automatically applied to all
IReadModelQueryable<T>queries - Developers don't need to remember manual
Where(t => t.TenantId == ...)clauses - Developers don't need to remember to add
Where(t => t.TenantId == ...)Benefits of Standardized Pattern:
- ✅ Consistent filtering logic across all modules
- ✅ No duplicate filter expressions in DbContext
- ✅ Type-safe expression building
- ✅ Supports both direct properties and navigation properties
- ✅ Centralized maintenance (update base class, all modules benefit) This is how accidental data leaks are prevented.
Pattern: System-Wide Queries
Occasionally you need to query across all tenants (e.g., admin dashboards, reporting):
public sealed class GetMultiTenantReportQueryHandler(
ITasksReadModels taskReadModels) :
IQueryProcessor<GetMultiTenantReportQuery, GetMultiTenantReportResult>
{
public async Task<QueryResult<GetMultiTenantReportResult>> Handle(
GetMultiTenantReportQuery request,
CancellationToken cancellationToken = default)
{
// Option 1: Override filter with special permission check
if (!await HasAdminPermissionAsync())
{
return QueryResult<GetMultiTenantReportResult>.Failed();
}
// Option 2: Use raw DbContext for system queries
// (requires special authorization)
var context = new ReportingDbContext();
var allTenantTasks = await context.Tasks
// TenantId filter is NOT applied here - we have all data
.GroupBy(t => t.TenantId)
.Select(g => new TenantReportLine
{
TenantId = g.Key,
TaskCount = g.Count()
})
.ToListAsync(cancellationToken);
return QueryResult<GetMultiTenantReportResult>.Succeeded(
new GetMultiTenantReportResult(allTenantTasks));
}
}
Best Practices:
- Document why you need cross-tenant data
- Add explicit authorization checks
- Log access for audit trails
- Consider using a separate reporting database
Pattern: Tenant Propagation in Background Jobs
When processing data in background tasks, the tenant context isn't automatically available:
// ❌ Wrong - No tenant context in background job
public class ProcessTasksBackgroundJob
{
private readonly ITaskService taskService;
public async Task ExecuteAsync()
{
// What tenant should this run for?
var tasks = await taskService.GetAllAsync(); // Ambiguous!
}
}
// ✅ Right - Explicitly pass tenant context
public class ProcessTasksBackgroundJob
{
private readonly ITaskService taskService;
private readonly ITenantIdentifier tenantIdentifier;
public async Task ExecuteAsync(Guid tenantId)
{
// Manually set tenant context
using (new TenantScope(tenantIdentifier, tenantId))
{
var tasks = await taskService.GetAllAsync();
// Tasks are filtered to specified tenant
}
}
}
// Caller
backgroundJobClient.Enqueue(() => job.ExecuteAsync(tenantId));
Pattern: Creating New Entities
Write Model (Command)
public sealed class Task : Entity, IAggregateRoot
{
public Guid Id { get; private set; }
public Guid TenantId { get; private set; } // ← Stored
public string Title { get; private set; }
// Factory method
public static Task Create(string title)
{
return new Task { Id = Guid.NewGuid(), Title = title };
}
// Assignment method (called after creation)
public void AssignTenant(Guid tenantId)
{
this.TenantId = tenantId;
}
}
// In command handler
var task = Task.Create(request.Title);
task.AssignTenant(tenantIdentifier.CurrentTenantId);
repository.Add(task);
EF Configuration (Write Model)
public sealed class TaskEntityConfiguration : IEntityTypeConfiguration<Task>
{
public void Configure(EntityTypeBuilder<Task> builder)
{
builder.ToTable("Task", "myapp");
builder.HasKey(e => e.Id);
// Map TenantId column
builder.Property(e => e.TenantId)
.HasColumnName("TenantId")
.IsRequired(); // Not nullable!
}
}
Read Model (Query)
public sealed class TaskProjection : IProjection
{
public Guid Id { get; init; }
public Guid TenantId { get; init; } // ← Required for filtering
public string Title { get; init; }
}
public sealed class TaskProjectionTypeConfiguration :
TenantFilteredEntityTypeConfigurationBase<TaskProjection>
{
public TaskProjectionTypeConfiguration(Func<string, object?> getFilterData)
: base(getFilterData)
{
}
protected override void ConfigureEntity(EntityTypeBuilder<TaskProjection> builder)
{
builder.ToView("vw_task", "myapp");
builder.HasKey(task => task.Id);
// No manual HasQueryFilter needed - base class handles it
}
protected override Expression<Func<TaskProjection, Guid>> GetTenantIdExpression()
=> task => task.TenantId;
}
Read DbContext Registration
public class MyAppReadDbContext : PearDropReadDbContextBase<MyAppReadDbContext>
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Pass getFilterData to each configuration
modelBuilder.ApplyConfiguration(
new TaskProjectionTypeConfiguration(this.GetFilterData));
// Base class applies tenant filters automatically
}
}
Pattern: Testing Multi-Tenant Operations
Unit Tests with Tenant Context
[TestFixture]
public class CreateTaskCommandTests
{
private ICommandRunner commandRunner;
private ITenantIdentifier tenantIdentifier;
private IRepository<Task> repository;
[SetUp]
public void Setup()
{
// Create test context
var services = new ServiceCollection();
// ... register services
this.tenantIdentifier = services.BuildServiceProvider()
.GetRequiredService<ITenantIdentifier>();
this.commandRunner = /* ... */;
this.repository = /* ... */;
}
[Test]
public async Task CreateTask_AssignsTenantId()
{
// Arrange
var tenantId = Guid.NewGuid();
var command = new CreateTaskCommand("Test Task");
// Set tenant context for this test
using (new TenantScope(this.tenantIdentifier, tenantId))
{
// Act
var result = await this.commandRunner.RunAsync(command);
// Assert
var savedTask = await this.repository.FindOne(
new ByIdSpecification<Task>(result.TaskId),
CancellationToken.None);
// Task should have the tenant ID we set
Assert.That(savedTask.Value.TenantId, Is.EqualTo(tenantId));
}
}
[Test]
public async Task QueryTask_FiltersByTenant()
{
// Arrange
var tenant1 = Guid.NewGuid();
var tenant2 = Guid.NewGuid();
// Create task for tenant1
using (new TenantScope(this.tenantIdentifier, tenant1))
{
await this.commandRunner.RunAsync(new CreateTaskCommand("Tenant1 Task"));
}
// Create task for tenant2
using (new TenantScope(this.tenantIdentifier, tenant2))
{
await this.commandRunner.RunAsync(new CreateTaskCommand("Tenant2 Task"));
}
// Act: Query as tenant1
using (new TenantScope(this.tenantIdentifier, tenant1))
{
var query = new GetTasksQuery();
var result = await /* query handler */.Handle(query);
// Assert: Should only see tenant1's task
Assert.That(result.Tasks, Has.Count.EqualTo(1));
Assert.That(result.Tasks[0].Title, Contains.Substring("Tenant1"));
}
}
}
Multi-Tenant Isolation Validation
[TestFixture]
public class TenantIsolationTests
{
[Test]
public async Task DataLeakTest_TenantCannnotAccessOtherTenantData()
{
// Arrange
var tenant1Id = Guid.NewGuid();
var tenant2Id = Guid.NewGuid();
// Tenant1 creates a task
string targetTaskId;
using (new TenantScope(tenantIdentifier, tenant1Id))
{
var result = await commandRunner.RunAsync(
new CreateTaskCommand("Secret Task"));
targetTaskId = result.TaskId.ToString();
}
// Act: Tenant2 tries to query as tenant1
using (new TenantScope(tenantIdentifier, tenant2Id))
{
var query = new GetTasksQuery();
var result = await queryHandler.Handle(query);
// Assert: Should NOT see tenant1's task
Assert.That(
result.Tasks.Any(t => t.Id.ToString() == targetTaskId),
Is.False,
"Tenant2 should not see Tenant1 data!");
}
}
}
Common Mistakes
❌ Mistake 1: Forgetting to Assign TenantId
// ❌ Wrong - TenantId is null/default
var task = Task.Create(request.Title);
repository.Add(task);
await repository.SaveAsync(); // TenantId is Guid.Empty!
// ✅ Right
var task = Task.Create(request.Title);
task.AssignTenant(tenantIdentifier.CurrentTenantId);
repository.Add(task);
await repository.SaveAsync();
❌ Mistake 2: Querying Without Filters
// ❌ Wrong - Might return another tenant's data
using (var context = new TaskDbContext())
{
var tasks = context.Tasks.Where(t => t.Status == "Open").ToList();
// ORM doesn't know about global filters when raw DbContext used
}
// ✅ Right - Use read models with automatic filtering
var tasks = await readModels.Tasks
.Where(t => t.Status == "Open")
.ToListAsync(); // Filtered to current tenant automatically
❌ Mistake 3: Not Testing Tenant Isolation
// ❌ Wrong - Only tests happy path
[Test]
public async Task Can_Create_Task()
{
var result = await commandRunner.RunAsync(new CreateTaskCommand("Test"));
Assert.That(result.Success, Is.True);
}
// ✅ Right - Tests isolation boundary
[Test]
public async Task Different_Tenants_See_Different_Data()
{
// Create for tenant1, verify tenant2 can't see it
// Highly specific test for security
}
❌ Mistake 4: Not Using Standardized Base Class
// ❌ Wrong - Manual filter implementation (legacy pattern)
public sealed class TaskProjectionTypeConfiguration :
ProjectionTypeConfigurationBase<TaskProjection>
{
public override void Configure(EntityTypeBuilder<TaskProjection> builder)
{
builder.ToView("vw_task");
var tenantId = /* get tenant somehow */;
builder.HasQueryFilter(e => tenantId == Guid.Empty || e.TenantId == tenantId);
}
}
// ✅ Right - Use base class for standardized filtering
public sealed class TaskProjectionTypeConfiguration :
TenantFilteredEntityTypeConfigurationBase<TaskProjection>
{
public TaskProjectionTypeConfiguration(Func<string, object?> getFilterData)
: base(getFilterData)
{
}
protected override void ConfigureEntity(EntityTypeBuilder<TaskProjection> builder)
{
builder.ToView("vw_task");
// No manual filter - base class applies it automatically
}
protected override Expression<Func<TaskProjection, Guid>> GetTenantIdExpression()
=> task => task.TenantId;
}
Migration Checklist
When adding multi-tenancy to an existing entity:
- Add
Guid TenantId { get; private set; }to write model aggregate - Add
void AssignTenant(Guid tenantId)method - Add
Guid TenantId { get; init; }to read model - Add tenant assignment in command handler before SaveAsync()
- Configure column in EF mapping (IsRequired())
- Create migration to add column as nullable
- Create migration to backfill TenantId from control table
- Create migration to add NOT NULL constraint
- Create migration to add composite indexes
- Update read model configurations to extend
TenantFilteredEntityTypeConfigurationBase - Add constructor accepting
Func<string, object?> getFilterDatato each configuration - Update read DbContext to pass
this.GetFilterDatato configuration constructors - Implement
GetTenantIdExpression()for each read model configuration - Remove manual
HasQueryFilter()calls from DbContext (if any) - Test tenant isolation with unit tests
- Verify no data leakage in integration tests
Single-Tenant Mode
PearDrop uses a special "well-known" GUID for single-tenant apps:
// SingleTenantIdentifier.CurrentTenantId always returns:
public static readonly Guid WellKnownSingleTenantId =
new("00000000-0000-0000-0000-000000000001");
This means:
- Single-tenant code uses the same structures as multi-tenant
- Migrating to multi-tenancy is straightforward (just add more tenants)
- No special single-tenant code paths needed
// Works for both single and multi-tenant
task.AssignTenant(tenantIdentifier.CurrentTenantId);
// SingleTenantIdentifier: Well-known GUID
// MultiTenantIdentifier: Current tenant from HTTP context
Performance Considerations
Indexes
Always create composite indexes for common queries:
builder.HasIndex(e => new { e.TenantId, e.Status })
.HasName("IX_Task_TenantId_Status");
builder.HasIndex(e => new { e.TenantId, e.CreatedAt })
.HasName("IX_Task_TenantId_CreatedAt");
builder.HasIndex(e => new { e.TenantId, e.UserId, e.Status })
.HasName("IX_Task_TenantId_UserId_Status");
Query Optimization
// ✅ Good - TenantId in WHERE clause, database can use index
var tasks = readModels.Tasks
.Where(t => t.TenantId == currentTenant && t.Status == "Open")
.ToListAsync();
// ❌ Slow - TenantId only in the global filter, not indexed
var tasks = readModels.Tasks
.Where(t => t.Status == "Open")
.ToListAsync();