Skip to content

Architecture and Core Concepts

This guide explains why Pragmatic.Discovery exists, how its pieces fit together, and how it enables safe distributed deployments. Read this before diving into the getting-started guide.


A modular monolith splits business logic into isolated modules — Booking, Billing, Catalog — each with its own database assignment. In a single host, everything works: the Composition source generator knows the full topology at compile time and validates it.

Splitting to microservices: topology becomes invisible

Section titled “Splitting to microservices: topology becomes invisible”

When you split modules across hosts, compile-time validation disappears:

Host A (Booking + Catalog) → BookingDb (PostgreSQL)
Host B (Billing) → BillingDb (PostgreSQL)

Host A has no idea what Host B deploys. You cannot know at compile time whether:

  • Two hosts accidentally include the same module (dual writes, data corruption)
  • A module declares [ReadAccess] on entities that live in a different host (SQL joins across hosts are impossible)
  • Two hosts assign the same database name but use different providers (PostgreSQL vs. InMemory — likely a misconfiguration)

Without runtime topology awareness, these issues surface as silent data corruption, failed queries, or mysterious production errors days after deployment.

Manual service registries: maintenance burden

Section titled “Manual service registries: maintenance burden”

Teams often solve this with Consul, etcd, or custom service registries. But those systems do not understand your module topology. They know “Host B is alive at port 5002” but not “Host B owns BillingModule on BillingDb with PostgreSQL.” You end up maintaining a separate configuration map that drifts from the actual code.

The gap: compile-time topology meets runtime reality

Section titled “The gap: compile-time topology meets runtime reality”

The Pragmatic Composition SG already emits a [assembly: PragmaticMetadata(HostTopology, ...)] attribute with the full module-to-database-to-provider mapping. This metadata exists in every compiled host assembly. What is missing is a runtime service that reads it, stores it, shares it across hosts, and validates it.


Pragmatic.Discovery bridges compile-time topology and runtime deployment. It reads the SG-emitted metadata at startup, registers it in a shared backend, and validates the deployment against other hosts.

Compile Time (SG) Runtime (Discovery)
───────────────── ───────────────────
[Module] classes with DiscoveryHostedService reads
[Include<T>] attributes PragmaticMetadata attribute
│ │
v v
SG emits HostTopology JSON ───────> HostTopologyInfo.FromEntryAssembly()
in assembly metadata │
v
RegisterAsync() → IDiscoveryBackend
v
ValidateAsync() → cross-host checks
v
DISC001/002/003 issues logged or thrown

The same “get guest by ID” scenario in a distributed deployment:

// Showcase.Host registers: [Booking, Catalog] → ShowcaseDb (PostgreSQL)
// Showcase.Billing.Host registers: [Billing] → BillingDb (PostgreSQL)
// At startup, both hosts register with the discovery backend.
// If both accidentally include CatalogModule, validation catches it:
// DISC001 Error: CatalogModule deployed in multiple hosts
// At runtime, FindHostsForModuleAsync locates the correct host:
var billingHosts = await discovery.FindHostsForModuleAsync("BillingModule");
// → [Showcase.Billing.Host]

No manual configuration. No external service registry that drifts from the code. The topology metadata travels with the compiled assembly, and Discovery reads it automatically.


Discovery operates on three immutable records that describe a host’s deployment.

The top-level record representing 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; } = DateTimeOffset.UtcNow;
}
PropertyDescription
HostNameLogical host name, derived from the root namespace of the host project
ModulesAll modules deployed in this host, with their database assignments
BoundariesReadAccess declarations per boundary (cross-boundary SQL join intent)
RegisteredAtUTC timestamp when this topology was registered

Describes a single module’s deployment within a host:

public sealed record ModuleDeploymentInfo
{
public required string ModuleName { get; init; }
public string? DatabaseName { get; init; }
public string? Provider { get; init; }
}
PropertyDescription
ModuleNameThe module class name (e.g., "BillingModule")
DatabaseNameThe database class name (e.g., "ShowcaseFinancialDatabase"), or null if the module has no database
ProviderThe database provider (e.g., "SqlServer", "InMemory"), or null if not assigned

Describes cross-boundary read access declarations:

public sealed record BoundaryReadAccessInfo
{
public required string BoundaryName { get; init; }
public IReadOnlyList<string> EntityTypes { get; init; } = [];
}
PropertyDescription
BoundaryNameThe boundary name (e.g., "Booking")
EntityTypesEntity type names this boundary needs to read via SQL join (declared with [ReadAccess<T>] on the boundary class)

The Composition SG emits JSON metadata as an assembly attribute:

{
"host": "Showcase.Host",
"includes": [
{ "module": "BookingModule", "database": "ShowcaseDatabase", "provider": "InMemory" },
{ "module": "CatalogModule", "database": "ShowcaseDatabase", "provider": "InMemory" }
],
"boundaries": [
{ "name": "Booking", "readAccess": ["Property", "RoomType"] }
]
}

HostTopologyInfo provides three parsing entry points:

MethodUse Case
FromEntryAssembly()Read metadata from the running host’s entry assembly. Used by DiscoveryHostedService automatically.
FromAssembly(assembly)Read from a specific assembly. Useful for testing or analyzing external assemblies.
Parse(json)Parse raw JSON. Useful for custom backends or manual topology construction.

All three return null if no valid metadata is found, rather than throwing.


The DiscoveryValidator checks for cross-host topology issues when a new host registers. It compares the incoming topology against all previously registered topologies.

