Skip to content
Kumar Chandrachooda
.NET

The Class Name Is the Registry Key

Above raw resolution sits a convention layer - an abstract endpoint base class that derives its Key Vault lookup key from its own type name, a template method separating the call from the interpretation, and a generic proxy that routes by name across every endpoint the DI container knows. Part 4 of Service Discovery Without a Service Mesh.

By Kumar Chandrachooda 20 Oct 2025 5 min read
Declare a class named Pricing, and pricing-DEV is what it resolves

Parts 2 and 3 built a working discovery stack: a name becomes an Instance, an Instance becomes an authenticated HTTP call. A consumer could stop there. But raw resolution scattered across an orchestrating function has a smell — every call site repeats the same lookup string, the same response interpretation, the same error shaping — and in an estate where one process-tier function fans out to four or five downstream systems, that repetition multiplies fast.

So the library grew a convention layer, and its central trick is the thing I named this article after: the C# class name is the registry key.

An endpoint per service

Each downstream service a consumer calls gets one small class. Here's the base they derive from:

namespace Platform.ServiceDiscovery;

public abstract class SubSystemEndPointBase<Request, ServiceName>
    : ISubSystemEndPoint<Request>
{
    protected readonly IHttpClientService _HttpClient;
    protected readonly IServiceProvider _serviceProvider;
    protected readonly IKeyVaultSetting keyVault;

    public static readonly string FriendlyName = typeof(ServiceName).Name;

    public string FriendlyNameInstance => FriendlyName;

    public virtual string SubSystemName => FriendlyName;

    public Instance SubSystem => keyVault.GetSubSystemInstance(SubSystemName);

    protected SubSystemEndPointBase(IHttpClientService httpService,
        IServiceProvider service, IKeyVaultSetting keyVaultSetting)
    {
        _HttpClient = httpService;
        _serviceProvider = service;
        keyVault = keyVaultSetting;
    }

    public abstract IDictionary<string, JObject> Invoke(
        Request request, IDictionary<string, JObject> param, out Error error);

    public bool InvokeAndProcessMessage(
        Request request, IDictionary<string, JObject> param, out Error error)
    {
        Invoke(request, param, out error);
        return ProcessMessage(request, param, error);
    }

    public abstract bool ProcessMessage(
        Request request, IDictionary<string, JObject> param, Error error);
}

The line that carries the design is typeof(ServiceName).Name. A concrete endpoint declares itself with its own type as the second generic argument — the curiously recurring template pattern, pressed into service as a naming device:

public class Pricing : SubSystemEndPointBase<QuoteRequest, Pricing>
{
    public Pricing(IHttpClientService http, IServiceProvider sp, IKeyVaultSetting kv)
        : base(http, sp, kv) { }

    public override IDictionary<string, JObject> Invoke(
        QuoteRequest request, IDictionary<string, JObject> param, out Error error)
    {
        var response = _HttpClient.InvokePostApiAsync(
            SubSystem, "CalculatePremium", BuildPayload(request)).Result;
        // ... interpret response, populate error, return named results ...
    }

    public override bool ProcessMessage(
        QuoteRequest request, IDictionary<string, JObject> param, Error error)
        => error == null;
}

Declare class Pricing, and FriendlyName becomes "Pricing", and SubSystem resolves the Key Vault secret Pricing-DEV (via the composition from Part 2). No attribute, no registration string, no config entry. The class is the registration. When the class name can't match the registry key — services registered under abbreviations, or shared endpoints — SubSystemName is virtual and one override restores the mapping. In practice almost nobody overrode it, which was the point: the path of least resistance produced consistent names, and the naming convention that the producer side leaned on got a second enforcement point in the type system. Rename the class and you have renamed the dependency — visibly, in a code review, rather than silently in a config file.

Note also that SubSystem is a property, resolved on access, not in the constructor. Endpoints are constructed by DI at host startup; if resolution happened there, one missing registry record would take down the whole host. Lazily resolved, a missing record only fails the calls that actually need it — and the cache behind it makes repeat access free.

Invoke, then ProcessMessage

