Skip to content

Pragmatic.Discovery

Runtime topology registration and discovery for Pragmatic.Design hosts.

A modular monolith splits business logic into isolated modules, each with its own database assignment. In a single host, the Composition source generator validates the full topology at compile time. But when you split modules across multiple hosts, compile-time validation disappears. You cannot know at build time whether two hosts accidentally deploy the same module, whether ReadAccess declarations span hosts (making SQL joins impossible), or whether database providers are mismatched.

Teams solve this with external service registries like Consul or etcd. But those systems know “Host B is alive at port 5002” — not “Host B owns BillingModule on BillingDb with PostgreSQL.” You end up maintaining a separate configuration that drifts from the code.

// Without Pragmatic.Discovery: manual, drift-prone configuration
var billingHost = configuration["Services:Billing:BaseUrl"]; // hope it's correct
var catalogHost = configuration["Services:Catalog:BaseUrl"]; // hope it matches
// No validation that BillingModule actually runs there
// No detection of provider mismatches
// No warning when ReadAccess spans hosts

Pragmatic.Discovery reads the SG-emitted topology metadata that already exists in every compiled host assembly, stores it in a shared backend, and validates the deployment against other hosts — automatically, at startup.

Host A (Booking + Catalog) --> registers --> discovery backend
Host B (Billing) --> registers --> discovery backend
Host A <-- queries <-- "which host owns Billing?"
--> calls --> Host B via HTTP

No manual configuration. No external registry that drifts. The topology metadata travels with the compiled assembly.


FeatureDescription
IDiscoveryServiceHigh-level API: register, query, validate
IDiscoveryBackendPluggable storage: InMemory (default), Redis, Consul
Auto-registrationDiscoveryHostedService reads SG metadata at startup
Cross-host validationDetects module conflicts, provider mismatches, ReadAccess issues
SG integrationReads [assembly: PragmaticMetadata(HostTopology, ...)] emitted by Composition SG
Terminal window
dotnet add package Pragmatic.Discovery

// Minimal -- InMemory backend, auto-registers, validates on startup
services.AddDiscovery();
// With configuration
services.AddDiscovery(opts =>
{
opts.ThrowOnValidationFailure = true; // Block startup on errors
opts.ValidateOnStartup = true;
});

The DiscoveryHostedService runs automatically at startup. It reads the [assembly: PragmaticMetadata(HostTopology, ...)] attribute from the entry assembly (emitted by the Composition source generator) and registers the topology with the backend.

In a modular monolith, discovery validates the topology against itself:

await PragmaticApp.RunAsync(args, app =>
{
// All modules in one host
app.UseDiscovery(opts =>
{
opts.ValidateOnStartup = true;
opts.ThrowOnValidationFailure = true;
});
});

Even in a single host, discovery catches misconfiguration like duplicate module declarations or provider mismatches.

When modules are split across hosts, use a shared backend:

// Host A: Booking + Catalog
services.AddDiscovery(opts => opts.ThrowOnValidationFailure = true);
services.UseDiscoveryBackend<RedisDiscoveryBackend>();
// Host B: Billing
services.AddDiscovery(opts => opts.ThrowOnValidationFailure = true);
services.UseDiscoveryBackend<RedisDiscoveryBackend>();

Both hosts register their topology to the same Redis instance. Each validates against the other’s topology on startup.


The high-level service for topology operations:

public interface IDiscoveryService
{
Task RegisterAsync(HostTopologyInfo topology, CancellationToken ct = default);
Task<IReadOnlyList<HostTopologyInfo>> GetAllHostsAsync(CancellationToken ct = default);
Task<IReadOnlyList<HostTopologyInfo>> FindHostsForModuleAsync(string moduleName, CancellationToken ct = default);
Task<DiscoveryValidationResult> ValidateAsync(HostTopologyInfo localTopology, CancellationToken ct = default);
}
MethodDescription
RegisterAsyncStores this host’s topology in the backend
GetAllHostsAsyncReturns all registered host topologies
FindHostsForModuleAsyncFinds which hosts own a specific module
ValidateAsyncChecks for cross-host conflicts and coherence issues

The storage abstraction. Implement this to plug in a different backend:

public interface IDiscoveryBackend
{
Task StoreAsync(HostTopologyInfo topology, CancellationToken ct = default);
Task<HostTopologyInfo?> GetByHostNameAsync(string hostName, CancellationToken ct = default);
Task<IReadOnlyList<HostTopologyInfo>> GetAllAsync(CancellationToken ct = default);
}

The default implementation is InMemoryDiscoveryBackend — a thread-safe ConcurrentDictionary suitable for monolith and single-process deployments.


Represents the deployment topology of a single host:

public sealed record HostTopologyInfo
{
public required string HostName { get; init; }
public IReadOnlyList<ModuleDeploymentInfo> Modules { get; init; }
public IReadOnlyList<BoundaryReadAccessInfo> Boundaries { get; init; }
public DateTimeOffset RegisteredAt { get; init; }
}

It can be parsed from the SG-emitted metadata attribute:

// Read from the running host's entry assembly
var topology = HostTopologyInfo.FromEntryAssembly();
// Read from a specific assembly
var topology = HostTopologyInfo.FromAssembly(assembly);
// Parse from raw JSON
var topology = HostTopologyInfo.Parse(json);

Describes a module’s deployment, including its database assignment:

public sealed record ModuleDeploymentInfo
{
public required string ModuleName { get; init; }
public string? DatabaseName { get; init; }
public string? Provider { get; init; }
}

Describes cross-boundary read access declarations:

public sealed record BoundaryReadAccessInfo
{
public required string BoundaryName { get; init; }
public IReadOnlyList<string> EntityTypes { get; init; }
}
public sealed record DiscoveryValidationResult
{
public bool IsValid { get; init; }
public IReadOnlyList<DiscoveryValidationIssue> Issues { get; init; }
// Convenience properties
public IEnumerable<DiscoveryValidationIssue> Errors => Issues.Where(i => i.Severity == IssueSeverity.Error);
public IEnumerable<DiscoveryValidationIssue> Warnings => Issues.Where(i => i.Severity == IssueSeverity.Warning);
}
public sealed record DiscoveryValidationIssue
{
public required string Code { get; init; } // "DISC001", "DISC002", "DISC003"
public IssueSeverity Severity { get; init; } // Info, Warning, Error
public required string Message { get; init; }
}

The DiscoveryValidator checks for cross-host topology issues when a new host registers.

CodeSeverityRuleWhen It Fires
DISC001InfoSame module deployed in multiple hosts on different databasesAllowed (scale-out), but flagged for awareness
DISC002WarningSame module + same database with different providersBookingModule on BookingDb uses PostgreSQL in Host A but SQLite in Host B
DISC003InfoBoundary declares ReadAccess on entitiesReminder to ensure owning module shares the same physical database
// Validation result
var result = await discoveryService.ValidateAsync(topology);
result.IsValid // true when no Error-severity issues
result.Issues // all issues (Info, Warning, Error)
result.Errors // only Error-severity
result.Warnings // only Warning-severity

When ThrowOnValidationFailure is true, DiscoveryHostedService throws on Error-severity issues during startup. Info and Warning issues are logged but do not block startup.

info: Pragmatic.Discovery[0]
Registered host 'Showcase.Host' with 3 module(s)
warn: Pragmatic.Discovery[0]
DISC002: Module 'BillingModule' on database 'BillingDb' uses provider 'PostgreSql'
in 'Host-A' but 'SQLite' in 'Host-B'. Provider mismatch is unexpected.

public sealed class DiscoveryOptions
{
public bool AutoRegisterOnStartup { get; set; } = true;
public bool ValidateOnStartup { get; set; } = true;
public bool ThrowOnValidationFailure { get; set; } = false;
public const string SectionName = "Pragmatic:Discovery";
}
OptionDefaultDescription
AutoRegisterOnStartuptrueRead SG metadata and register at startup
ValidateOnStartuptrueRun cross-host validation after registration
ThrowOnValidationFailurefalseThrow on Error-severity issues (recommended true in staging/prod)

Configure via appsettings.json:

{
"Pragmatic": {
"Discovery": {
"ThrowOnValidationFailure": true
}
}
}

Replace the InMemory backend with a distributed one:

services.AddDiscovery();
services.UseDiscoveryBackend<RedisDiscoveryBackend>();

The UseDiscoveryBackend<T>() extension removes the default InMemory registration and replaces it.

public class RedisDiscoveryBackend(IConnectionMultiplexer redis) : IDiscoveryBackend
{
private readonly IDatabase _db = redis.GetDatabase();
private const string Prefix = "pragmatic:discovery:";
public async Task StoreAsync(HostTopologyInfo topology, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(topology);
await _db.StringSetAsync($"{Prefix}{topology.HostName}", json);
}
public async Task<HostTopologyInfo?> GetByHostNameAsync(string hostName, CancellationToken ct = default)
{
var json = await _db.StringGetAsync($"{Prefix}{hostName}");
return json.HasValue ? JsonSerializer.Deserialize<HostTopologyInfo>(json!) : null;
}
public async Task<IReadOnlyList<HostTopologyInfo>> GetAllAsync(CancellationToken ct = default)
{
var server = redis.GetServers().First();
var keys = server.Keys(pattern: $"{Prefix}*").ToArray();
var values = await _db.StringGetAsync(keys);
return values
.Where(v => v.HasValue)
.Select(v => JsonSerializer.Deserialize<HostTopologyInfo>(v!)!)
.ToList();
}
}

The Composition source generator emits topology metadata as an assembly attribute:

// Auto-generated by Pragmatic.SourceGenerator
[assembly: PragmaticMetadata(
MetadataCategory.HostTopology,
SchemaVersion = 1,
JsonData = """{"hostName":"Showcase.Host","modules":[...]}""")]

DiscoveryHostedService reads this attribute at startup using HostTopologyInfo.FromEntryAssembly(). The metadata includes:

  • Host name (from the assembly name)
  • All included modules and their database assignments
  • All boundary ReadAccess declarations
  • Registration timestamp