Host registers → DiscoveryHostedService.StartAsync()
v
HostTopologyInfo.FromEntryAssembly()
v
RegisterAsync() → stored in backend
v
ValidateAsync() → compare against existing
v
DiscoveryValidationResult
├── IsValid (no Error-severity issues)
├── Errors → logged as Error
├── Warnings → logged as Warning
└── Info → logged as Debug
CodeSeverityTriggerDescription
DISC001InfoSame module in multiple hosts with different databasesMulti-deployment (scale-out). Allowed but flagged: ensure consistency via messaging.
DISC002WarningSame module + same database name with different providersLikely misconfiguration. One host uses PostgreSQL, another uses InMemory for the same database name.
DISC003InfoBoundary declares ReadAccess on entities from another boundaryFlagged for awareness. Compile-time SG validation (PRAG0601/PRAG0602) provides exact cross-database checks.
public sealed record DiscoveryValidationResult
{
public static readonly DiscoveryValidationResult Ok = new() { IsValid = true };
public bool IsValid { get; init; }
public IReadOnlyList<DiscoveryValidationIssue> Issues { get; init; } = [];
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; }
public required string Message { get; init; }
public IssueSeverity Severity { get; init; }
}
public enum IssueSeverity
{
Info,
Warning,
Error
}
EnvironmentRecommended ThrowOnValidationFailureRationale
Developmentfalse (default)Log warnings, do not block startup. InMemory backend, single host — few real conflicts.
StagingtrueCatch topology issues before production. Block startup on Error-severity issues.
ProductiontruePrevent known-broken deployments from receiving traffic.

Discovery uses a pluggable backend for topology storage. The backend abstraction is IDiscoveryBackend:

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);
}

A thread-safe ConcurrentDictionary implementation. Suitable for:

  • Single-process monoliths (discovery is self-referential)
  • Integration tests
  • Local development

Registered automatically by AddDiscovery(). All data lives for the lifetime of the application — no persistence, no sharing across processes.

For distributed deployments, replace the InMemory backend with a shared store:

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

UseDiscoveryBackend<T>() removes the default InMemory registration and replaces it with your implementation.

Any class implementing IDiscoveryBackend can serve as a backend. The interface has three methods — StoreAsync, GetByHostNameAsync, GetAllAsync — keeping the implementation surface minimal.

PackageBackendStatus
Pragmatic.Discovery.RedisRedis-backed distributed registryPlanned
Pragmatic.Discovery.ConsulHashiCorp Consul service catalogPlanned

By default, DiscoveryHostedService runs at startup and:

  1. Reads [assembly: PragmaticMetadata(HostTopology, ...)] from the entry assembly
  2. Calls IDiscoveryService.RegisterAsync() to store the topology
  3. Calls IDiscoveryService.ValidateAsync() to check for cross-host issues

This is fully automatic — no code needed beyond services.AddDiscovery().

For testing or custom scenarios, disable auto-registration and register manually:

services.AddDiscovery(opts =>
{
opts.AutoRegisterOnStartup = false;
opts.ValidateOnStartup = false;
});

Then register when ready:

var topology = new HostTopologyInfo
{
HostName = "TestHost",
Modules =
[
new ModuleDeploymentInfo
{
ModuleName = "CatalogModule",
DatabaseName = "TestDb",
Provider = "InMemory"
}
]
};
await discoveryService.RegisterAsync(topology);

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. Logs the host name and module count.
GetAllHostsAsyncReturns all registered host topologies from the backend.
FindHostsForModuleAsyncFinds all hosts that include (own) the specified module. Case-sensitive match on module name.
ValidateAsyncCompares the given topology against all registered hosts. Returns issues with codes, messages, and severities.
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";
}

Configure via code:

services.AddDiscovery(opts =>
{
opts.ThrowOnValidationFailure = true;
});

Or via appsettings.json:

{
"Pragmatic": {
"Discovery": {
"AutoRegisterOnStartup": true,
"ValidateOnStartup": true,
"ThrowOnValidationFailure": true
}
}
}

Discovery depends on the Composition SG for its metadata source. The SG reads [Module] classes with [Include<T>] and [BelongsTo<T>] attributes at compile time and emits:

[assembly: PragmaticMetadata(MetadataCategory.HostTopology, """{"host":"Showcase.Host","includes":[...]}""")]

Without Pragmatic.Composition and at least one [Module] class, no metadata is emitted and Discovery has nothing to register.

Pragmatic.Composition.Host uses IDiscoveryService to locate remote modules when [RemoteBoundary<T>] is declared. Discovery tells the composition layer which host URL to call for a given module:

Showcase.Host → registers: [Booking, Catalog]
Showcase.Billing.Host → registers: [Billing]
Showcase.Host wants to call Billing:
1. FindHostsForModuleAsync("BillingModule") → Showcase.Billing.Host
2. HTTP call to configured base URL

Inject IDiscoveryService into custom health checks to monitor the deployment topology:

public class TopologyHealthCheck(IDiscoveryService discovery) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
var hosts = await discovery.GetAllHostsAsync(ct);
return hosts.Count > 0
? HealthCheckResult.Healthy($"{hosts.Count} host(s) registered")
: HealthCheckResult.Degraded("No hosts registered");
}
}

When Pragmatic.Discovery is referenced in a host project, the FeatureDetector in the Composition SG detects it and auto-registers Discovery services via RegisterAllPragmaticServices(). This means AddDiscovery() is called automatically in hosts that use PragmaticApp.RunAsync().