Skip to content
Kumar Chandrachooda
.NET

Shipping Discovery as a NuGet Package

One static registration method wires the whole discovery subsystem into an Azure Functions host, and one CI pipeline packs it as a private NuGet package. The lifetime choices in those few lines quietly decide the caching semantics of the entire estate - and a duplicate registration teaches how DI forgives. Part 5 of Service Discovery Without a Service Mesh.

By Kumar Chandrachooda 27 Nov 2025 5 min read
One package, one registration line, every Function App in the estate

A discovery library that lives in one repository is a pattern; a discovery library that every team consumes identically is a platform. The difference is packaging, and this part is about the two pieces that did it: a single DI registration helper, and the CI pipeline that turned the library into a versioned NuGet package on a private feed.

Neither piece is glamorous. Both repay close reading, because a handful of unremarkable-looking lines here fixed the runtime semantics — cache lifetime, thread-safety exposure, per-invocation state — of every service in the estate.

One method to wire it all

Consumers were Azure Functions apps on the in-process model, where startup wiring happens in a FunctionsStartup class. The library ships one static helper so that wiring is a single call:

namespace Platform.ServiceDiscovery;

public static class RegisterSubSystem
{
    public static void RegisterSubSystemForRequest<Request>(IFunctionsHostBuilder builder)
    {
        builder.Services.AddScoped<IHttpClientService, HttpClientService>();
        builder.Services.AddSingleton<IKeyVaultSetting, KeyVaultSetting>();
        builder.Services.AddScoped<ILogToBlob, LogToBlob>();
        builder.Services.AddScoped<ISubSystemProxy<Request>, SubSystemProxy<Request>>();
        builder.Services.AddScoped<ILogToBlob, LogToBlob>();
    }
}

A consumer's startup, in full:

[assembly: FunctionsStartup(typeof(MyService.Startup))]
public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        RegisterSubSystem.RegisterSubSystemForRequest<QuoteRequest>(builder);

        // each downstream dependency: one endpoint class, one line
        builder.Services.AddScoped<ISubSystemEndPoint<QuoteRequest>, Pricing>();
        builder.Services.AddScoped<ISubSystemEndPoint<QuoteRequest>, DocStore>();
    }
}

The generic parameter is the subtle part. The proxy from Part 4 is SubSystemProxy<Request> — endpoints for a given request type form a family, and the registration method is generic so each consumer stamps out the family for its request shape. A consumer with two distinct request types calls the method twice and gets two independent proxies, each seeing only its own endpoints through IEnumerable<ISubSystemEndPoint<Request>>. The type system does the grouping that a routing table would otherwise do.

Lifetimes are the design

Read the lifetimes as decisions, because that's what they are.

KeyVaultSetting is a singleton — and that one word is the caching strategy. Everything Part 2 said about the dictionary cache — process-lifetime, no expiry, one vault round-trip per service name per process — is true only because of this line. Change it to AddScoped and the same class becomes a per-invocation cache: correct, always fresh, and hammering Key Vault once per service per function execution until the throttling starts. Nothing in KeyVaultSetting.cs tells you which behaviour you have; the class's most important property is written in a different file. That's worth internalizing beyond this library: for any stateful service, the DI lifetime is part of the design, and it's invisible at the class definition. The singleton choice is also what turns the plain Dictionary into a concurrency question, since every parallel invocation now shares one instance — the ledger in Part 8 picks that up.

Everything else is scoped, which in the Functions in-process model means per function invocation. For the proxy and endpoints that's the natural choice — they're cheap, and any per-request state they accumulate dies with the invocation. For the original HttpClientService the lifetime was almost irrelevant, since it created a new HttpClient inside every method call anyway; when the v1 rewrite moved to IHttpClientFactory, registration switched to AddHttpClient<IHttpClientService, HttpClientService>() and the factory took over handler pooling regardless of service lifetime.

And yes, ILogToBlob is registered twice. That shipped. It's harmless — with TryAdd variants nowhere in sight, the container simply records two registrations and resolves the last one for a single-instance request; you'd only notice if something asked for IEnumerable<ILogToBlob> and got two. But it's instructive precisely because it's harmless: the Microsoft DI container does not deduplicate, and it does not warn. The same forgiving behaviour that makes a stray duplicate a non-event is what makes IEnumerable<> injection (the proxy's whole mechanism) work at all — multiple registrations of an interface are a feature, so the container cannot treat yours as a mistake. In a registration method copied into blog posts and consumed by every team, a duplicate line also has a way of propagating: nobody edits the canonical wiring snippet.

The coupling to IFunctionsHostBuilder is the wiring's one real regret. The method takes the Functions-specific builder type, so the library drags Microsoft.Azure.Functions.Extensions into every consumer — including the occasional non-Functions one, like a console migration tool that wanted discovery and had to fake a host to get it. Everything the method actually does is plain IServiceCollection work; accepting IServiceCollection instead would have served Functions, ASP.NET Core and console apps identically, with the Functions call site one property access away (builder.Services). Take the narrowest dependency that does the job — a lesson that costs nothing on day one and a package reference forever after.

What the helper deliberately does not register is the endpoints themselves. The library cannot know your dependencies; you declare them, one AddScoped line per downstream service. Assembly scanning could have removed even that — find every ISubSystemEndPoint<Request> and register it — but the explicit list is your consumer-side manifest of who you call, in one reviewable place. I kept it explicit on purpose.

The pipeline that made it a platform

The distribution story is a short Azure DevOps pipeline: restore, build, dotnet pack, dotnet push to a private feed — with pre-release versioning:

- task: DotNetCoreCLI@2
  inputs:
    command: 'pack'
    packagesToPack: '**/Platform.ServiceDiscovery.csproj'
    versioningScheme: 'byPrereleaseNumber'
    majorVersion: '1'
    minorVersion: '0'
    patchVersion: '0'

- task: DotNetCoreCLI@2
  inputs:
    command: 'push'
    packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg'
    nuGetFeedType: 'internal'

byPrereleaseNumber stamps every CI run as 1.0.0-CI-<timestamp> — monotonically increasing, no manual version bumps, and unmistakably pre-release so nothing auto-upgrades into a consumer. Teams referenced an explicit version and moved forward deliberately. For an internal library with a handful of consuming teams, I'll defend that over ceremony-heavy SemVer: the version number's only job was ordering and pinning, and it did both for free.

The deeper consequence of NuGet distribution is social, not technical. A shared-source folder gets forked the first time a team wants one behaviour changed; a package makes the feed the contract and turns every change into an intentional upgrade on the consumer's schedule. That's how the library could later ship a whole second generation inside the same package — old namespace untouched, new v1 namespace alongside — and let every team migrate on its own timeline. No consumer was ever broken by an upgrade they didn't choose.

The costs, honestly: pinned versions mean fixes propagate only as fast as teams upgrade (the inverted cache condition lived on in consumers long after it was understood); and packagesToPush: '*.nupkg' on every main-branch build means the feed accretes dozens of near-identical pre-release versions — harmless, but archaeology for whoever browses it later. A retention policy on the feed is the two-minute fix nobody did.

One package, one registration call, one singleton doing the caching. The next part follows the oddest consumer of all this machinery — the component inside the library that uses the registry to find the service it logs to: The Logger That Discovers Itself.