Central Package Management in .NET: how did I miss this?
I’ve been working with .NET for a long time.
Long enough, apparently, to confidently manage dependencies across solutions while completely missing a feature designed to make managing dependencies across solutions easier.
Excellent.
I recently started using Central Package Management in .NET, and my first reaction was basically:
Why have I not been doing this already?
If you’ve got a solution with more than a couple of projects, there’s a decent chance you’ve seen something like this:
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />Then another project has:
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />Then another is somehow still on:
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />And suddenly your dependency strategy is less “carefully managed” and more “whatever happened at the time”.
Enter Directory.Packages.props
Central Package Management moves the package versions out of your individual project files and into one central place.
And, usefully, you don’t even have to create the file yourself.
From the root of your solution or repository:
dotnet new packagespropsThat creates a Directory.Packages.props file for you.
You’ll end up with something along these lines:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
</ItemGroup>
</Project>Your individual project files can then lose the versions:
<PackageReference Include="Microsoft.EntityFrameworkCore" />NuGet walks up the directory tree looking for Directory.Packages.props, so putting it at the root means the projects underneath it can share the same centrally managed versions.
One place controls the version. All the projects use it.
No hunting through twelve .csproj files wondering which one you forgot to update.
Very boring.
Very useful.
My favourite kind of engineering improvement.
Migrating an existing solution
Starting fresh is easy. Most of us aren’t starting fresh.
Most of us have the twelve .csproj files from earlier, each with its own opinion about which version of EF Core is correct.
The first thing to know is that dotnet new packagesprops doesn’t go and collect your existing versions. It gives you the file and switches CPM on. That’s it.
The second thing to know is that the moment CPM is on, any PackageReference that still has a Version attribute becomes a build error:
error NU1008: Projects that use central package version management should not define the version on the PackageReference items but on the PackageVersion itemsWhich is actually helpful. It means you can’t half-migrate and forget about it.
So the process is:
- Create
Directory.Packages.propsat the root. - For every package used across the solution, add a
PackageVersionentry. - Remove the
Versionattribute from everyPackageReference. - Build, and let NU1008 tell you what you missed.
Step 2 is where it gets interesting.
If three projects are on three different versions of the same package, you now have to pick one. CPM doesn’t create that decision. It just stops you avoiding it.
In practice, that usually means taking the highest version and checking the projects that were behind still build and pass their tests.
The escape hatch
The obvious question after “one version for everything” is:
What if one project genuinely needs a different version?
It happens. A legacy project that can’t move yet. A tool with an awkward dependency. A package that broke something in one place but not the others.
CPM has an escape hatch for exactly this: VersionOverride.
<PackageReference Include="Microsoft.EntityFrameworkCore" VersionOverride="8.0.7" />That project uses 8.0.7. Everything else carries on using the central version.
It’s useful.
It’s also exactly how you end up back where you started if you’re not careful.
If you’d rather nobody quietly sneaks an override in, you can turn the feature off in Directory.Packages.props:
<PropertyGroup>
<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>
</PropertyGroup>Then any VersionOverride becomes a restore error, and the exception has to be a conversation rather than a commit.
NU1507: the warning you’ll probably see straight away
If you’ve also got a private feed (Azure Artifacts, GitHub Packages, a company feed), you’ll probably see this as soon as you turn CPM on:
warning NU1507: There are 2 package sources defined in your configuration. When using Central Package Management, please map your package sources with package source mapping or specify a single package source.The reason is fairly sensible. With multiple feeds and no rules, NuGet will take a package from whichever source answers. If someone publishes a package with the same name as one of your internal ones on a public feed, you could end up restoring the wrong one.
The fix is package source mapping in your nuget.config:
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="MyCompany" value="https://pkgs.dev.azure.com/mycompany/_packaging/feed/nuget/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="MyCompany">
<package pattern="MyCompany.*" />
</packageSource>
</packageSourceMapping>
</configuration>Now your internal packages only ever come from your internal feed, and everything else comes from nuget.org.
It’s a warning about CPM, but really it’s a supply-chain fix you probably should have had anyway.
But do I have to maintain that file manually?
This was one of my first questions.
Because replacing “go through a load of .csproj files manually” with “go through one XML file manually” is certainly better, but it’s not exactly the automation revolution.
Thankfully, the CLI understands Central Package Management.
Adding packages
If I want to add a package to a project:
dotnet package add Microsoft.EntityFrameworkCore --project MyApi.csprojIf the project is using Central Package Management, the CLI adds the PackageReference to the project and adds or updates the appropriate PackageVersion inside Directory.Packages.props.
So you don’t have to go editing both files yourself.
If I want a specific version:
dotnet package add Microsoft.EntityFrameworkCore \
--version 9.0.10 \
--project MyApi.csprojAgain, the version ends up in the central props file rather than being embedded into the project.
For older SDKs you may be more familiar with the command written the other way around:
dotnet add MyApi.csproj package Microsoft.EntityFrameworkCoreThe newer dotnet package add form arrived with .NET 10.
Finding out what needs updating
You can also ask the CLI which packages are out of date:
dotnet package list --outdatedThat shows the current, resolved and latest available versions.
On older SDKs, the equivalent syntax is:
dotnet list package --outdatedSo rather than periodically opening NuGet and clicking around hoping something turns orange, you can make dependency checking part of your normal workflow or even your CI pipeline.
Updating packages
There are a few options here.
You can absolutely edit Directory.Packages.props manually:
<PackageVersion
Include="Microsoft.EntityFrameworkCore"
Version="9.0.10" />There’s nothing wrong with that, and for a small solution it’s probably the quickest thing in the world.
You can also use the package command again with a version:
dotnet package add Microsoft.EntityFrameworkCore \
--version 9.0.10 \
--project MyApi.csprojBecause the project is using CPM, NuGet updates the centrally managed version.
And if you’re using the .NET 10 SDK or later, there is now an actual update command:
dotnet package update Microsoft.EntityFrameworkCoreOr, to update packages referenced by the selected project:
dotnet package updateThere’s even:
dotnet package update --vulnerablewhich can attempt to move vulnerable packages onto versions without known vulnerabilities.
That was another small thing I liked about CPM once I started looking into it properly: the central file is still just simple XML if you want to edit it directly, but modern tooling understands it as well.
You’re not signing yourself up for some elaborate dependency management system.
You’re basically just giving NuGet a single source of truth.
A small catch if you use TreatWarningsAsErrors
If your projects have TreatWarningsAsErrors switched on (and plenty of good ones do), dotnet package update can trip over itself.
The update command does an implicit restore to check the resulting package graph is valid. --vulnerable relies on that restore too, because that’s how it finds NuGetAudit warnings.
With TWAE on, those restore warnings become errors. The restore fails, and the update never finishes.
Which is slightly ironic, given that the warnings are the whole reason you ran --vulnerable in the first place.
The workaround Microsoft suggests is to make TWAE conditional on an environment variable:
<TreatWarningsAsErrors Condition="'$(SkipWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>Then, on Linux or macOS:
SkipWarningsAsErrors=true dotnet package update --vulnerableOn Windows you set the environment variable, run the update, then unset it again as three separate steps.
Your normal builds stay strict. The update gets to finish.
Not elegant, but it works until NuGet gets a proper switch for it.
Why I actually started caring
The obvious benefit is consistency, but the part that made it click for me was thinking about builds and deployments.
Especially Docker.
A typical Dockerfile might copy project files first, restore dependencies, then copy the rest of the source:
COPY Directory.Packages.props .
COPY MyApi/MyApi.csproj MyApi/
RUN dotnet restore MyApi/MyApi.csproj
COPY . .
RUN dotnet publish MyApi/MyApi.csproj -c ReleaseBecause the package versions live in Directory.Packages.props, that file is now part of your dependency definition.
- Change some application code? Docker can potentially keep the cached restore layer.
- Change a package version? The restore layer gets invalidated and your packages are restored again.
Which is exactly what you want.
It also means your local machine, Docker build and CI/CD pipeline are all reading dependency versions from the same place.
Fewer opportunities for the classic:
Well, it works on my machine.
A phrase that has never once improved anyone’s day.
It also makes updates less painful
This gets even more useful when security updates appear.
Without central package management, updating a dependency in a large solution can mean searching through project files and checking everywhere it’s referenced.
With CPM, the dependency version lives in one predictable place.
That also plays nicely with dependency automation tools such as Dependabot or Renovate, because there’s a much clearer source of truth for package versions.
It’s a small thing, but small things become surprisingly important once a solution starts growing.
Is it groundbreaking?
No.
It doesn’t make your application faster.
It doesn’t involve AI.
There are no agents involved.
Nobody is going to stand up at a conference and dramatically announce that they moved NuGet versions into another XML file.
But I think that’s partly why I like it.
A lot of good software engineering is just removing unnecessary opportunities for things to go wrong.
Central Package Management gives you:
- Consistent dependency versions.
- Easier upgrades.
- Cleaner project files.
- Simpler security patching.
- More predictable builds.
- Tooling that can add and update packages without abandoning the central model.
And all it really costs you is one extra file.
The slightly embarrassing bit
The only problem is that this feature has existed for a while.
So after discovering it, I had the slightly humbling experience of realising that while I was carefully keeping package versions aligned manually, .NET had already solved the problem for me.
Still, that’s one of the things I enjoy about software development.
You can spend years working with a technology and still regularly discover features, patterns and tools that make you rethink how you’ve been doing things.
Sometimes that discovery is a new distributed architecture.
Sometimes it’s an XML file.
They can’t all be glamorous.