Pragmatic.Discovery
Runtime topology registration and discovery for Pragmatic.Design hosts.
The Problem
Section titled “The Problem”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 configurationvar billingHost = configuration["Services:Billing:BaseUrl"]; // hope it's correctvar 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 hostsThe Solution
Section titled “The Solution”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 backendHost B (Billing) --> registers --> discovery backendHost A <-- queries <-- "which host owns Billing?" --> calls --> Host B via HTTPNo manual configuration. No external registry that drifts. The topology metadata travels with the compiled assembly.
Features
Section titled “Features”| Feature | Description |
|---|---|
IDiscoveryService | High-level API: register, query, validate |
IDiscoveryBackend | Pluggable storage: InMemory (default), Redis, Consul |
| Auto-registration | DiscoveryHostedService reads SG metadata at startup |
| Cross-host validation | Detects module conflicts, provider mismatches, ReadAccess issues |
| SG integration | Reads [assembly: PragmaticMetadata(HostTopology, ...)] emitted by Composition SG |
Installation
Section titled “Installation”dotnet add package Pragmatic.DiscoveryQuick Start
Section titled “Quick Start”Register in DI
Section titled “Register in DI”// Minimal -- InMemory backend, auto-registers, validates on startupservices.AddDiscovery();
// With configurationservices.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.
Monolith Setup (Single Host)
Section titled “Monolith Setup (Single Host)”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.
Distributed Setup (Multiple Hosts)
Section titled “Distributed Setup (Multiple Hosts)”When modules are split across hosts, use a shared backend:
// Host A: Booking + Catalogservices.AddDiscovery(opts => opts.ThrowOnValidationFailure = true);services.UseDiscoveryBackend<RedisDiscoveryBackend>();
// Host B: Billingservices.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.
Core Abstractions
Section titled “Core Abstractions”IDiscoveryService
Section titled “IDiscoveryService”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);}| Method | Description |
|---|---|
RegisterAsync | Stores this host’s topology in the backend |
GetAllHostsAsync | Returns all registered host topologies |
FindHostsForModuleAsync | Finds which hosts own a specific module |
ValidateAsync | Checks for cross-host conflicts and coherence issues |
IDiscoveryBackend
Section titled “IDiscoveryBackend”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.
Models
Section titled “Models”HostTopologyInfo
Section titled “HostTopologyInfo”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 assemblyvar topology = HostTopologyInfo.FromEntryAssembly();
// Read from a specific assemblyvar topology = HostTopologyInfo.FromAssembly(assembly);
// Parse from raw JSONvar topology = HostTopologyInfo.Parse(json);ModuleDeploymentInfo
Section titled “ModuleDeploymentInfo”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; }}BoundaryReadAccessInfo
Section titled “BoundaryReadAccessInfo”Describes cross-boundary read access declarations:
public sealed record BoundaryReadAccessInfo{ public required string BoundaryName { get; init; } public IReadOnlyList<string> EntityTypes { get; init; }}DiscoveryValidationResult
Section titled “DiscoveryValidationResult”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);}DiscoveryValidationIssue
Section titled “DiscoveryValidationIssue”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; }}Validation
Section titled “Validation”The DiscoveryValidator checks for cross-host topology issues when a new host registers.
Validation Rules
Section titled “Validation Rules”| Code | Severity | Rule | When It Fires |
|---|---|---|---|
| DISC001 | Info | Same module deployed in multiple hosts on different databases | Allowed (scale-out), but flagged for awareness |
| DISC002 | Warning | Same module + same database with different providers | BookingModule on BookingDb uses PostgreSQL in Host A but SQLite in Host B |
| DISC003 | Info | Boundary declares ReadAccess on entities | Reminder to ensure owning module shares the same physical database |
Validation Behavior
Section titled “Validation Behavior”// Validation resultvar result = await discoveryService.ValidateAsync(topology);
result.IsValid // true when no Error-severity issuesresult.Issues // all issues (Info, Warning, Error)result.Errors // only Error-severityresult.Warnings // only Warning-severityStartup Validation
Section titled “Startup Validation”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.Configuration Options
Section titled “Configuration Options”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";}| Option | Default | Description |
|---|---|---|
AutoRegisterOnStartup | true | Read SG metadata and register at startup |
ValidateOnStartup | true | Run cross-host validation after registration |
ThrowOnValidationFailure | false | Throw on Error-severity issues (recommended true in staging/prod) |
Configure via appsettings.json:
{ "Pragmatic": { "Discovery": { "ThrowOnValidationFailure": true } }}Custom Backend
Section titled “Custom Backend”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.
Implementing a Custom Backend
Section titled “Implementing a Custom Backend”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(); }}How SG Metadata Works
Section titled “How SG Metadata Works”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.
Querying the Topology
Section titled “Querying the Topology”Find Which Host Owns a Module
Section titled “Find Which Host Owns a Module”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; }}List All Registered Hosts
Section titled “List All Registered Hosts”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: BillingModuleProject Structure
Section titled “Project Structure”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>()Use Cases
Section titled “Use Cases”| Scenario | Benefit |
|---|---|
| Single-process monolith | Self-validation at startup detects module conflicts early |
| Modular monolith to microservices | Topology map already exists; add a real backend and done |
| Multi-host health checks | Know which host owns which module for targeted monitoring |
| Dynamic routing | Route API calls to the correct host based on module ownership |
| Remote boundaries | Pragmatic.Composition.Host uses discovery to locate remote modules |
| Deployment auditing | Track what runs where, detect drift from intended topology |
Testing
Section titled “Testing”Unit Tests — InMemoryDiscoveryBackend
Section titled “Unit Tests — InMemoryDiscoveryBackend”[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");}Integration Tests — Validation
Section titled “Integration Tests — Validation”[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");}Feature Summary
Section titled “Feature Summary”| Problem | Solution |
|---|---|
| Manual service registry configuration | Auto-registration from SG-emitted metadata |
| Configuration drifts from code | Topology metadata compiled into the assembly |
| Duplicate module deployments undetected | DISC001 validation at startup |
| Provider mismatches across hosts | DISC002 warning on mismatch |
| ReadAccess across hosts (broken joins) | DISC003 reminder to check database sharing |
| External registry overhead | InMemory default for monolith, pluggable for distributed |
| No topology visibility | GetAllHostsAsync() + FindHostsForModuleAsync() |
Cross-Module Integration
Section titled “Cross-Module Integration”| With Module | Integration |
|---|---|
| Pragmatic.Composition | Reads SG-emitted [PragmaticMetadata(HostTopology)] |
| Pragmatic.Composition.Host | Remote 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 |
Future Backends
Section titled “Future Backends”| Package | Backend |
|---|---|
Pragmatic.Discovery.Redis | Redis-backed distributed registry |
Pragmatic.Discovery.Consul | HashiCorp Consul service catalog |
Requirements
Section titled “Requirements”- .NET 10.0+
License
Section titled “License”Part of the Pragmatic.Design ecosystem.