When your solution grows to dozens of projects, keeping NuGet versions consistent becomes noisy, error-prone, and a magnet for merge conflicts. Central Package Management (CPM) fixes that by moving versions to a single file while leaving references in project files. It's built into NuGet/.NET and officially documented.
Why I recommend CPM
Operational wins
- Single source of truth for versions. One place to upgrade, audit, and roll back. No more hunting through
.csprojfiles. - Cleaner PRs & fewer conflicts. Version churn moves to one file; feature branches don't fight over unrelated package bumps.
- Predictable transitive dependency behavior via transitive pinning (optional), so you decide the version even when it comes in transitively.
- Easier fleet upgrades (e.g., EF Core minor version across the solution) and faster CI reviews.
Security wins
- Deterministic, auditable dependency set when you pair CPM with NuGet lock files (
packages.lock.json). You can fail CI if the lock file is out of sync. - Reduced supply-chain risk by combining CPM with Package Source Mapping to restrict what packages can come from which feed (e.g., internal packages only from your private feed, public packages only from nuget.org).
- Aligns with NuGet's ongoing supply-chain security efforts (2FA, publisher trust, etc.).
Minimal setup (what to check in)
Create Directory.Packages.props at the solution root:
<Project>
<PropertyGroup>
<!-- Turn on Central Package Management -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- Optional: ensure your chosen versions win over transitive ones -->
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<!-- Recommended with CPM for reproducible builds -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- In CI you can add: <RestoreLockedMode>true</RestoreLockedMode> -->
</PropertyGroup>
<ItemGroup>
<!-- Popular, realistic packages -->
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.8.1" />
<PackageVersion Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageVersion Include="Polly" Version="8.4.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="8.0.8" />
</ItemGroup>
</Project>
In each project (.csproj), keep references without versions:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="FluentValidation.AspNetCore" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Polly" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" />
</ItemGroup>
</Project>
References stay local; versions live centrally.
Migration playbook (used on multi-project solutions)
-
Enable CPM
At the repo root, addDirectory.Packages.propsand setManagePackageVersionsCentrallytotrue. You can start with:dotnet new packagesprops -
Inventory and move versions
Copy everyVersion="x.y.z"from your.csprojPackageReferences into<PackageVersion />entries inDirectory.Packages.props. Then remove theVersionattributes from the projects. -
Turn on transitive pinning (optional but recommended)
Add<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>to keep control over transitives. -
Adopt lock files
Add<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>(and consider<RestoreLockedMode>true</RestoreLockedMode>in CI). Check inpackages.lock.jsonto source control to make dependency graphs deterministic and auditable. -
Secure package sources with mapping
Configurenuget.configwith Package Source Mapping so only specific packages can come from specific sources. -
Build & test
Run a full restore/build, resolve conflicts, update the lock file, and merge.
nuget.config: Package Source Mapping (security must-have)
Place nuget.config at the solution root:
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="contoso" value="https://pkgs.contoso.com/nuget/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="contoso">
<package pattern="Contoso.*" />
</packageSource>
</packageSourceMapping>
</configuration>
This prevents accidental restores of internal packages from public feeds (and vice-versa), shrinking the attack surface and making restores deterministic across machines/CI.
Lock files + CPM: the power duo
- Repeatable restores: lock files pin the entire dependency graph (including transitives), so restores reproduce exactly what CI validated.
- Security: NuGet records package hashes in the lock file—tampering triggers failures.
- Speed: repeated restores skip graph resolution.
Enable with <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> and consider <RestoreLockedMode>true</RestoreLockedMode> for CI.
Short PR diff (Before → After)
diff --git a/src/App/App.csproj b/src/App/App.csproj
index 1111111..2222222 100644
--- a/src/App/App.csproj
+++ b/src/App/App.csproj
@@ -6,12 +6,12 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
- <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
- <PackageReference Include="FluentValidation.AspNetCore" Version="11.8.1" />
- <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
+ <PackageReference Include="Serilog.AspNetCore" />
+ <PackageReference Include="Serilog.Sinks.Console" />
+ <PackageReference Include="FluentValidation.AspNetCore" />
+ <PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
</Project>
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..3333333
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,29 @@
+<Project>
+ <PropertyGroup>
+ <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
+ <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
+ <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
+ <PackageVersion Include="Serilog.Sinks.Console" Version="5.0.1" />
+ <PackageVersion Include="FluentValidation.AspNetCore" Version="11.8.1" />
+ <PackageVersion Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
+ <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.6.2" />
+ <PackageVersion Include="Polly" Version="8.4.1" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
+ <PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="8.0.8" />
+ </ItemGroup>
+</Project>
Optional: CI guardrails (YAML fragment)
# Example GitHub Actions fragment
- name: Restore in locked mode
run: dotnet restore --locked-mode
- name: Verify no drift in lock files
run: |
git diff --exit-code **/packages.lock.json || (echo "Lock file drift detected" && exit 1)
Sources & further reading
- Central Package Management (official docs) — setup, behaviors, and transitive pinning.
https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management - Introducing Central Package Management (.NET Blog).
https://devblogs.microsoft.com/nuget/introducing-central-package-management/ - Enable repeatable package restores using a lock file (NuGet docs).
https://learn.microsoft.com/en-us/nuget/concepts/lock-file - Package Source Mapping (NuGet docs).
https://learn.microsoft.com/en-us/nuget/consume-packages/package-source-mapping - NuGet 6.5 and later — ecosystem improvements around CPM and tooling.
https://devblogs.microsoft.com/nuget/announcing-nuget-6-5/
💡 Pro Tip: Start with a small solution to test CPM before rolling it out to your entire codebase. The migration is straightforward, but having a test run helps build confidence and iron out any project-specific quirks.
Key takeaways: Central Package Management transforms dependency management in multi-project .NET solutions from chaotic to controlled. Combined with lock files and package source mapping, it creates a robust, secure, and maintainable foundation for your .NET applications. The migration is straightforward, and the operational benefits are immediate.
💬 Comments & Reactions