Skip to main content

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:

  1. ✅ Single-tenant: 00000000-0000-0000-0000-000000000001
  2. ✅ Multi-tenant: Each customer gets a unique GUID
  3. ✅ Add TenantId column to aggregates
  4. ✅ Migrate existing data to use tenant ID
  5. ✅ Update read models to filter by tenant
  6. ✅ Configure multi-tenant provider (URL, claims, etc.)

No application restart or redesign needed!

Next Steps