Multi-Tenancy in PearDrop
Understanding single-tenant and multi-tenant configurations.
Single-Tenant vs Multi-Tenant
Single-Tenant:
- One customer, one application instance
- Well-known tenant ID:
00000000-0000-0000-0000-000000000001 - Simple data isolation (all data in one database)
- Easy to start, scales vertically
Multi-Tenant:
- Multiple customers sharing application
- Each tenant has unique ID
- Optional per-tenant databases
- More complex, scales horizontally
Single-Tenant Mode
By default, PearDrop projects run in single-tenant mode:
// .NET configuration
var singleTenant = new SingleTenantContextAccessor();
var tenantId = singleTenant.CurrentTenantId;
// → Always returns: 00000000-0000-0000-0000-000000000001
Benefits of single-tenant:
- ✅ Simple to implement
- ✅ Can migrate to multi-tenant later (both use GUIDs)
- ✅ No tenant isolation overhead
- ✅ Familiar development experience
Multi-Tenant Mode
To enable multi-tenancy, configure tenant resolution:
URL-Based Tenancy
Each tenant gets a subdomain:
https://acme.myapp.com → Tenant: "acme"
https://globex.myapp.com → Tenant: "globex"
Configuration:
// Program.cs
builder.Services.AddMultiTenant<TenantInfo>()
.WithRouteStrategy("SubdomainStrategy", options =>
{
options.Template = "{subdomain}.{domain}.{tld}";
});
Claim-Based Tenancy
Tenant ID is stored in user claims:
// User's JWT contains tenant claim
{
"sub": "user-123",
"email": "john@example.com",
"tenant_id": "acme-corp-guid"
}
Configuration:
builder.Services.AddMultiTenant<TenantInfo>()
.WithClaimStrategy("tenant_id");
Data Isolation in Queries
Read models automatically filter by tenant:
public class MyQueryHandler : IQueryProcessor<MyQuery, MyResult>
{
private readonly IAppReadModels readModels;
private readonly IMultiTenantContextAccessor tenantAccessor;
public async Task<QueryResult<MyResult>> Handle(
MyQuery request,
CancellationToken cancellationToken = default)
{
var currentTenant = tenantAccessor.MultiTenantContext?.TenantInfo?.Id;
// Read models filters by tenant automatically if configured
var data = await this.readModels.MyData
.Where(x => x.TenantId == currentTenant) // Explicit tenant filter
.ToListAsync(cancellationToken);
return QueryResult<MyResult>.Succeeded(...);
}
}
Tenant Information
Access current tenant details:
using Finbuckle.MultiTenant;
public class MyService
{
private readonly IMultiTenantContextAccessor multiTenantContextAccessor;
public MyService(IMultiTenantContextAccessor accessor)
{
this.multiTenantContextAccessor = accessor;
}
public void DoWork()
{
var tenantInfo = this.multiTenantContextAccessor.MultiTenantContext?.TenantInfo;
var tenantId = tenantInfo?.Id; // Unique tenant ID
var displayName = tenantInfo?.DisplayName; // Human-readable name
var connectionString = tenantInfo?.ConnectionString; // If per-tenant DB
}
}
Per-Tenant Databases
For isolation, each tenant can have its own database:
{
"Finbuckle": {
"MultiTenant": {
"Stores": {
"ConfigurationStore": {
"Tenants": [
{
"id": "acme-corp-guid",
"displayName": "ACME Corporation",
"connectionString": "Server=server1;Database=acme_db;..."
},
{
"id": "globex-corp-guid",
"displayName": "Globex",
"connectionString": "Server=server2;Database=globex_db;..."
}
]
}
}
}
}
}
Tenant Isolation in Commands
Commands should validate tenant ownership:
public class UpdateNoteCommandHandler : AuditableCommandHandler<UpdateNoteCommand, Unit>
{
private readonly IRepositoryFactory<NoteAggregate> repositoryFactory;
private readonly IMultiTenantContextAccessor tenantAccessor;
protected override async Task<CommandResult<Unit>> HandleInternalWithRepository(
UpdateNoteCommand request,
CancellationToken cancellationToken)
{
var currentTenant = tenantAccessor.MultiTenantContext?.TenantInfo?.Id;
// Load note and verify tenant ownership
var note = await this.Repository.FindOne(
new ByIdSpecification<NoteAggregate>(request.NoteId),
cancellationToken);
if (note.HasNoValue || note.Value.TenantId != currentTenant)
{
return CommandResult<Unit>.Failed(
new BluQubeErrorData("FORBIDDEN", "Cannot access this resource"));
}
// Safe to update
note.Value.UpdateContent(request.Title, request.Content);
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return result.IsSuccess ? CommandResult<Unit>.Succeeded() : CommandResult<Unit>.Failed(result.Error!);
}
}
Multi-Tenant Considerations
Schema Isolation:
- One database, multiple schemas (one per tenant)
- Faster than per-tenant databases
- Easier migrations
- Good for SaaS
Row-Level Security:
- All data in shared tables
- Filter by TenantId column
- Simplest approach
- Highest query complexity
Per-Tenant Databases:
- Complete isolation
- Can have different schemas
- Scaling complexity
- Highest availability
Migration from Single to Multi-Tenant
PearDrop makes this easy because both use GUIDs for tenant IDs:
- ✅ Single-tenant:
00000000-0000-0000-0000-000000000001 - ✅ Multi-tenant: Each customer gets a unique GUID
- ✅ Add
TenantIdcolumn to aggregates - ✅ Migrate existing data to use tenant ID
- ✅ Update read models to filter by tenant
- ✅ Configure multi-tenant provider (URL, claims, etc.)
No application restart or redesign needed!
Next Steps
- Multi-Tenant Setup - Configuration guide
- Your First Feature - Implement with tenancy