Skip to content

Topology Validation and Custom Backends

When multiple hosts deploy the same modules, inconsistencies can arise: overlapping module ownership, missing database assignments, or conflicting read-access declarations. The Discovery module detects these issues at startup before they cause runtime failures.


When ValidateOnStartup is enabled (default), the DiscoveryHostedService performs two steps:

  1. Register — reads [assembly: PragmaticMetadata(HostTopology, ...)] from the current host and calls IDiscoveryService.RegisterAsync().
  2. Validate — calls ValidateAsync() comparing the local topology against all registered hosts.
services.AddDiscovery(opts =>
{
opts.AutoRegisterOnStartup = true; // Register this host's topology
opts.ValidateOnStartup = true; // Compare against other hosts
opts.ThrowOnValidationFailure = false; // Log warnings instead of crashing
});

DiscoveryValidationResult contains a list of DiscoveryValidationIssue entries, each with a code, message, and severity.

CodeSeverityMeaning
DISC001ErrorA module is deployed in multiple hosts without [RemoteBoundary] declaration
DISC002WarningA module has no database assignment in any host
DISC003InfoCross-boundary read access declared but target boundary is in a different host

Two hosts both include the same module without marking one as remote. This means both hosts run the module’s business logic independently, which can cause data inconsistency.

Host "Booking.Host" includes CatalogModule
Host "Catalog.Host" includes CatalogModule
→ DISC001 Error: CatalogModule deployed in multiple hosts

Fix: Use [RemoteBoundary<CatalogModule>] on the host that should invoke remotely, and [Include<CatalogModule>] only on the owning host.

A module is included but has no database assignment. This is a warning because some modules (e.g., utility modules) legitimately don’t need a database.

Fix: Use [Include<TModule, TDatabase>] to assign a database, or ignore if the module has no entities.

A boundary declares [ReadAccess] for entities in another boundary, but that boundary is deployed in a different host. SQL joins across hosts are not possible.

Fix: Either co-locate the modules in the same host, or use application-level data fetching instead of SQL joins.


The HostTopologyInfo record captures a host’s deployment topology. It is populated automatically from SG-generated assembly metadata.

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

For testing or non-standard scenarios, you can build topology manually:

var topology = new HostTopologyInfo
{
HostName = "Booking.Host",
Modules =
[
new ModuleDeploymentInfo { ModuleName = "BookingModule", DatabaseName = "BookingDb", Provider = "PostgreSql" },
new ModuleDeploymentInfo { ModuleName = "CatalogModule", DatabaseName = "BookingDb", Provider = "PostgreSql" }
],
Boundaries = []
};

In production, topology is read from SG-generated metadata:

var topology = HostTopologyInfo.FromEntryAssembly();

The default InMemoryDiscoveryBackend works for single-process and testing scenarios. For distributed deployments, implement IDiscoveryBackend with a shared store.

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);
}
public sealed class RedisDiscoveryBackend(IConnectionMultiplexer redis) : IDiscoveryBackend
{
private const string KeyPrefix = "pragmatic:discovery:";
public async Task StoreAsync(HostTopologyInfo topology, CancellationToken ct)
{
var db = redis.GetDatabase();
var json = JsonSerializer.Serialize(topology);
await db.StringSetAsync($"{KeyPrefix}{topology.HostName}", json);
}
public async Task<HostTopologyInfo?> GetByHostNameAsync(string hostName, CancellationToken ct)
{
var db = redis.GetDatabase();
var json = await db.StringGetAsync($"{KeyPrefix}{hostName}");
return json.HasValue ? JsonSerializer.Deserialize<HostTopologyInfo>(json!) : null;
}
public async Task<IReadOnlyList<HostTopologyInfo>> GetAllAsync(CancellationToken ct)
{
var server = redis.GetServers().First();
var keys = server.Keys(pattern: $"{KeyPrefix}*").ToArray();
var db = redis.GetDatabase();
var results = new List<HostTopologyInfo>();
foreach (var key in keys)
{
var json = await db.StringGetAsync(key);
if (json.HasValue)
results.Add(JsonSerializer.Deserialize<HostTopologyInfo>(json!)!);
}
return results;
}
}
services.AddDiscovery();
services.UseDiscoveryBackend<RedisDiscoveryBackend>();

PropertyTypeDefaultDescription
AutoRegisterOnStartupbooltrueRegister this host’s topology on startup
ValidateOnStartupbooltrueValidate topology against other hosts
ThrowOnValidationFailureboolfalseThrow exception on validation errors (vs. log warning)

Configuration section: Pragmatic:Discovery

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