Skip to content

Getting Started with Pragmatic.ControlPlane

Step-by-step guide to setting up distributed host coordination with the SignalR-based control plane.


  • A Pragmatic host application (PragmaticApp.RunAsync)
  • .NET 10+ SDK
  • Pragmatic.Abstractions (included transitively)

The control plane supports three deployment modes:

ModePackages NeededWhen to Use
MonolithNoneSingle instance, no coordination needed
Embedded HubControlPlane.SignalR + ControlPlane.ClientPrimary host acts as hub, others connect to it
Dedicated HubControlPlane.SignalR on hub, ControlPlane.Client on workersSeparate coordination service

In a single-instance deployment, the control plane is not needed. All interfaces resolve to NoOp implementations automatically:

await PragmaticApp.RunAsync(args, builder =>
{
// No UseControlPlane call needed.
// IHostIdentity → LocalHostIdentity (hostname + PID)
// IHostStatus → LocalHostStatus (always Serving)
// IControlPlane → NoOpControlPlane (self-only)
});

Every interface has a working default. You can code against IHostIdentity, IHostStatus, and IControlPlane in your services, and they will work in both monolith and distributed modes.


Terminal window
dotnet add package Pragmatic.ControlPlane.SignalR
Terminal window
dotnet add package Pragmatic.ControlPlane.Client

Embedded Hub (Hub + Client in Same Process)

Section titled “Embedded Hub (Hub + Client in Same Process)”
Terminal window
dotnet add package Pragmatic.ControlPlane.SignalR
dotnet add package Pragmatic.ControlPlane.Client

The hub is the coordination point. All clients connect to it via SignalR.

// In the hub host's Program.cs
await PragmaticApp.RunAsync(args, builder =>
{
builder.UseControlPlaneHub(hub =>
{
hub.WithApiKey("my-cluster-secret");
hub.WithStaleEvictionInterval(TimeSpan.FromMinutes(2));
});
});

This registers:

  • ControlPlaneHub — SignalR hub at /_pragmatic/control-plane
  • ControlPlaneHubRegistry — Thread-safe host registry
  • CommandAuditLog — Immutable command history
  • ControlPlaneAuthFilter — API key validation
  • StaleHostEvictionService — Removes hosts that miss heartbeats

The hub also exposes REST fallback endpoints:

EndpointDescription
GET /_pragmatic/control-plane/hostsList all connected hosts
GET /_pragmatic/control-plane/hosts/{hostId}Get a specific host
GET /_pragmatic/control-plane/auditGet command audit trail

Every application instance that should participate in coordination needs the client:

// In each worker host's Program.cs
await PragmaticApp.RunAsync(args, builder =>
{
builder.UseControlPlane(cp =>
{
cp.WithHubUrl("https://control-plane:5100/_pragmatic/control-plane");
cp.WithApiKey("my-cluster-secret");
cp.WithHeartbeatInterval(TimeSpan.FromSeconds(30));
});
});
{
"Pragmatic": {
"ControlPlane": {
"HubUrl": "https://control-plane:5100/_pragmatic/control-plane",
"ApiKey": "my-cluster-secret",
"HeartbeatIntervalSeconds": 30
}
}
}

The ControlPlaneClientOptions values:

SettingDefaultDescription
HubUrlnullSignalR hub URL. Null = monolith mode (NoOp)
HeartbeatInterval15 secondsInterval between heartbeat reports
MaxReconnectAttempts10Max reconnect attempts (exponential backoff)
ApiKeynullShared secret sent via X-Control-Plane-Key header

Step 6: Embedded Hub (Hub + Client in Same Host)

Section titled “Step 6: Embedded Hub (Hub + Client in Same Host)”

The most common setup for small deployments: the primary host runs both the hub and a client.

await PragmaticApp.RunAsync(args, builder =>
{
// This host IS the hub
builder.UseControlPlaneHub(hub =>
{
hub.WithApiKey("my-cluster-secret");
});
// AND connects to itself as a client
builder.UseControlPlane(cp =>
{
cp.WithHubUrl("https://localhost:5100/_pragmatic/control-plane");
cp.WithApiKey("my-cluster-secret");
});
// Migrations use the hub for leader election
builder.UsePragmaticMigrations();
});