The base class splits every interaction into two abstract halves and one sealed combination. Invoke makes the remote call and returns a dictionary of named JObject results plus an Error out-parameter. ProcessMessage looks at what happened and decides what it means — success or failure, retryable or fatal, and any local bookkeeping. InvokeAndProcessMessage is the template method gluing them in order.

The separation looks ceremonial until you see the call sites that used it. Orchestrators sometimes wanted the raw outcome (Invoke, then branch on the error themselves); handlers replaying a stored message wanted the interpretation without re-calling the remote service (ProcessMessage alone, feeding it a recorded error); the common case wanted both. Three verbs, one contract, no duplicated interpretation logic.

The Error envelope is the estate-wide agreement that makes the interpretation half composable:

public class Error
{
    [JsonPropertyName("code")]              public string Code { get; set; }
    [JsonPropertyName("message")]           public string Message { get; set; }
    [JsonPropertyName("additional_message")] public string AdditionalMessage { get; set; }
}

Every subsystem returns failures in this shape, so a caller three hops up can log and branch on code without knowing which service produced it. (Sharp-eyed readers will notice these are System.Text.Json attributes in a library whose payloads are Newtonsoft JObjects — two serializers in one small package. It worked, because the envelope was always serialized by the newer stack at the API boundary, but it's the kind of seam I'd unify today.)

The out Error parameters and the blocking .Result you can smell nearby are the original generation's synchronous design — the async rewrite that fixed both is Part 7.

The proxy: routing by name

Endpoints alone still leave the orchestrator holding concrete types. The last piece removes even that:

public class SubSystemProxy<Request> : ISubSystemProxy<Request>
{
    private readonly IEnumerable<ISubSystemEndPoint<Request>> _endPoints;

    public SubSystemProxy(IEnumerable<ISubSystemEndPoint<Request>> subSystemEndPoints)
        => _endPoints = subSystemEndPoints;

    public List<string> ListOfServices =>
        _endPoints.Select(e => e.FriendlyNameInstance).ToList();

    private ISubSystemEndPoint<Request> FindSubSystem(string abbreviation)
    {
        var found = _endPoints.FirstOrDefault(s => s.FriendlyNameInstance
            .Contains(abbreviation, StringComparison.OrdinalIgnoreCase));
        if (found == null)
            throw new ArgumentNullException(
                $"Endpoint not found in endpoint registration. ('{abbreviation}')");
        return found;
    }

    public IDictionary<string, JObject> Invoke(string abbreviation,
        Request request, IDictionary<string, JObject> param, out Error error)
        => FindSubSystem(abbreviation).Invoke(request, param, out error);
}

The constructor is the idiomatic .NET move that makes the whole layer click: ask DI for IEnumerable<ISubSystemEndPoint<Request>> and the container hands you every registered endpoint for that request type. Register a new endpoint class and the proxy knows it — no list to maintain, no switch to extend. Orchestration code then routes by string: proxy.InvokeAndProcessMessage("pricing", request, param, out error). The string can come from configuration, from a message, from a workflow definition — which is precisely what an orchestrator processing “call these three systems for this transaction type” wants.

Two honest edges. Substring matching. Contains with case-insensitivity is forgiving — "price" finds Pricing — and forgiving lookups are ambiguous lookups: add a PricingAudit endpoint and "pricing" binds to whichever the container enumerates first. Registration order becomes semantics, invisibly. Equals, or a startup check that no friendly name contains another, would have closed it; the estate instead survived on naming discipline, which is a strategy right up until it isn't. The exception type. ArgumentNullException for “no endpoint matched” reads as caller passed null in every log aggregator on earth; a KeyNotFoundException — or a domain exception listing ListOfServices, which sits right there on the class as a diagnostic — would have turned a 2 a.m. head-scratcher into a self-explaining failure.

And one design boundary worth defending: the proxy routes among in-process endpoint classes, not among registry records. You can only route to a service somebody wrote an endpoint class for — the registry may know fifty services, but this consumer declares its dependencies in code, one class each. I've come to see that restraint as a feature: the alternative — dynamically invoking anything the vault knows — is stringly-typed RPC with no compile-time trace of who depends on whom.

What's left is the wiring that makes all of this appear in a Function App with one line — and the packaging that put the same behaviour in every consumer across the estate. That's next: Shipping Discovery as a NuGet Package.