The premise
!A Slack message from a teammate: "matt, quick one when you're free. cromulent.cryptography is throwing in payments-api in prod after the dependabot merge last night. tests passed locally, CI was green, no idea what we changed. seeing this 👇" followed by a System.MissingMethodException stack trace for Cromulent.Cryptography.Helpers.IsActuallySafe.
Some version of this message lands in my DMs about once a fortnight. The package name changes, the exception sometimes changes, the shape doesn't. A green Dependabot PR went in overnight, someone senior approved it, and production now has a stack trace nobody recognises.
Monorepos in .NET are mostly a good idea, and central package management has improved. The underlying resolution algorithm has not, and it does something that surprises people more often than it should. The natural comparison is npm, which gets resolution right and pays for it with a supply chain that is roughly on fire.
Both ecosystems are broken in different ways. The choice is closer to "pick your poison" than anyone is comfortable admitting.
NuGet's failure mode
Two rules, each reasonable in isolation, combine into a quiet problem at scale.
Lowest applicable version. A reference of >= 2.1.0 resolves to 2.1.0, not the latest 2.x. The range is a floor, not a target.
Nearest wins. When the same package appears at different depths with different versions, NuGet picks the one closest to the consuming project. Which depth is "nearest" changes silently when you add or remove a reference.
Combine those in a monorepo with a few hundred shared packages and the version of any given transitive dependency is an emergent property of the graph. Move a reference. Delete one. Add an internal package. The resolved version can shift, with no diff visible in any csproj.
A concrete example
Three internal projects at fictional British startup BadgerSoft. They all eventually depend on Cromulent.Cryptography: third-party, unaudited, popular.
<!-- BadgerSoft.Core.csproj -->
<PackageReference Include="Cromulent.Cryptography" Version="3.4.0" />
<!-- BadgerSoft.Auth.csproj -->
<ProjectReference Include="..\BadgerSoft.Core\BadgerSoft.Core.csproj" />
<PackageReference Include="Cromulent.Cryptography" Version="3.6.0" />
<!-- BadgerSoft.Web.csproj -->
<ProjectReference Include="..\BadgerSoft.Auth\BadgerSoft.Auth.csproj" />
BadgerSoft.Web resolves to Cromulent.Cryptography 3.6.0 because the direct reference in Auth wins. Fine.
Three sprints later, a new starter (Tarquin) notices the EncryptThing() API that Auth needed in 3.6 isn't called anywhere anymore. He removes the direct reference, ships a PR titled "chore: tidy up unused dependency", and it gets merged.
BadgerSoft.Web now resolves Cromulent.Cryptography through Core, which is still on 3.4.0. The version in production has dropped two minor versions, including a CVE fix the security team mandated last quarter. Nothing in the diff hints at it; the package downgrade happens at restore time. Dependabot, which only watches for things going up, is silent.
Six weeks later production throws the exception in the screenshot. Two services in the same process load two different versions of the same assembly and one loses the coin flip.
Not a contrived case. This is the standard failure mode of NuGet in any non-trivial monorepo.
And then the full ecosystem update
The inverse problem hit us a few months later, and was worse because nobody had been careless.
Cromulent.Cryptography shipped a 5.0.0 with one API rename and a deprecation removed. Dependabot raised the PR. Small diff, readable changelog, green CI, two senior approvals.
Production at 16:00 Tuesday, broken by 17:30:
System.MissingMethodException:
Method not found: 'Cromulent.Cryptography.Envelope
Cromulent.Cryptography.Sealer.Seal(System.Byte[], System.String)'.
We had bumped Cromulent.Cryptography to 5.0.0. We had not (and could not) bump the five sibling packages (Cromulent.Cryptography.AspNetCore, .Sql, .Hosting and so on) because they hadn't released 5.x-compatible versions yet. The runtime ended up with both contracts loaded against one assembly.
You can't fix this by reverting the one bump; 4.x is end-of-life. You can't fix it by bumping the one package; that's what caused it. The only fix is a full ecosystem update: every related package, in every csproj, in every solution, bumped to 5.x-compatible versions in lockstep.
Two of the sibling packages we needed didn't have 5.x releases yet. We reverted and stayed on 4.x for a quarter.
The lesson is that in tightly-coupled vendor families, a major bump is a project, not a chore. CPM doesn't help. Lockfiles tell you afterwards. The resolution algorithm doesn't care.
The workarounds, briefly
Three official answers. None of them is a complete fix.
- Central Package Management (
Directory.Packages.props) pins every direct package version repo-wide. Doesn't pin transitives unless you also enable CentralPackageTransitivePinningEnabled.
packages.lock.json is the real lockfile. It works, it's opt-in, and adoption is roughly homeopathic. Of the .NET monorepos I've audited, fewer than one in ten ship one.
- Transitive pinning is the closest to a fix and a meaningful blast radius the first time you flip it on a mature repo. Worth the pain.
Use all three or accept that you're flying blind.
npm gets resolution right
npm allows two versions of the same package to coexist by nesting them under whichever consumer needs which version. The diamond problem doesn't exist. package-lock.json is on by default, locks every transitive version, and npm ci will refuse to install anything that disagrees.
This is the model .NET wishes it had.
And then you read the CVEs
The cost is a supply chain that is the most-attacked in software. A fresh React + Vite project pulls down hundreds of packages, most maintained by one person on the side, a meaningful share without 2FA on their npm account. event-stream, ua-parser-js, colors/faker, every couple of months a new one. postinstall scripts, typo-squats, compromised maintainer credentials.
NuGet has been quieter: signed packages, no install-time script execution, fewer-but-larger maintainers. Compromises happen but you can usually name them.
So the trade is:
- NuGet: small graph, signed packages, but resolution will quietly land you on the wrong version of something you don't know you depend on.
- npm: correct resolution, real lockfile, but you are one compromised utility package away from credentials in production.
There is no third option.
What I do
For .NET:
- CPM + transitive pinning +
packages.lock.json from day one.
dotnet list package --include-transitive --vulnerable in CI. Fail the build above moderate.
- Treat any major bump of a coupled package family as a planned upgrade, not a Dependabot PR.
For npm:
npm ci only, never npm install in CI.
- Exact pins in
package.json. No carets, no tildes.
npm audit on every PR; block high or critical.
--ignore-scripts and overrides used liberally.
- Watch maintainers of your most-depended-on packages. Sudden handoffs are an early warning, not a footnote.
None of this makes either ecosystem safe. It moves both from "actively dangerous" to "tractable with vigilance".
Closing
NuGet picks a version for you and doesn't tell you. npm tells you all of them and lets the attackers in.
Use both. Instrument both. Treat dependency management as the load-bearing engineering problem it is, not the chore you delegate. The cost of getting it right is real. The cost of getting it wrong is silent until it's production's.
Pick your poison. Pay attention to which one you swallowed.