The ControlPlaneConnectionService waits for ApplicationStarted before connecting, so the SignalR hub is ready when the client tries to connect. No race condition.


Register health contributors to report module-specific health:

public sealed class DatabaseHealthContributor(IDbContextFactory<AppDbContext> factory)
: IHostHealthContributor
{
public string Name => "Database";
public async Task<HealthContribution> CheckAsync(CancellationToken ct)
{
try
{
await using var db = await factory.CreateDbContextAsync(ct);
await db.Database.CanConnectAsync(ct);
return new HealthContribution(Name, ContributorHealthStatus.Healthy);
}
catch (Exception ex)
{
return new HealthContribution(Name, ContributorHealthStatus.Unhealthy,
ex.Message);
}
}
}

Register in DI:

services.AddSingleton<IHostHealthContributor, DatabaseHealthContributor>();

Health is included in heartbeat payloads automatically and aggregated by the hub.


The hub can push commands to specific hosts or broadcast to all. Built-in commands:

  • MaintenanceCommand — Toggle maintenance mode
  • DrainCommand — Prepare for graceful shutdown

To handle custom commands:

public sealed record ScaleCacheCommand(int NewSizeMb) : HostCommand;
public sealed class ScaleCacheHandler : IHostCommandHandler<ScaleCacheCommand>
{
public Task HandleAsync(ScaleCacheCommand command, CancellationToken ct)
{
// Resize cache pool
return Task.CompletedTask;
}
}

Register it:

services.AddSingleton<IHostCommandHandler<ScaleCacheCommand>, ScaleCacheHandler>();

Commands are dispatched via IHostCommandDispatcher (registered by Pragmatic.Composition.Host) which resolves the correct handler by command type.


Stream real-time events from the control plane:

public sealed class ControlPlaneEventLogger(
IControlPlane controlPlane,
ILogger<ControlPlaneEventLogger> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var evt in controlPlane.StreamEventsAsync(ct))
{
switch (evt)
{
case HostStateChangedEvent e:
logger.LogInformation("Host {HostId}: {OldState} → {NewState}",
e.HostId, e.OldState, e.NewState);
break;
case MigrationProgressChangedEvent e:
logger.LogInformation("Migration on {HostId}: {Progress}%",
e.HostId, e.Status.ProgressPercent);
break;
case ConfigChangedEvent e:
logger.LogInformation("Config changed: {Key}", e.Key);
break;
case DeploymentAnnouncedEvent e:
logger.LogInformation("Deploy {Version} announced", e.Version);
break;
}
}
}
}

Event types:

EventWhen
HostStateChangedEventAny host transitions state (Starting, Ready, Serving, Draining, Maintenance, Stopped)
MigrationProgressChangedEventA host reports migration progress
ConfigChangedEventConfiguration push via hub
DeploymentAnnouncedEventDeployment announced to all hosts
DeploymentCompletedEventDeployment completed

Terminal window
curl https://control-plane:5100/_pragmatic/control-plane/hosts

Expected response:

[
{
"hostId": "a1b2c3d4e5f6g7h8",
"hostName": "Showcase.Host",
"hostType": "Monolith",
"state": "Serving",
"lastHeartbeat": "2026-03-31T10:15:30Z"
}
]
Terminal window
curl https://control-plane:5100/_pragmatic/control-plane/audit

From the Showcase project — a distributed setup with two hosts:

Showcase.Host (primary, embedded hub):

await PragmaticApp.RunAsync(args, builder =>
{
builder.UseControlPlaneHub(hub =>
{
hub.WithApiKey(builder.Configuration["Pragmatic:ControlPlane:ApiKey"]!);
});
builder.UseControlPlane(cp =>
{
cp.WithHubUrl("https://localhost:5100/_pragmatic/control-plane");
cp.WithApiKey(builder.Configuration["Pragmatic:ControlPlane:ApiKey"]!);
});
builder.UsePragmaticMigrations();
});

Showcase.Billing.Host (standalone, client only):

await PragmaticApp.RunAsync(args, builder =>
{
builder.UseControlPlane(cp =>
{
cp.WithHubUrl("https://showcase-host:5100/_pragmatic/control-plane");
cp.WithApiKey(builder.Configuration["Pragmatic:ControlPlane:ApiKey"]!);
});
builder.UsePragmaticMigrations();
});