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 .csproj files.
  • 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)

  1. Enable CPM
    At the repo root, add Directory.Packages.props and set ManagePackageVersionsCentrally to true. You can start with:

    dotnet new packagesprops
  2. Inventory and move versions
    Copy every Version="x.y.z" from your .csproj PackageReferences into <PackageVersion /> entries in Directory.Packages.props. Then remove the Version attributes from the projects.

  3. Turn on transitive pinning (optional but recommended)
    Add <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> to keep control over transitives.

  4. Adopt lock files
    Add <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> (and consider <RestoreLockedMode>true</RestoreLockedMode> in CI). Check in packages.lock.json to source control to make dependency graphs deterministic and auditable.

  5. Secure package sources with mapping
    Configure nuget.config with Package Source Mapping so only specific packages can come from specific sources.

  6. 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

💡 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.