Resolving a Service from a Secret
The resolution path of a Key Vault-backed discovery client - three fail-fast environment variables, DefaultAzureCredential with a user-assigned managed identity, a name composed as service-plus-environment, a tolerant JSON parser, and a process-lifetime cache with a bug I shipped and honest trade-offs I chose. Part 2 of Service Discovery Without a Service Mesh.
Part 1 established the contract: one Key Vault secret per service, named <service>-<environment>, holding {"URL","key","type"}. This part is the class that consumes it — the registry client at the bottom of the whole library. It is barely sixty lines, and almost every line embodies a decision worth defending or confessing.
Fail fast, at construction
The client needs three facts to do anything: which vault is the registry, which identity to authenticate as, and which environment this process is. All three come from configuration, and all three are checked in the constructor:
namespace Platform.ServiceDiscovery;
public class KeyVaultSetting : IKeyVaultSetting
{
public string KeyVaultURL { get; set; }
public string ManagedIdentityID { get; set; }
public string Stage { get; set; }
public Dictionary<string, Instance> LocalCacheInstance { get; set; }
public KeyVaultSetting()
{
ManagedIdentityID = Environment.GetEnvironmentVariable("ManagedIdentityId");
KeyVaultURL = Environment.GetEnvironmentVariable("KeyVaultURL");
Stage = Environment.GetEnvironmentVariable("Environment");
if (string.IsNullOrWhiteSpace(ManagedIdentityID))
throw new Exception("Managed identity id is missing from configuration.");
if (string.IsNullOrWhiteSpace(KeyVaultURL))
throw new Exception("Key vault URL is missing from configuration.");
if (string.IsNullOrWhiteSpace(Stage))
throw new Exception("Environment name is missing from configuration.");
LocalCacheInstance = new Dictionary<string, Instance>();
}
}
Environment variables rather than IConfiguration was a pragmatic choice: in an Azure Function App, app settings are environment variables, so this works identically in the portal, in a pipeline, and in local.settings.json — and the library stays free of any configuration-framework dependency. The cost is testability (you're mutating process state to unit test) and discoverability (nothing tells a new consumer which settings exist until the constructor throws). I'd take IOptions<T> today; more on that in Part 8.
The fail-fast throws, though, I would keep exactly as they are. This class is constructed once, at host startup, as a singleton — so a misconfigured Function App dies on its first request with a message naming the missing setting, instead of limping along and failing on the twentieth call with a null URL somewhere deep in an HTTP stack.
Note what is deliberately not configuration: the service names themselves. Consumers hold logical names in code; only the environment suffix varies by deployment. That's the whole trick — one binary, N environments.
The resolution path
public Instance GetSubSystemInstance(string keyname)
{
if (LocalCacheInstance.ContainsKey(keyname))
return LocalCacheInstance[keyname];
var credential = new DefaultAzureCredential(
new DefaultAzureCredentialOptions { ManagedIdentityClientId = ManagedIdentityID });
var secretClient = new SecretClient(new Uri(KeyVaultURL), credential);
string secret = secretClient.GetSecret($"{keyname}-{Stage}").Value?.Value;
Instance connectionDetails = null;
if (!string.IsNullOrWhiteSpace(secret))
{
connectionDetails = new Instance(secret);
LocalCacheInstance.Add(keyname, connectionDetails);
}
return connectionDetails;
}
Three things carry the design.
The name composition. $"{keyname}-{Stage}" is where the naming convention from the producer side meets the consumer. Ask for pricing in a process whose Environment is DEV and the client reads the secret pricing-DEV. Every producer and every consumer computes the same string, and that agreement is the entire integration layer — there is no schema, no compiler, no registry service enforcing it. It works remarkably well and it fails silently when it doesn't; the delimiter is a single constant (-) so at least nobody can typo it per call site.
The identity. DefaultAzureCredential with ManagedIdentityClientId pinned to a user-assigned managed identity. The user-assigned part matters at estate scale: dozens of Function Apps share one identity that holds get permission on the registry vault, so onboarding a new service is “attach the identity”, not “write a new access policy”. And because DefaultAzureCredential is a chain, the same code authenticates as you on a laptop via Azure CLI or Visual Studio credentials — no connection strings in local settings, no fake vault for development. The consumer never sees a credential for the registry itself; the only secrets in motion are the ones being resolved.
The tolerant parse. The secret value lands in a small constructor:
public class Instance
{
public Instance(string instance)
{
JObject values = JObject.Parse(instance);
values.TryGetValue("URL", out JToken url);
if (url != null) URL = url.ToString();
values.TryGetValue("key", out JToken key);
if (key != null) Key = key.ToString();
values.TryGetValue("type", out JToken type);
if (type != null) Type = (InstanceType)Enum.Parse(typeof(InstanceType), type.ToString());
}
public string URL { get; set; }
public string Key { get; set; }
public InstanceType Type { get; set; }
}
public enum InstanceType { azfun, azlogicapp }
TryGetValue per field, nothing mandatory: a record missing key parses fine (some callees are anonymous), and extra fields are ignored, which means producers can enrich records without breaking a single deployed consumer. The one hard edge is Enum.Parse — an unknown type value throws. That is arguably correct (a consumer that doesn't know the calling convention cannot call the service, and should fail loudly rather than guess) but it also means the registry's most extensible field is the one place a producer can break consumers by being too early with a new runtime type. Version-skew rule of thumb that fell out of this: roll out consumers that understand a new type before any producer writes one.
The cache, and its confession
Resolution hits the network once per service name; after that it's a dictionary lookup. Since the class is registered as a singleton (Part 5 shows the wiring), the cache lives as long as the process. That's the right default for this workload — a Function App calling five downstream services should not pay a vault round-trip per invocation, and Key Vault throttles hard enough that it would eventually make you stop.
But look closely at the sibling method that fetches raw secrets, because I shipped this:
public string GetSecret(string keyname)
{
if (LocalCache.ContainsKey(keyname)) return LocalCache[keyname];
// ... fetch from vault ...
if (string.IsNullOrWhiteSpace(secret)) // <- inverted
{
LocalCache.Add(keyname, secret);
}
return secret;
}
The condition is backwards. It caches the secret only when the secret is empty — every real value misses the cache and re-fetches from the vault, forever. The Instance path has the check the right way round; this one doesn't. It survived because its failure mode is invisible: the method still returns correct values, just slower and chattier, and nobody was graphing per-secret vault calls. A one-line unit test — resolve twice, assert one fetch — would have caught it. There were no tests. Lesson filed.
Two more trade-offs I accepted with open eyes, and one I didn't. Accepted: no expiry. A cached record lives until the process recycles, so when infrastructure pipelines rotate a function key, consumers hold the stale one until their next restart. In practice Function Apps recycle often enough that this rarely bit, and rotations were deploy-events humans watched — but “restart everything that calls X” is a real operational cost and the registry pushes no invalidation. Accepted: no negative caching. A missing record returns null and will be re-queried next call, which is what you want while a new service's record is still being written. Not really considered at the time: thread safety. Dictionary.Add from concurrent Function invocations inside one singleton can race — worst case an ArgumentException on a duplicate add or a torn read on older runtimes. ConcurrentDictionary.GetOrAdd is the drop-in fix and it's the first thing the rewrite in Part 7 should have changed and didn't. The full reckoning waits for Part 8.
One more choice worth naming: SecretClient is constructed per resolution rather than held. It looks wasteful, but the Azure SDK's token caching lives in the credential/pipeline layer and the cache in front of this method absorbs almost all traffic anyway — the dictionary means the client construction is as rare as the network call. Wasteful-looking, measured-harmless.
So a logical name now reliably becomes an Instance — a URL, a key, and a type. The next question is what the HTTP layer does with that type, because an Azure Function and a Logic App do not want to be called the same way at all: One HTTP Client, Two Calling Conventions.