Skip to content
Kumar Chandrachooda
.NET

A Control Plane Needs Its Own Domain Model

Tenants are data too. The tenant-management database is the one single-tenant database in the whole platform, and its five entities — Tenant, TenantConnection, TenantFeature, DatabaseGroup, TenantMigrationHistory — carry every decision the rest of the series depends on.

By Kumar Chandrachooda 26 Oct 2025 5 min read
The switchboard every tenant is wired into

Here is the quiet irony at the centre of this platform: the system that makes every other database exotic keeps its own database completely boring. The tenant-management store is plain EF Core — one database, one public schema, conventionally scaffolded migrations, dotnet ef database update in the README. No schema parameterisation, no replaced internals, none of the machinery from parts 3 and 4.

That is not laziness; it is policy. The control plane is the thing you debug when tenants are broken. It must not depend on any of the clever code it exists to orchestrate. If the schema-aware migration stack ever misbehaves, I want the record of who-lives-where readable with the dumbest tools in the box.

This part walks the five entities in that boring database, because between them they encode every operational decision the platform makes.

Tenant: a status machine wearing an entity's clothes

The Tenant row carries the identity basics — a unique slug, display name, contact details, timezone, locale, a jsonb-bound settings blob — plus the two fields that drive everything: TenantType (Shared or Isolated) and Status. Status transitions are methods with guards, not a settable property:

public void Activate()
{
    if (Status != TenantStatus.Provisioning)
        throw new InvalidOperationException($"Cannot activate tenant in {Status} status");

    Status = TenantStatus.Active;
    ActivatedAt = DateTime.UtcNow;
}

public void Suspend(string reason)
{
    if (Status != TenantStatus.Active)
        throw new InvalidOperationException($"Cannot suspend tenant in {Status} status");

    Status = TenantStatus.Suspended;
    SuspendedAt = DateTime.UtcNow;
    StatusReason = reason;
}

public void MarkProvisioningFailed(string reason)
{
    if (Status != TenantStatus.Provisioning)
        throw new InvalidOperationException($"Cannot mark tenant as failed in {Status} status");

    Status = TenantStatus.Failed;
    StatusReason = reason;
}

The lifecycle is Provisioning → Active → Suspended/Deactivated, with Failed as the exit ramp from Provisioning. Two details earn their keep operationally. First, every transition stamps its own timestamp (ActivatedAt, SuspendedAt, DeactivatedAt) — so “how long was this tenant suspended before anyone noticed” is a query, not an archaeology dig. Second, StatusReason is required by the method signature for every negative transition. Six months later, “suspended” is useless; “suspended: payment failed 3x” is an answer.

Is a handful of guarded methods a real state machine? No — nothing stops two handlers loading the same tenant and racing. The RowVersion concurrency token on the base entity turns that race into a visible DbUpdateConcurrencyException rather than a silent lost update, which is the cheap 90% of the fix.

TenantConnection: provisioning's receipt

Everything part 5 creates gets recorded in one place:

public class TenantConnection : BaseEntity
{
    public Guid TenantId { get; set; }
    public string DatabaseName { get; set; } = string.Empty;
    public string? SchemaName { get; set; }          // null-ish for isolated tenants
    public string ConnectionString { get; set; } = string.Empty;
    public TenantType IsolationType { get; set; }
    public bool IsPrimary { get; set; } = true;

    public int PoolSize { get; set; } = 10;
    public int ConnectionTimeout { get; set; } = 30;
    public int CommandTimeout { get; set; } = 30;

    public DateTime? LastConnectionTest { get; set; }
    public bool? LastConnectionTestSuccessful { get; set; }
    public string? LastConnectionTestError { get; set; }
}

