Last year I blogged about a way to handle NuGet package versions at the solution level for .NET SDK-based csproj project files (so those using <PackageReference /> entries to define their NuGet dependencies).

That approach worked reasonably well, but was entirely custom – as it simply relied on defining reusable MsBuild properties to handled the versions, which created a bit of overhead.

With MsBuild 15 and newer, you can actually do it in a much more elegant way. Let’s have a look.

Pinning packages

In order to centrally manage package versions, you can now leverage the Directory.Build.targets feature. Without going into too much detail (the linked document explains it well), Directory.Build.targets can be used to provide customizations to project files located under a certain directory (within a directory tree). This means that if you create such a file at the root of your solution, it would normally (unless you have a very unusual solution structure) be able to customize all the csproj files in your solution as they would exist in the child directories.

The usage is very simple. In your individual project files, when adding packages via <PackageReference /> you can skip the version altogether. For example, imagine the following 2 ASP.NET Core projects, both being part of a single solution:

<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>netcoreapp2.1</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.App" /> <PackageReference Include="WindowsAzure.Storage" /> <PackageReference Include="AutoMapper" /> </ItemGroup> </Project> 1 2 3 4 5 6 7 8 9 10 11 <Project Sdk = "Microsoft.NET.Sdk.Web" > <PropertyGroup> <TargetFramework> netcoreapp2.1 </TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include = "Microsoft.AspNetCore.App" /> <PackageReference Include = "WindowsAzure.Storage" /> <PackageReference Include = "AutoMapper" /> </ItemGroup> </Project>

And:

<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>netcoreapp2.1</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.App" /> <PackageReference Include="WindowsAzure.Storage" /> </ItemGroup> </Project> 1 2 3 4 5 6 7 8 9 10 <Project Sdk = "Microsoft.NET.Sdk.Web" > <PropertyGroup> <TargetFramework> netcoreapp2.1 </TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include = "Microsoft.AspNetCore.App" /> <PackageReference Include = "WindowsAzure.Storage" /> </ItemGroup> </Project>

You can then add a Directory.Build.targets at the root of the solution, and specify how each <PackageReference /> should be updated by MsBuild (pay attention to the Update instead of Include).

<Project> <ItemGroup> <PackageReference Update="Microsoft.AspNetCore.App" Version="2.1.2" /> <PackageReference Update="WindowsAzure.Storage" Version="9.2.0" /> <PackageReference Update="AutoMapper" Version="7.0.1" /> </ItemGroup> </Project> 1 2 3 4 5 6 7 <Project> <ItemGroup> <PackageReference Update = "Microsoft.AspNetCore.App" Version = "2.1.2" /> <PackageReference Update = "WindowsAzure.Storage" Version = "9.2.0" /> <PackageReference Update = "AutoMapper" Version = "7.0.1" /> </ItemGroup> </Project>

This means that all (in this case 2) our projects would now use the pinned versions, making dependency management across the solution much easier than ever before (unless you used Paket, but that’s a separate story).

The solution described above relies on a Directory.Build.targets located in the root folder of your repository / solution. However, in any of the child folders you can always create an extra Directory.Build.targets to further customize the process. This way you could for example have two separate top-level folders src and test and have independent package pinning definitions in each of them.

Finally, you can also use this feature to arbitrarily include a package definition into all of the csproj files. If we modify the Directory.Build.targets file in the following manner (pay attention to the Include instead of Update next to Newtonsoft.Json):

<Project> <ItemGroup> <PackageReference Update="Microsoft.AspNetCore.App" Version="2.1.2" /> <PackageReference Update="WindowsAzure.Storage" Version="9.2.0" /> <PackageReference Update="AutoMapper" Version="7.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> </ItemGroup> </Project> 1 2 3 4 5 6 7 8 <Project> <ItemGroup> <PackageReference Update = "Microsoft.AspNetCore.App" Version = "2.1.2" /> <PackageReference Update = "WindowsAzure.Storage" Version = "9.2.0" /> <PackageReference Update = "AutoMapper" Version = "7.0.1" /> <PackageReference Include = "Newtonsoft.Json" Version = "11.0.2" /> </ItemGroup> </Project>

This now means that aside from providing pinned versions to our projects, we are also injecting an 11.0.2 version of Newtonsoft.Json to every project file, even if the csproj doesn’t specify it as a reference.

Note that this feature is not supported by the NuGet UI in Visual Studio at the moment, so you will need to manage the packages by hand. According to David Kean, this functionality would likely be supported in VS in the future though.

Summary

If you want to control NuGet package versions in a central place, pin the versions to certain values and not have to worry about mismatches and conflicts between various dependencies across your solution, this approach should work really well for you.

To further illustrate things, I can point you to a sample solution – I used this pinning approach in my NDC Oslo 2018 demos – the code is here. And if you would like to see this approach in action in a real life project, we actually use it in OmniSharp, so you might find that interesting too.