Skip to main content

Tenant Management

Perform CRUD operations and manage tenant lifecycle using PearDrop commands.

Creating Tenants

Basic Tenant Creation

var command = new CreateTenantCommand(
identifier: "acmecorp", // URL-friendly, unique
reference: "CUST-12345", // Your internal customer ID
name: "ACME Corporation" // Display name
);

var result = await commandRunner.ExecuteAsync(command, cancellationToken);

if (result.IsSuccess)
{
var tenantId = result.Data.TenantId;
Console.WriteLine($"Created tenant: {tenantId}");
}
else
{
Console.WriteLine($"Error: {result.Error.Message}");
}

Command result:

public sealed record CreateTenantCommandResult(Guid TenantId);

Identifier Requirements

Valid identifiers:

  • acmecorp
  • client-123
  • tenant_one
  • abc123

Invalid identifiers:

  • ACME Corp ❌ (spaces)
  • acme@corp ❌ (special chars except - and _)
  • 123 ❌ (too short, varies by validation rules)
  • Capital letters may be auto-lowercased

Best practices:

  • Lowercase alphanumeric with dashes/underscores
  • 3-50 characters
  • Unique across all tenants
  • Match your subdomain naming conventions

Provisioning Workflow

public async Task<Guid> ProvisionNewTenant(
string companyName,
string customerRef,
TenantIsolationLevel isolationLevel,
CancellationToken cancellationToken)
{
// 1. Generate identifier from company name
var identifier = GenerateIdentifier(companyName); // "ACME Corp" → "acmecorp"

// 2. Create tenant
var createCommand = new CreateTenantCommand(identifier, customerRef, companyName);
var createResult = await commandRunner.ExecuteAsync(createCommand, cancellationToken);

if (createResult.IsFailure)
throw new Exception($"Failed to create tenant: {createResult.Error.Message}");

var tenantId = createResult.Data.TenantId;

// 3. Configure isolation level
if (isolationLevel != TenantIsolationLevel.Shared)
{
var configCommand = new SetTenantIsolationCommand(
tenantId,
isolationLevel,
schemaName: isolationLevel == TenantIsolationLevel.Schema ? identifier : null,
connectionString: isolationLevel == TenantIsolationLevel.Database
? GenerateConnectionString(identifier)
: null
);

await commandRunner.ExecuteAsync(configCommand, cancellationToken);
}

// 4. Set system features
var featuresCommand = new SetSystemFeaturesCommand(
tenantId,
new List<string> { "Tasks", "Reports", "Export" }
);

await commandRunner.ExecuteAsync(featuresCommand, cancellationToken);

// 5. Configure MFA settings
var mfaCommand = new SetTenantMfaSettingsCommand(
tenantId,
requireMfa: false,
allowDeviceRemembrance: true,
deviceRemembranceDays: 30
);

await commandRunner.ExecuteAsync(mfaCommand, cancellationToken);

return tenantId;
}

private string GenerateIdentifier(string companyName)
{
return companyName
.ToLowerInvariant()
.Replace(" ", "")
.Replace(".", "")
.Replace(",", "");
}

private string GenerateConnectionString(string identifier)
{
var config = configuration.GetConnectionString("TenantTemplate");
return config.Replace("{TenantId}", identifier);
}

Updating Tenants

Update Basic Details

var command = new UpdateTenantDetailsCommand(
tenantId: currentTenant.Id,
name: "ACME Corporation Ltd", // Updated name
reference: "CUST-12345-UK" // Updated reference
);

await commandRunner.ExecuteAsync(command, cancellationToken);

What can be updated:

  • ✅ Name (display name)
  • ✅ Reference (your internal ID)
  • ❌ Identifier (cannot change after creation, DNS/URL impact)
  • ❌ Id (GUID, immutable)

Update Tenant Metadata

var metaItems = new Dictionary<string, string>
{
{ "BillingTier", "Enterprise" },
{ "MaxUsers", "500" },
{ "Region", "EU-West" },
{ "ContactEmail", "admin@acmecorp.com" }
};

var command = new UpdateTenantMetaItemsCommand(
tenantId: currentTenant.Id,
metaItems: metaItems
);

await commandRunner.ExecuteAsync(command, cancellationToken);

Access metadata:

var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);

var billingTier = tenant.Value.GetMetaItem("BillingTier");
var maxUsers = int.Parse(tenant.Value.GetMetaItem("MaxUsers") ?? "100");

Remove Metadata Items

var command = new RemoveTenantMetaItemsCommand(
tenantId: currentTenant.Id,
metaKeys: new List<string> { "BillingTier", "MaxUsers" }
);

await commandRunner.ExecuteAsync(command, cancellationToken);

Enabling and Disabling Tenants

Disable Tenant

var command = new DisableTenantCommand(tenantId: currentTenant.Id);

await commandRunner.ExecuteAsync(command, cancellationToken);