Three things to notice. The isolation type is stored per connection, not per tenant — which is what lets a tenant migrate between strategies someday without rewriting history; the old connection stays as a record while a new one becomes IsPrimary. The pool and timeout knobs live in data, so a heavy tenant can get a bigger pool without a deploy. And the last-connection-test triplet exists because “is this tenant's database reachable” is a question support gets asked at 2 a.m., and a nullable bool with an error string is the cheapest possible pre-answer.

One honest confession: the entity's XML comment says the connection string is encrypted. It is not — it is plaintext with an aspiration attached. The retrospective in part 10 does not let me off the hook for that.

DatabaseGroup: capacity as an aggregate

The grouped-shared strategy from part 1 lives here, and the invariants are enforced in the entity, not in the handler:

public bool HasCapacity() => IsActive && CurrentTenantCount < MaxTenants;

public void IncrementTenantCount()
{
    if (CurrentTenantCount >= MaxTenants)
        throw new InvalidOperationException("Database group is at maximum capacity");
    CurrentTenantCount++;
}

public void DecrementTenantCount()
{
    if (CurrentTenantCount <= 0) return;
    CurrentTenantCount--;
}

Note the asymmetry: incrementing past the limit throws, decrementing past zero silently floors. That is deliberate. Over-allocating is a real harm (a database with 51 noisy tenants); under-counting is bookkeeping drift that a reconciliation query can fix. Fail hard on the harmful direction, degrade gracefully on the annoying one.

The group also carries Region, Environment, ServerHost/ServerPort and its own health-check triplet — the fields you need the day “add another group database in EU” stops being hypothetical.

TenantFeature and TenantMigrationHistory: the two ledgers

TenantFeature is per-tenant feature flags with a JSON configuration payload, a (TenantId, FeatureName) unique index, and enable/disable methods that stamp who and when — plus one guard I have been grateful for: IsRequired features refuse to be disabled.

TenantMigrationHistory is more interesting, because it looks redundant. Every tenant schema already has its own __EFMigrationsHistory (part 3). Why a second record?

Because EF's history table answers “what has been applied here” and nothing else. The control-plane ledger answers fleet questions: which tenants are still on migration N, how long did the rollout take per tenant, which three tenants failed and with what error. It stores the migration name and version, the target database and schema, timing, success flag and error message — per tenant. When part 8 rolls a migration across fifty schemas, this table is the difference between a rollout and a rumour.

The boring context, doing quiet work

TenantManagementDbContext is 250 lines of conventional configuration, and its most consequential choices are relational plumbing:

  • Unique indexes on the natural keysTenant.TenantIdentifier, DatabaseGroup.GroupName, DatabaseGroup.DatabaseName. The database is the last line of defence against two tenants claiming one slug in a race the application-level check misses.
  • Cascade for owned things, SetNull for shared things. Deleting a tenant cascades to its connections and features — they are meaningless without it. But Tenant.DatabaseGroupId is SetNull on group deletion: a group is shared infrastructure, and its removal must never take tenants' rows with it.
  • Soft delete wired at the context level — global HasQueryFilter(e => !e.IsDeleted) on every entity, and a SaveChangesAsync override that converts Deleted entries into Modified + IsDeleted = true. No repository can forget to filter, and no handler can hard-delete by accident. That machinery deserves its own part, and gets it next.

The one wart worth naming: connection strings for group databases are duplicated onto every TenantConnection in the group. Convenient for reads — the data plane fetches one row and goes — but a credential rotation now updates N rows plus the group instead of one. A pointer with a join would be more normalised; I chose the denormalised read path and accepted the rotation chore. I am still not sure I was right.

Why this is the part I would not skip

It is tempting to treat the control plane as CRUD scaffolding around the “real” engineering in the migration internals. My experience was the opposite: every hard operational question — can we suspend instantly, can we restore a purged tenant, which tenants are behind on migrations, can this group take one more — was answered or doomed by decisions in these five entities, made long before the question was asked.

Next up is the lifecycle those entities exist to protect: suspension, soft delete, restore — and the purge path, where the platform finally runs DROP DATABASE on purpose.