Getting Started with Pragmatic.ControlPlane
Step-by-step guide to setting up distributed host coordination with the SignalR-based control plane.
Prerequisites
Section titled “Prerequisites”- A Pragmatic host application (
PragmaticApp.RunAsync) - .NET 10+ SDK
Pragmatic.Abstractions(included transitively)
Step 1: Understand the Deployment Modes
Section titled “Step 1: Understand the Deployment Modes”The control plane supports three deployment modes:
| Mode | Packages Needed | When to Use |
|---|---|---|
| Monolith | None | Single instance, no coordination needed |
| Embedded Hub | ControlPlane.SignalR + ControlPlane.Client | Primary host acts as hub, others connect to it |
| Dedicated Hub | ControlPlane.SignalR on hub, ControlPlane.Client on workers | Separate coordination service |
Step 2: Monolith Mode (Zero Config)
Section titled “Step 2: Monolith Mode (Zero Config)”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.
Step 3: Add Packages for Distributed Mode
Section titled “Step 3: Add Packages for Distributed Mode”Hub Host
Section titled “Hub Host”dotnet add package Pragmatic.ControlPlane.SignalRClient Hosts (Every Worker Instance)
Section titled “Client Hosts (Every Worker Instance)”dotnet add package Pragmatic.ControlPlane.ClientEmbedded Hub (Hub + Client in Same Process)
Section titled “Embedded Hub (Hub + Client in Same Process)”dotnet add package Pragmatic.ControlPlane.SignalRdotnet add package Pragmatic.ControlPlane.ClientStep 4: Configure the Hub
Section titled “Step 4: Configure the Hub”The hub is the coordination point. All clients connect to it via SignalR.
// In the hub host's Program.csawait 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-planeControlPlaneHubRegistry— Thread-safe host registryCommandAuditLog— Immutable command historyControlPlaneAuthFilter— API key validationStaleHostEvictionService— Removes hosts that miss heartbeats
REST Endpoints
Section titled “REST Endpoints”The hub also exposes REST fallback endpoints:
| Endpoint | Description |
|---|---|
GET /_pragmatic/control-plane/hosts | List all connected hosts |
GET /_pragmatic/control-plane/hosts/{hostId} | Get a specific host |
GET /_pragmatic/control-plane/audit | Get command audit trail |
Step 5: Configure Client Hosts
Section titled “Step 5: Configure Client Hosts”Every application instance that should participate in coordination needs the client:
// In each worker host's Program.csawait 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)); });});Configuration via appsettings.json
Section titled “Configuration via appsettings.json”{ "Pragmatic": { "ControlPlane": { "HubUrl": "https://control-plane:5100/_pragmatic/control-plane", "ApiKey": "my-cluster-secret", "HeartbeatIntervalSeconds": 30 } }}The ControlPlaneClientOptions values:
| Setting | Default | Description |
|---|---|---|
HubUrl | null | SignalR hub URL. Null = monolith mode (NoOp) |
HeartbeatInterval | 15 seconds | Interval between heartbeat reports |
MaxReconnectAttempts | 10 | Max reconnect attempts (exponential backoff) |
ApiKey | null | Shared 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.
Step 7: Health Contributors
Section titled “Step 7: Health Contributors”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.
Step 8: Handle Commands
Section titled “Step 8: Handle Commands”The hub can push commands to specific hosts or broadcast to all. Built-in commands:
MaintenanceCommand— Toggle maintenance modeDrainCommand— 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.
Step 9: Listen for Events
Section titled “Step 9: Listen for Events”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:
| Event | When |
|---|---|
HostStateChangedEvent | Any host transitions state (Starting, Ready, Serving, Draining, Maintenance, Stopped) |
MigrationProgressChangedEvent | A host reports migration progress |
ConfigChangedEvent | Configuration push via hub |
DeploymentAnnouncedEvent | Deployment announced to all hosts |
DeploymentCompletedEvent | Deployment completed |
Step 10: Verify the Setup
Section titled “Step 10: Verify the Setup”Check Connected Hosts
Section titled “Check Connected Hosts”curl https://control-plane:5100/_pragmatic/control-plane/hostsExpected response:
[ { "hostId": "a1b2c3d4e5f6g7h8", "hostName": "Showcase.Host", "hostType": "Monolith", "state": "Serving", "lastHeartbeat": "2026-03-31T10:15:30Z" }]Check Audit Trail
Section titled “Check Audit Trail”curl https://control-plane:5100/_pragmatic/control-plane/auditComplete Showcase Example
Section titled “Complete Showcase Example”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();});Next Steps
Section titled “Next Steps”- Concepts — Architecture: hub/client, health, commands, leader election
- Common Mistakes — 8 pitfalls and how to avoid them
- Troubleshooting — Connection issues, stale hosts, recovery