Effects:

  • Users cannot log in
  • Existing sessions may be terminated (depends on implementation)
  • Data remains in database
  • Admin can still access tenant for management
  • Tenant appears as "Disabled" in admin UI

Use cases:

  • Subscription expired
  • Payment failed
  • Contract ended
  • Security incident
  • Tenant requested service pause

Enable Tenant

var command = new EnableTenantCommand(tenantId: currentTenant.Id);

await commandRunner.ExecuteAsync(command, cancellationToken);

Effects:

  • Users can log in again
  • Normal operations resume
  • All data and settings preserved

Check Tenant Status

var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);

if (tenant.Value.IsEnabled)
{
Console.WriteLine("Tenant is active");
}
else
{
Console.WriteLine("Tenant is disabled");
}

Programmatic Enforcement

public class TenantStatusMiddleware
{
private readonly RequestDelegate next;

public async Task InvokeAsync(
HttpContext context,
IMultiTenantContextAccessor accessor,
IRepository<Tenant> tenantRepo)
{
var tenantInfo = accessor.MultiTenantContext?.TenantInfo;

if (tenantInfo != null && Guid.TryParse(tenantInfo.Id, out var tenantId))
{
var tenant = await tenantRepo.FindOne(
new ByIdSpecification<Tenant>(tenantId),
context.RequestAborted);

if (tenant.HasValue && !tenant.Value.IsEnabled)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new
{
error = "Tenant is currently disabled. Please contact support."
});
return;
}
}

await next(context);
}
}

// Register after UseMultiTenant()
app.UseMultiTenant();
app.UseMiddleware<TenantStatusMiddleware>();

Feature Management

Set System Features

var command = new SetSystemFeaturesCommand(
tenantId: currentTenant.Id,
systemFeatures: new List<string>
{
"Tasks",
"Calendar",
"Reports",
"Export",
"API"
}
);

await commandRunner.ExecuteAsync(command, cancellationToken);

Replaces entire feature list. To add/remove individual features, fetch current list first:

var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);

var currentFeatures = tenant.Value.SystemFeatures.ToList();

// Add new feature
if (!currentFeatures.Contains("AdvancedReports"))
{
currentFeatures.Add("AdvancedReports");

var command = new SetSystemFeaturesCommand(tenantId, currentFeatures);
await commandRunner.ExecuteAsync(command, cancellationToken);
}

Check Feature Availability

public async Task<bool> CanAccessFeature(Guid tenantId, string featureName)
{
var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);

if (tenant.HasNoValue)
return false;

return tenant.Value.SystemFeatures.Contains(featureName);
}

// Usage in controller
[HttpGet("advanced-reports")]
public async Task<IActionResult> GetAdvancedReports()
{
var tenantId = GetCurrentTenantId();

if (!await CanAccessFeature(tenantId, "AdvancedReports"))
{
return Forbid("Feature not available for your subscription");
}

// ... return reports
}

Feature Tiers

public static class FeatureTiers
{
public static readonly List<string> Basic = new() { "Tasks" };

public static readonly List<string> Standard = new()
{
"Tasks",
"Calendar",
"Reports"
};

public static readonly List<string> Enterprise = new()
{
"Tasks",
"Calendar",
"Reports",
"Export",
"API",
"AdvancedReports",
"CustomIntegrations"
};
}

// Set tier
var command = new SetSystemFeaturesCommand(
tenantId,
FeatureTiers.Enterprise
);
await commandRunner.ExecuteAsync(command, cancellationToken);

MFA and Device Remembrance Settings

Configure MFA per Tenant

var command = new SetTenantDeviceRemembranceSettingsCommand(
tenantId: currentTenant.Id,
useMfa: true, // Require MFA for this tenant
useDeviceRemembrance: true, // Allow "Remember this device"
deviceRemembranceDays: 30 // Trust device for 30 days
);

await commandRunner.ExecuteAsync(command, cancellationToken);

Configuration options:

SettingTypeDescription
useMfaboolRequire MFA for all users in tenant
useDeviceRemembranceboolAllow users to skip MFA on trusted devices
deviceRemembranceDaysintDays to trust a device (0 = forever, until revoked)

Examples:

High security (financial services):

new SetTenantDeviceRemembranceSettingsCommand(
tenantId,
useMfa: true,
useDeviceRemembrance: false, // Always require MFA
deviceRemembranceDays: 0
);

Balanced (SaaS apps):

new SetTenantDeviceRemembranceSettingsCommand(
tenantId,
useMfa: true,
useDeviceRemembrance: true,
deviceRemembranceDays: 30
);

Low security (internal tools):

new SetTenantDeviceRemembranceSettingsCommand(
tenantId,
useMfa: false,
useDeviceRemembrance: false,
deviceRemembranceDays: 0
);

Read Current Settings

var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);

