Skip to main content

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
}
}

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
}
}
  1. Framework extracts current tenant from HTTP context → stored as "tenantId" filter data
  2. TenantFilteredEntityTypeConfigurationBase builds query filter: tenantId == Guid.Empty || entity.TenantId == tenantId
  3. Filter automatically applied to all IReadModelQueryable<T> queries
  4. Developers don't need to remember manual Where(t => t.TenantId == ...) clauses
  5. 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?> getFilterData to each configuration
  • Update read DbContext to pass this.GetFilterData to 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();