Skip to content
Kumar Chandrachooda
.NET

The Graveyard of Registered Dependencies

Redis registered in nine services and read in one, discovery stacks in services that never call anyone, an outbox package referenced but never wired, and versions that float by construction - an inventory of everything Pacco wires up and never uses.

By Kumar Chandrachooda 25 Feb 2026 8 min read
Rows of neat registrations, most of them headstones

A dependency injection container is write-only memory. Registration is additive, silent, and free at review time: .AddRedis() costs six characters in a fluent chain, compiles against any service, and will never once complain that nothing downstream resolves what it registered. The costs land elsewhere, later — a config section that must be populated in every environment, an open connection an operator has to explain, a package on the upgrade path, a subsystem in the threat model. Part 11 closed on the observation that the estate's smells cluster in the boilerplate nobody reads; this part inventories the purest expression of that: the dependencies Pacco registers and never uses. Every headstone below is verified in source. Together they are the estate's quietest ledger — and, I will argue, its most predictable one, because every entry was carved by the same chisel: a composition root copied from the previous service, with nothing deleted.

Redis, nine registrations, one reader

Grep the estate for .AddRedis() and it appears in the composition roots of nine services: Availability, Customers, Deliveries, Identity, Orders, OrderMaker, Parcels, Vehicles — and Operations, which calls it twice. Now grep for a consumer: the only code in all thirteen repositories that reads or writes through the registration is Operations' OperationsService, which injects IDistributedCache to store operation state under requests:{id} keys (plus, conditionally, Operations' SignalR Redis backplane). That is the entire usage surface. Eight services hold an open Redis connection, carry a redis section in a 190-line appsettings file, and ship a cache client in every Docker image, for nothing.

The double registration is worth seeing in situ, because it explains how the graveyard grows. From Pacco.Services.Operations.Api/Infrastructure/Extensions.cs:

return builder
    .AddErrorHandler<ExceptionToResponseMapper>()
    .AddJwt()
    .AddCommandHandlers()
    .AddEventHandlers()
    .AddQueryHandlers()
    .AddHttpClient()
    .AddConsul()
    .AddFabio()
    .AddRabbitMq(plugins: p => p.AddJaegerRabbitMqPlugin())
    .AddMongo()
    .AddRedis()
    .AddMetrics()
    .AddJaeger()
    .AddRedis()
    .AddSignalR()
    .AddWebApiSwaggerDocs()
    .AddSecurity();

AddRedis at position eleven and again at position fourteen, three lines apart, in the one service that actually uses Redis. Vehicles has the same defect with a different tenant — .AddMongo() twice, four lines apart, in its own chain. Neither duplicate does harm (the registrations are idempotent in practice); both are diagnostic. A seventeen-step fluent chain is code that has stopped being read — it is scanned, pattern-matched against the sibling service it was copied from, and extended at whichever end the cursor happened to be. Duplicates slip in because no human parses the middle.

And look two lines above the first AddRedis: .AddMongo(), in Operations. The Convey.Persistence.MongoDB package is referenced in the csproj, the using is imported, the call is made — and no Operations application code touches Mongo at all. Its state lives in Redis; the Mongo registration is a connection to a database the service never queries. Three graveyard plots in one chain, in the estate's nervous system.

A discovery stack for the housebound

Six services — Deliveries, Vehicles, Identity, Customers, Parcels, Operations — register the full outbound-HTTP stack: .AddHttpClient().AddConsul().AddFabio(), a resilient typed client with retries, service discovery, and load-balanced routing. Here is the httpClient section those six carry in appsettings.json, quoted from Deliveries:

"httpClient": {
  "type": "fabio",
  "retries": 3,
  "services": {},
  "requestMasking": {
    "enabled": true,
    "maskTemplate": "*****"
  }
}

"services": {} — a discovery-aware, retrying, masking HTTP client configured to call no one. The estate's actual IHttpClient consumers live in exactly four services: Orders (three clients), Availability, Pricing, and OrderMaker. The other six inherited the stack from the template and will retry three times against an empty routing table forever.

To be fair, the registration is not uniformly dead: AddConsul also registers the service into Consul's catalogue, so in the Docker profile the inbound half — Fabio routing traffic to the service — is live infrastructure. The graveyard is specifically the client half: retries, load-balancing policy, request masking, and a fabio client type, provisioned in services that never make an outbound call. The same nuance applies to UseVault(), which every service calls: real Vault machinery, exercised only in the one local profile that runs a dev-mode Vault, riding along everywhere else. (The token in those committed appsettings is the published sample's placeholder — the pattern of a committed secret-manager token is the finding; the value is beside the point.)

Referenced, registered, and never wired

The subtlest genre in the graveyard is the capability that exists at every layer except the one that matters. OrderMaker's csproj:

<PackageReference Include="Chronicle_" Version="3.2.1" />
<PackageReference Include="Convey.MessageBrokers.Outbox" Version="0.4.*" />
<PackageReference Include="Convey.Persistence.Redis" Version="0.4.*" />

The outbox package is referenced — and AddInfrastructure never calls AddMessageOutbox, so the saga's publishes go through raw IBusPublisher with no retry ledger; the package ships in the image and the capability never comes into existence at runtime. Redis is referenced and registered — and Chronicle is added bare, so saga state lives in an in-memory dictionary while a perfectly good Redis connection idles beside it. Part 9 priced what both absences cost the orchestration; the graveyard note is that neither absence is visible from the csproj, the config, or the composition root. You have to trace the wiring to discover the difference between referenced, registered, and used — three words that a dependency audit must refuse to treat as synonyms.

The same genre, smaller: IAppContext — the request-identity abstraction — is registered in Deliveries' and Vehicles' containers (Extensions.cs:53-54 in each) and injected by no handler in either service. It is live in Availability and Orders, where handlers do ownership checks; in the CRUD services it is speculative template baggage, resolved by nobody.

Drift by construction

Every headstone so far is a dependency the estate has and does not use. The last genre inverts the sign: a discipline the estate needs and does not have. Every one of the ~20 Convey.* package references in every service floats:

<PackageReference Include="Convey" Version="0.4.*" />
<PackageReference Include="Convey.CQRS.Commands" Version="0.4.*" />
<PackageReference Include="Convey.MessageBrokers.RabbitMQ" Version="0.4.*" />

0.4.* is a wildcard, and the estate never pins it. Ten repositories restore whatever 0.4.x is newest on their individual build day, which means two consequences by construction: no build is reproducible, and any two repos can silently skew against each other — the coherence of the estate is a hope expressed in a version range. What the estate does pin is a short and telling list: Chronicle_ at 3.2.1 (the trailing underscore is a genuine NuGet name-squat workaround, not a typo), Microsoft.Extensions.Logging at 3.1.3 — in seven Application-layer csprojs, the one dependency every service agreed to pin — and Operations' SignalR pair: Microsoft.AspNetCore.SignalR 1.1.0 with Microsoft.AspNetCore.SignalR.Redis 1.1.5. Those SignalR ids are themselves headstones: they are the 2.x-era package names, deprecated once SignalR moved into the shared framework in 3.0 and the Redis backplane moved to ...StackExchangeRedis. The packages still restore, the hub still runs, and the ids date the estate like a postmark.

The postmarks are everywhere once you look. netcoreapp3.1 in every csproj — end-of-life December 2022. A .travis.yml in every repo and travis-ci.org badges in every README — pointing at a CI service that no longer exists in that form, fronting pipelines of which nine ran dotnet test against zero test projects anyway (part 5 totalled that inversion). And in ten appsettings files, the Consul address is docker.for.win.localhost — a Docker-for-Windows host alias that dates the estate as precisely as a tree ring, and quietly documents which operating system the authors developed on.

How graveyards grow

Nothing above is a bug, exactly. Nothing above was decided, either — and that is the actual finding. Each plot was purchased the same way: a new service began life as a copy of the previous service's composition root, csproj, and appsettings, and nothing was ever deleted, because deletion requires the confidence that the thing is unused, and confidence requires reading — which seventeen-step chains and 190-line config files actively discourage. The framework's per-package granularity (one AddX per capability, one config section per package) makes assembly beautifully explicit and disassembly nobody's job.

What I would prescribe, for any estate consuming a chassis this way:

  • Treat the composition root as code, not incantation. It gets read in review, line by line, like a handler. Duplicate registrations are the canary; if AddRedis can appear twice without anyone noticing, anything can.
  • Audit registrations against injections periodically. The whole inventory above fell out of an afternoon of grep: registrations on one list, resolved interfaces on another, diff. Twenty minutes per service.
  • A dependency you register is a dependency you operate. If it is in the container, it is in the config, the image, the monitoring surface, and the upgrade plan. Registering “in case” is not free; it is a standing cost with no return.
  • Pin what you depend on, or say out loud that you have decided not to. 0.4.* across ten repos is not a versioning policy; it is the absence of one, wearing policy's syntax.

Every unused registration is a small lie the service tells its operators — a capability advertised in config and images that does not exist in behaviour. One lie is trivia. The estate's full set — eight idle Redis connections, six housebound HTTP stacks, an unwired outbox, a Mongo client with no queries, and a version range where a version should be — is a measurable tax, paid monthly, on the decision to distribute boilerplate by copy-paste.

Whether that decision was wrong is a bigger question than a graveyard tour can settle, because the alternative — a shared kernel — is the thing this framework's own history warns against. Settling it takes a hash function and the whole arc's ledger: next, the retrospective — the price of no shared kernel.