var useMfa = tenant.Value.GetMetaItem("UseMfa") == "True";
var useDeviceRemembrance = tenant.Value.GetMetaItem("UseDeviceRemembrance") == "True";
var daysStr = tenant.Value.GetMetaItem("DeviceRemembranceDays") ?? "0";
var days = int.Parse(daysStr);

Console.WriteLine($"MFA: {useMfa}, Device Remembrance: {useDeviceRemembrance} ({days} days)");

Querying Tenants

Read Models

public interface IMultitenancyReadModels : IModuleReadModels
{
IReadModelQueryable<TenantProjection> Tenants { get; }
}

// Inject in query handler
public class GetTenantsQueryHandler
{
private readonly IMultitenancyReadModels readModels;

public async Task<QueryResult<TenantListResult>> Handle(
GetTenantsQuery request,
CancellationToken cancellationToken)
{
var tenants = await readModels.Tenants
.Where(t => t.IsEnabled)
.OrderBy(t => t.Name)
.Select(t => new TenantDto
{
Id = t.Id,
Identifier = t.Identifier,
Name = t.Name,
Reference = t.Reference,
IsEnabled = t.IsEnabled,
CreatedAt = t.CreatedAt
})
.ToListAsync(cancellationToken);

return QueryResult<TenantListResult>.Succeeded(
new TenantListResult(tenants));
}
}

Common Queries

All active tenants:

var tenants = await readModels.Tenants
.Where(t => t.IsEnabled)
.ToListAsync(cancellationToken);

Search by name:

var tenants = await readModels.Tenants
.Where(t => t.Name.Contains(searchTerm))
.ToListAsync(cancellationToken);

Find by identifier:

var tenant = await readModels.Tenants
.FirstOrDefaultAsync(
t => t.Identifier == identifier,
cancellationToken);

Count tenants:

var totalTenants = await readModels.Tenants.CountAsync(cancellationToken);
var activeTenants = await readModels.Tenants
.CountAsync(t => t.IsEnabled, cancellationToken);

Pagination:

var pageSize = 20;
var page = 2;

var tenants = await readModels.Tenants
.OrderBy(t => t.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);

Bulk Operations

Enable/Disable Multiple Tenants

public async Task BulkDisableTenants(
List<Guid> tenantIds,
CancellationToken cancellationToken)
{
foreach (var tenantId in tenantIds)
{
var command = new DisableTenantCommand(tenantId);
await commandRunner.ExecuteAsync(command, cancellationToken);
}
}

Update Features Across Tenants

public async Task ApplyFeatureTierToTenants(
List<Guid> tenantIds,
List<string> features,
CancellationToken cancellationToken)
{
foreach (var tenantId in tenantIds)
{
var command = new SetSystemFeaturesCommand(tenantId, features);
await commandRunner.ExecuteAsync(command, cancellationToken);
}
}

// Usage
var enterpriseTenants = await readModels.Tenants
.Where(t => t.BillingTier == "Enterprise")
.Select(t => t.Id)
.ToListAsync(cancellationToken);

await ApplyFeatureTierToTenants(
enterpriseTenants,
FeatureTiers.Enterprise,
cancellationToken);

Deleting Tenants

PearDrop does not provide a delete tenant command by default. Deleting tenants is a destructive operation with significant implications.

// Mark as disabled instead of deleting
var command = new DisableTenantCommand(tenantId);
await commandRunner.ExecuteAsync(command, cancellationToken);

// Add metadata to track deletion request
var metaCommand = new UpdateTenantMetaItemsCommand(
tenantId,
new Dictionary<string, string>
{
{ "MarkedForDeletion", DateTime.UtcNow.ToString("O") },
{ "DeletionRequestedBy", currentUserId.ToString() }
}
);
await commandRunner.ExecuteAsync(metaCommand, cancellationToken);

Hard Delete Implementation (If Required)

public async Task<Result> HardDeleteTenant(
Guid tenantId,
CancellationToken cancellationToken)
{
// 1. Delete all tenant data (order matters for foreign keys)
await DeleteTenantUsers(tenantId, cancellationToken);
await DeleteTenantTasks(tenantId, cancellationToken);
await DeleteTenantFiles(tenantId, cancellationToken);
// ... delete all tenant-scoped data

// 2. Remove tenant record
var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);

if (tenant.HasNoValue)
return Result.Failed("Tenant not found");

repository.Remove(tenant.Value);

var result = await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);

// 3. If database-per-tenant, drop database
if (tenant.Value.IsolationLevel == TenantIsolationLevel.Database)
{
await DropTenantDatabase(tenant.Value.ConnectionString, cancellationToken);
}

return result;
}

Considerations before deleting:

  • Data retention policies (GDPR, compliance)
  • Backup/archive tenant data first
  • Notify users
  • Cascade delete all tenant-owned data
  • Handle cross-tenant references
  • Drop databases/schemas if isolated
  • Update billing systems

Next Steps