Central Package Management with Directory.Packages.props: An Architecture Note
An architecture note on NuGet Central Package Management: when one Directory.Packages.props is the right design, how to make it a trust boundary with source mapping and locked-mode restore, and the signals that say to split it.
18 Nov 2025, 09:54 UTC

Once a repository grows past a handful of projects, package versions drift. One service pins Serilog 3.0, another still references 2.12, and nobody can answer "which version of Newtonsoft.Json do we actually ship?" without grepping every csproj. Central Package Management (CPM) is NuGet's built-in answer: one file owns every direct dependency version, and project files declare only which packages they need, never which version.
The useful takeaway: CPM is cheap to adopt, but it only pays off if you treat the central file as a trust boundary and wire up locked-mode restore in CI. Without those two pieces, you've just moved the drift somewhere less visible.
Requirements before adopting CPM
CPM is a good fit when all of these hold:
- Multiple projects in one repository (or one build unit) share most dependencies.
- Those projects ship or deploy together, so a single version per package is semantically correct.
- You want one reviewable location for "what third-party code enters this build."
You need a recent .NET SDK / NuGet version — CPM arrived with NuGet 6.2 / .NET SDK 6.0.300-era tooling, and related features like package source mapping require similarly modern toolchains. Verify your SDK with dotnet --version before assuming a feature exists; older toolchains can silently ignore newer NuGet.config settings.
The smallest suitable design
Start with exactly one Directory.Packages.props at the repository root. MSBuild imports the nearest such file walking up the directory tree from each project, so a single root file covers everything by default.
<!-- Directory.Packages.props at repo root -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog" Version="4.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
</ItemGroup>
</Project>
Project files then drop their Version attributes entirely:
<PackageReference Include="Serilog" />
Two details matter here. First, without ManagePackageVersionsCentrally set to true, the file is silently ignored — this is the most common misconfiguration and produces confusing "version not specified" errors rather than a clear message. Second, if you already set package versions in Directory.Build.props, remove them; import-order interactions between the two files are a recurring source of "why did my bump not take effect" debugging.
Trust and data boundaries
CPM centralizes the decision of which third-party code enters the build. That makes Directory.Packages.props a natural review gate: require code-owner review on changes to it, the same way you would for CI pipeline definitions.
Pair it with package source mapping in NuGet.config. Source mapping pins package ID patterns to specific feeds, which mitigates dependency-confusion attacks (where an attacker publishes a same-named package to a public feed hoping your restore picks it up instead of your internal one):
<!-- NuGet.config -->
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="internal-feed">
<package pattern="Contoso.*" />
</packageSource>
</packageSourceMapping>
With this, Contoso.* packages can only ever come from your internal feed, even if a typosquat exists on nuget.org. Note the version caveat: older NuGet clients ignore source mapping silently, so confirm behavior on your build agents, not just your dev machine.
Operational checks that make it deterministic
Centralization without lock files still allows per-machine resolution differences for transitive dependencies. The standard pairing is:
- Generate lock files: run
dotnet restore --force-evaluatelocally (developer permissions, repo root) to produce or refreshpackages.lock.jsonper project, and commit them. - In CI, run
dotnet restore --locked-mode. Any drift between the central file and the lock file fails the build instead of silently resolving something new. - After any central bump, regenerate lock files in the same commit and review the diff — this is where unexpected transitive upgrades surface.
Verification after restore: inspect obj/project.assets.json for a project and confirm the resolved version matches the central file, with no stray override in effect. For ongoing audit, dotnet list package --outdated and dotnet list package --vulnerable report against the centrally pinned versions, so one command covers the whole repo.
Failure modes to expect
- Leftover Version attributes. A project that still declares its own version conflicts with CPM and fails restore (NU1008-class errors; exact codes vary by SDK version). The fix is mechanical: delete the attribute and add a central entry.
- Transitive surprises after a bump. CPM manages direct references only. Transitive versions still resolve by NuGet's nearest-wins rule, so bumping one package can shift transitive versions across the graph. The lock-file diff is your early warning.
- Override creep.
VersionOverrideon a PackageReference lets one project deviate — legitimate for a targeted hotfix, but each override breaks the single-source guarantee. Track them; a rising count means the central file no longer reflects reality. - Transitive pinning side effects.
CentralPackageTransitivePinningEnabledpromotes transitive dependencies to the centrally declared versions repo-wide. It closes real security gaps but changes resolution semantics broadly — enable it deliberately and diff the lock files before merging.
When to change the design
Split into multiple Directory.Packages.props files (MSBuild picks the nearest one, so a nested file governs its subtree) when services in the repo genuinely ship on independent cadences and forcing one version creates coordination cost rather than removing it. Reconsider CPM's scope, not just its layout, when overrides proliferate or when teams routinely bypass the central file — those are signals the "one version per repo" assumption no longer holds, and no file layout will fix a wrong assumption.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.