Rewriting a Live Library in a v1 Namespace
Sync-over-async, a new HttpClient per call, out parameters everywhere - the discovery library needed a rewrite, and dozens of deployed consumers meant it could not break anyone. How the second generation shipped side by side with the first in the same NuGet package, what it fixed, and the one thing it deliberately kept. Part 7 of Service Discovery Without a Service Mesh.
Six months into production, the discovery library had earned both of the things a successful internal library earns: a lot of consumers, and a list of regrets. The regrets, catalogued across this series: synchronous Key Vault calls blocking Function threads (Part 2); a new HttpClient() per HTTP call marching toward socket exhaustion, with certificate validation disabled beside it (Part 3); out Error parameters that made every signature async-hostile (Part 4); a LogToBlob that ignored its own DI (Part 6).
None of these were emergencies. All of them were the kind of debt that compounds — every new endpoint class inherited the sync signatures, every new consumer baked the old contract deeper. And the consumers were the constraint that shaped everything: dozens of Function Apps referenced the package at pinned versions, owned by teams with their own deadlines. A breaking release would not get adopted; it would get ignored.
Versioning by namespace
The move: the rewrite shipped inside the same package, in a sub-namespace, with the original left untouched.
Platform.ServiceDiscovery <- original, frozen
Platform.ServiceDiscovery.v1 <- the rewrite
Same class names in both — KeyVaultSetting, HttpClientService, SubSystemProxy, all of it — same concepts, new contracts. A consumer upgraded the package and nothing changed: old namespace, old behaviour, recompiles clean. Migration was opting in:
using Platform.ServiceDiscovery.v1; // was: using Platform.ServiceDiscovery;
...followed by chasing the compiler through the async signature changes, one project at a time, on the consuming team's schedule.
Namespace-versioning is unfashionable — the usual advice is a major version bump and a clean break. For a public package, sure. For an internal library, the major-bump strategy has a failure mode I'd already watched: consumers pin, the old major becomes an unmaintained fork you still patch, and the ecosystem splits along package versions that tooling manages badly. Side-by-side namespaces keep one binary, one feed entry, one build — and make gradual migration a within-project affair, where one Function App can even run mixed generations mid-migration because the types are distinct. ASP.NET's own Microsoft.AspNetCore.Mvc.Versioning culture normalized v1 route namespaces for HTTP APIs; this is the same idea one level down. The cost is equally real: the package carries two of everything, the old code is frozen but shipping (its bugs live forever for whoever hasn't moved), and v1 confusingly names the newer code. I'd name it Async or V2 today and take the same architecture.
What the rewrite fixed
Async, all the way down. The vault client call became GetSecretAsync; GetSubSystemInstance now returns Task<Instance?>. That forced the interesting contract change upstream, because out parameters can't cross an await. The endpoint contract went from
IDictionary<string, JObject> Invoke(Request request,
IDictionary<string, JObject> param, out Error error);
to
Task<Error> Invoke(Request request, IDictionary<string, JObject> param);
— the error is now the return value, and the named-results dictionary moved into the param dictionary the caller already holds. It's a leaner shape: the original forced every implementer to produce two outputs even when one was empty; the rewrite says an invocation's essential result is “what went wrong, if anything”, and everything else is context you were already sharing. Every signature in the chain — endpoint, template method, proxy — became Task-returning, and the .Result blocking calls that had been quietly bridging sync interfaces over async SDKs disappeared. In a Functions host, where blocked threads are stolen scale-out, this was the change with the most invisible payoff.
IHttpClientFactory and a typed client. The per-call new HttpClient(handler) pattern died. Registration became:
builder.Services.AddHttpClient<IHttpClientService, HttpClientService>()
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (m, crt, chn, e) => true
});
and the service now receives its HttpClient from the factory — pooled handlers, recycled connections, DNS-change awareness, no socket bleed. But a shared client surfaced a problem the throwaway clients had been hiding: the original mutated BaseAddress and default headers per call, and you cannot do that to a client other requests are sharing (mutating BaseAddress after first use literally throws). So the per-call switch from Part 3 was rebuilt to configure the request instead of the client:
private static void SetRequestDefaults(HttpRequestMessage request,
Instance connectionDetails, string urlPart)
{
var uri = new Uri(connectionDetails.URL);
var baseUrl = uri.Scheme + "://" + uri.Host;
var part = urlPart.StartsWith('/') ? urlPart : $"/{urlPart}";
switch (connectionDetails.Type)
{
case InstanceType.azfun:
request.RequestUri = new Uri($"{baseUrl}{part}");
request.Headers.Add("x-functions-key", connectionDetails.Key);
break;
case InstanceType.azlogicapp:
request.RequestUri = new Uri($"{baseUrl}?{connectionDetails.Key}&{part}");
break;
}
}
Stateless, static, and honest about what varies per call. This is the general lesson of IHttpClientFactory migrations: the hard part is never the registration line, it's discovering how much per-request state your code had been smuggling into client instances. (Sharp eyes will note the rebuilt URI keeps scheme and host but drops any path prefix the record's URL carried, like /api — the method name has to carry the full path in v1. A behavioural difference exactly zero release notes mentioned, because there were no release notes. Consumers found it with the debugger.)
Nullability, and smaller repairs. The rewrite turned on nullable reference types in anger — Instance? returns, annotated properties — so “record not found” became a compiler-visible path instead of a runtime surprise. LogToBlob got real dependency injection at last, taking the HTTP service and vault client through its constructor and thereby joining the singleton cache it had been bypassing. POST gained the optional query-string parameters the other verbs had. Error logs got method-name prefixes so a failure said which verb produced it.
What it deliberately kept — and what it missed
Look again at that ConfigurePrimaryHttpMessageHandler block: (m, crt, chn, e) => true. The certificate bypass survived the rewrite — promoted, even, from a per-call handler into the composition root, applied to every pooled connection. That tells you something true about how these things persist: the rewrite was scoped to mechanical debt — sockets, threads, signatures — and TLS trust was somebody's risk decision from a year earlier that no one felt authorized to reverse mid-refactor. Internal traffic, APIM and private endpoints in the path, “it's fine”. It is the first line the final part of this series will refuse to defend.
The rewrite also missed two fixes it was perfectly positioned to make, both catalogued in earlier parts: the inverted cache condition in GetSecret was carried over verbatim — the bug survived being retyped, which is the strongest argument for tests I know; a rewrite without a test suite is a photocopier — and the cache dictionaries stayed plain Dictionary rather than becoming ConcurrentDictionary, so the singleton's thread-safety exposure carried into the async generation with more concurrency around it, not less.
Scorecard, honestly: the rewrite fixed the things that page you at night (sockets, blocked threads), improved the things that slow you down (contracts, nullability, DI), and preserved the things nobody owned (TLS trust, the cache bug, testlessness). That's not a failed rewrite — that's what rewrites do when their scope is set by pain rather than by audit. Which is exactly why this series ends with the audit: the complete ledger of what this discovery layer owes, what I'd pay first, and when you should stop paying and buy the managed thing instead — What a Homegrown Discovery Layer Owes You.