Josh Ashton

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:

XML
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />

Then another project has:

XML
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />

Then another is somehow still on:

XML
<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:

Bash
dotnet new packagesprops

That creates a Directory.Packages.props file for you.

You’ll end up with something along these lines:

Directory.Packages.props XML
<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:

MyApi.csproj XML
<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:

Text
error NU1008: Projects that use central package version management should not define the version on the PackageReference items but on the PackageVersion items

Which is actually helpful. It means you can’t half-migrate and forget about it.

So the process is:

  1. Create Directory.Packages.props at the root.
  2. For every package used across the solution, add a PackageVersion entry.
  3. Remove the Version attribute from every PackageReference.
  4. 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.

LegacyProject.csproj XML
<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:

Directory.Packages.props XML
<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:

Text
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:

nuget.config XML
<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:

Bash
dotnet package add Microsoft.EntityFrameworkCore --project MyApi.csproj

If 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:

Bash
dotnet package add Microsoft.EntityFrameworkCore \
  --version 9.0.10 \
  --project MyApi.csproj

Again, 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:

Bash
dotnet add MyApi.csproj package Microsoft.EntityFrameworkCore

The 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:

Bash
dotnet package list --outdated

That shows the current, resolved and latest available versions.

On older SDKs, the equivalent syntax is:

Bash
dotnet list package --outdated

So 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:

Directory.Packages.props XML
<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:

Bash
dotnet package add Microsoft.EntityFrameworkCore \
  --version 9.0.10 \
  --project MyApi.csproj

Because 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:

Bash
dotnet package update Microsoft.EntityFrameworkCore

Or, to update packages referenced by the selected project:

Bash
dotnet package update

There’s even:

Bash
dotnet package update --vulnerable

which 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:

XML
<TreatWarningsAsErrors Condition="'$(SkipWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>

Then, on Linux or macOS:

Bash
SkipWarningsAsErrors=true dotnet package update --vulnerable

On 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:

Dockerfile Dockerfile
COPY Directory.Packages.props .
COPY MyApi/MyApi.csproj MyApi/

RUN dotnet restore MyApi/MyApi.csproj

COPY . .

RUN dotnet publish MyApi/MyApi.csproj -c Release

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