Service Discovery Without a Service Mesh
Dozens of Azure Functions and Logic Apps need each other's URLs and keys, and there is no Kubernetes, no sidecar, no mesh to hand them out. How a small .NET library turned one Key Vault and a naming convention into a working discovery layer - the consumer side of a registry made of secrets. Part 1 of a new series.
Every article about service discovery starts in a Kubernetes cluster, and that is exactly where my problem wasn't.
The estate I was working in — a large Azure platform I helped build — was made of PaaS parts: scores of Azure Functions arranged in tiers, Logic App workflows stitching them together, API Management out front. No AKS for the integration layer, no sidecars, no Consul, no mesh. And yet the discovery problem was fully present: a process-tier function needs to call the pricing system, the pricing system needs the document store, everything needs the logging function. Each of those callees has a URL that depends on where it was deployed, a credential (a function key or a Logic App SAS signature) that changes when infrastructure pipelines run, and an environment — the dev copy must call dev, prod must call prod.
The default answer is app settings. Every consumer gets PricingUrl, PricingKey, DocumentStoreUrl, DocumentStoreKey… multiplied by every environment, pasted into every Function App's configuration by every deployment pipeline. I have watched that approach at scale and it fails exactly the way you'd expect: settings drift, keys go stale in three places out of four, and moving a service to a new resource group becomes a coordination project across every team that calls it.
This series is about the library I built instead — a small .NET package, Platform.ServiceDiscovery, that gave every service in the estate the same three-line answer to "how do I call X?". This first post lays out the design end to end; the rest of the series works down through each mechanism at full depth.
The registry already existed
The insight that started it: we already had a durable, access-controlled, name-addressable record store with audit logging and RBAC — Azure Key Vault. The infrastructure side of the estate (Terraform, in this case) was made to write one secret per service, and I've written about that half separately in A Service Registry Made of Key Vault Secrets. The contract is small enough to hold in your head:
- The secret's name is the service's logical identity plus its environment:
pricing-DEV,pricing-PROD,docstore-UAT. - The secret's value is a JSON record:
{
"URL": "https://fn-pricing-dev.azurewebsites.net/api",
"key": "<the function key>",
"type": "azfun"
}
Three fields. URL is where the service lives. key is the credential that opens it. And type is the quiet load-bearing field: it names the calling convention. azfun means “this is an Azure Function — send the key as an x-functions-key header”. azlogicapp means “this is a Logic App workflow — the key is a SAS query string, append it to the URL”. A consumer that resolves a name learns not just where the service is but how to speak to it.
This series is about the other half of that contract: the .NET code that reads those records and turns them into HTTP calls.
What the consumer sees
From inside a Function App, calling another service looks like this — resolve a logical name, get back a typed record, hand it to an HTTP helper:
// namespace renamed for publication; the real one is employer-internal
using Platform.ServiceDiscovery;
Instance pricing = _keyVault.GetSubSystemInstance("pricing");
var response = await _httpClient.InvokePostApiAsync(
pricing, "CalculatePremium", requestBody);
Notice what is not there. No URL. No key. No environment. The consumer holds one string — the logical service name — and the library composes the rest: it appends the current environment (from configuration) to form the secret name, fetches the secret from the registry vault using the app's managed identity, parses the JSON into an Instance, caches it, and lets the HTTP layer pick the right authentication style off the type field. Deploy the same binary to dev and prod and it resolves different records because the environment differs, not the code.
The moving parts
The whole library is deliberately small — a handful of classes, each of which gets its own article:
KeyVaultSetting — the registry client. Reads three settings at startup (vault URL, managed-identity client ID, environment name) and fails fast if any is missing. Resolution is DefaultAzureCredential with the user-assigned identity's client ID — which means production authenticates with no secret at all, and local development falls back to your developer credential. Resolved records are cached in-process, which is both the reason the whole thing is fast enough and the source of the most honest trade-off in the series. That's Part 2.
Instance and HttpClientService — the transport. Instance is the parsed record; HttpClientService wraps GET/POST/PUT/DELETE and switches on Instance.Type to inject the credential correctly for each PaaS flavour. One client, two calling conventions, extensible by adding an enum member. Part 3.
SubSystemEndPointBase and SubSystemProxy — the convention layer. Above raw resolution sits a pattern I grew fond of: each downstream service gets a small endpoint class, and the class's own name is the registry key — declare class Pricing : SubSystemEndPointBase<QuoteRequest, Pricing> and the base class derives the lookup key from typeof(Pricing).Name. A generic proxy collects every registered endpoint through IEnumerable<> injection and routes calls by name, so orchestration code can say “invoke pricing with this request” without referencing a concrete class. Part 4.
RegisterSubSystem — the wiring. One static helper registers the whole subsystem into an Azure Functions host, and a CI pipeline packs the library as a NuGet package so every team consumes the same behaviour. The lifetime choices in that one method — which service is a singleton and which is scoped — quietly decide the caching semantics of the entire estate. Part 5.
LogToBlob — discovery eating its own cooking. The estate's transaction logging writes to blob storage through a dedicated logging function — which is itself discovered through the same registry. A component of the discovery library uses the discovery library. That circularity is more interesting than it sounds. Part 6.
And then the two retrospective parts: the library was rewritten once — sync to async, new HttpClient() per call to IHttpClientFactory — with both generations shipping side by side in one package under a v1 namespace (Part 7); and it carries real scars — a cache with no expiry, a certificate-validation bypass I will neither hide nor defend, no retry policy — which get a full honest accounting in Part 8.
Why not the real thing?
It's worth asking up front. Service meshes and registries earn their complexity by solving liveness (is the instance up?), load balancing (which instance?), and dynamic membership (instances come and go). On Azure PaaS, the platform already owns most of that: a Function App is one stable ingress URL that Azure scales behind the scenes; there is no fleet of instances to track. What's left of the discovery problem is exactly three questions — where is it, what opens it, how do I call it — and that is precisely the three fields in the record.
So the honest framing isn't “we built a poor man's Consul”. It's that on a PaaS estate the discovery problem shrinks until a secured record store plus a small client library covers it. The record store costs nothing (the vault existed anyway), the client library is a few hundred lines, and the whole mechanism is legible to any .NET developer in an afternoon.
What it does not give you — health checking, cache invalidation on key rotation, protection from naming drift — it doesn't pretend to give you, and the estate felt each of those edges over time. Knowing which edges you've accepted is the difference between a pattern and an accident, and that's what this series is for.
Next up, the resolution path in full: Resolving a Service from a Secret — three environment variables, one managed identity, a dictionary cache, and the bug I shipped in it.