The premise

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. All of them end up depending on Cromulent.Cryptography, which is third-party, unaudited and 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. Production has dropped two minor versions, including a CVE fix the security team mandated last quarter. The diff shows none of it, because the downgrade happens at restore time. Dependabot only watches for things going up, so it says nothing.
Six weeks later production throws the exception in the screenshot. Two projects in that process were built against different versions of the same assembly, only one of them gets loaded, and whichever one lost is now calling a method that isn't there.
This is not a contrived case. It's the standard failure mode of NuGet in any monorepo big enough to matter.
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.
Deployed at 16:00 on a 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.
Reverting the bump puts you back on a 4.x that's end-of-life, and bumping harder is what caused it. The only way out is a full ecosystem update, every related package, in every csproj, in every solution, moved to 5.x-compatible versions together.
Two of the siblings we needed had no 5.x release at all. We reverted and sat on 4.x for a quarter.
In a tightly-coupled vendor family, a major bump is a project rather than a chore. CPM won't help you, lockfiles only tell you afterwards, and the resolution algorithm has no opinion about any of it.
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, with signed packages, no install-time script execution and fewer, larger maintainers. Compromises happen, and you can usually name them.
So here's the trade. NuGet gives you a small graph and signed packages, and resolution that will quietly land you on the wrong version of something you didn't know you depended on. npm gives you correct resolution and a real lockfile, and leaves you one compromised utility package away from your credentials being somewhere else.
There is no third option.
What I do
On .NET, CPM with transitive pinning and packages.lock.json from day one. dotnet list package --include-transitive --vulnerable in CI, failing the build above moderate. And any major bump of a coupled package family gets planned as an upgrade rather than approved as a Dependabot PR.
On npm, npm ci and never npm install in CI. Exact pins in package.json, no carets and no tildes. npm audit on every PR, blocking on high or critical. --ignore-scripts and overrides used liberally. And keep half an eye on who maintains the packages you depend on most, because a sudden handoff is an early warning rather than a footnote.
None of that 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.