This metadata is computed at compile time, so it is always accurate for the running binary.


public class RemoteBoundaryRouter(IDiscoveryService discovery)
{
public async Task<string?> GetBaseUrlForModule(string moduleName, CancellationToken ct)
{
var hosts = await discovery.FindHostsForModuleAsync(moduleName, ct);
return hosts.FirstOrDefault()?.HostName;
}
}
var allHosts = await discoveryService.GetAllHostsAsync(ct);
foreach (var host in allHosts)
{
Console.WriteLine($"{host.HostName}: {string.Join(", ", host.Modules.Select(m => m.ModuleName))}");
}
// Output:
// Showcase.Host: BookingModule, CatalogModule
// Billing.Host: BillingModule

src/Pragmatic.Discovery/
Abstractions/
IDiscoveryService.cs High-level discovery interface
IDiscoveryBackend.cs Storage backend interface
Models/
HostTopologyInfo.cs Host topology record with JSON parsing
ModuleDeploymentInfo.cs Module + database assignment
BoundaryReadAccessInfo.cs Cross-boundary read access
DiscoveryValidationResult.cs Validation outcome
DiscoveryValidationIssue.cs Single issue with code and severity
Services/
DiscoveryService.cs Default IDiscoveryService implementation
DiscoveryValidator.cs Cross-host validation logic
InMemory/
InMemoryDiscoveryBackend.cs Thread-safe ConcurrentDictionary backend
Startup/
DiscoveryHostedService.cs IHostedService for auto-registration
Options/
DiscoveryOptions.cs Configuration options
Extensions/
DiscoveryServiceExtensions.cs AddDiscovery(), UseDiscoveryBackend<T>()

ScenarioBenefit
Single-process monolithSelf-validation at startup detects module conflicts early
Modular monolith to microservicesTopology map already exists; add a real backend and done
Multi-host health checksKnow which host owns which module for targeted monitoring
Dynamic routingRoute API calls to the correct host based on module ownership
Remote boundariesPragmatic.Composition.Host uses discovery to locate remote modules
Deployment auditingTrack what runs where, detect drift from intended topology

[Fact]
public async Task RegisterAndQuery_ReturnsTopology()
{
var backend = new InMemoryDiscoveryBackend();
var topology = new HostTopologyInfo
{
HostName = "TestHost",
Modules = [new ModuleDeploymentInfo { ModuleName = "BookingModule", DatabaseName = "BookingDb", Provider = "PostgreSql" }],
Boundaries = [],
RegisteredAt = DateTimeOffset.UtcNow
};
await backend.StoreAsync(topology);
var retrieved = await backend.GetByHostNameAsync("TestHost");
retrieved.Should().NotBeNull();
retrieved!.Modules.Should().ContainSingle(m => m.ModuleName == "BookingModule");
}
[Fact]
public async Task Validate_ProviderMismatch_ReturnsWarning()
{
var service = new DiscoveryService(new InMemoryDiscoveryBackend());
await service.RegisterAsync(new HostTopologyInfo
{
HostName = "Host-A",
Modules = [new ModuleDeploymentInfo { ModuleName = "Billing", DatabaseName = "BillingDb", Provider = "PostgreSql" }],
Boundaries = [],
RegisteredAt = DateTimeOffset.UtcNow
});
var incoming = new HostTopologyInfo
{
HostName = "Host-B",
Modules = [new ModuleDeploymentInfo { ModuleName = "Billing", DatabaseName = "BillingDb", Provider = "SQLite" }],
Boundaries = [],
RegisteredAt = DateTimeOffset.UtcNow
};
var result = await service.ValidateAsync(incoming);
result.Warnings.Should().ContainSingle(w => w.Code == "DISC002");
}

ProblemSolution
Manual service registry configurationAuto-registration from SG-emitted metadata
Configuration drifts from codeTopology metadata compiled into the assembly
Duplicate module deployments undetectedDISC001 validation at startup
Provider mismatches across hostsDISC002 warning on mismatch
ReadAccess across hosts (broken joins)DISC003 reminder to check database sharing
External registry overheadInMemory default for monolith, pluggable for distributed
No topology visibilityGetAllHostsAsync() + FindHostsForModuleAsync()

With ModuleIntegration
Pragmatic.CompositionReads SG-emitted [PragmaticMetadata(HostTopology)]
Pragmatic.Composition.HostRemote boundary routing uses discovery

| Architecture and Core Concepts | The Problem, The Solution, topology model, validation, backends | | Getting Started | Set up discovery in a Pragmatic.Design host | | Topology Validation and Custom Backends | Validation rules, issue codes, custom backends (Redis, DB) | | Common Mistakes | Wrong/Right/Why for the most frequent issues | | Troubleshooting | Problem/checklist format for runtime issues |

PackageBackend
Pragmatic.Discovery.RedisRedis-backed distributed registry
Pragmatic.Discovery.ConsulHashiCorp Consul service catalog
  • .NET 10.0+

Part of the Pragmatic.Design ecosystem.