Transitive NuGet dependencies: .NET Core’s got your back

The other day, a colleague and I were looking into an issue with one of our solutions’ build pipeline. The main project was a ‘classic’ MSBuild project with a transitive NuGet dependency through another classic MSBuild project. The transitive dependency wasn’t being copied into the main project’s output directory, even though the build was succeeding.

Note: I’ve fictionalized package names in this blog, because the problem has nothing to do with the authors, and I wouldn’t want their names to be associated with this problem.

What’s a transitive NuGet dependency?

Let’s first start with a slightly simpler question: what is a transitive dependency?

A transitive dependency is an indirect dependency; you depend on something which itself has another dependency that you don’t depend on.

Let’s say you have projects B, C, and D (I’ll explain why I don’t start with ‘A’ in a bit). When project B has a reference to project C and project C has a reference to project D, but B does not have a reference to D, then D is a transitive dependency of B.

B -> C -> D

Now, when you create this exact project structure, there will not be an issue; MSBuild will build project D, then project C, and finally project B. The output directory for project B will contain B.dll, C.dll, and D.dll. So far, so good.

Now let’s say that C is a NuGet package, and so is D. The picture changes slightly, because in when you install a package in a classic MSBuild project, its dependencies will also be directly referenced by B. So B has a direct reference to D. However, there is not a single line of code that actually mentions D, or any of its symbols. If you squint, it is still a transitive dependency.

B -> C -> D

What’s the problem?

So far, nothing. When you compile the project, the output directory will still contain B.dll, C.dll, and D.dll, just like when they were still projects.

What I’ve left out, so far, is that, actually, there is another layer of referencing. See, in our situation, B is actually the persistence layer of that particular solution. C is a wrapper library that we’d written around D, which is a commonly used NuGet package. The persistence layer is being referenced by the ‘host’ project, which in our case is a web API. Let’s call that A. Now you see why I started with B.

A -> B -> C -> D

Project A has a reference to B, but does not have any reference to the dependencies of B. The problem was that, although C.dll was copied to the output directory for A, D.dll was not.

Version hell

Initially, we thought it was ‘just a bug’ in MSBuild. We’d written a hacky MSBuild task to just recursively copy all of the dependencies from all the projects referenced by the main project. I refused to believe this was a scenario that MSBuild ‘just didn’t support,’ so I started digging.

I built our solution with the verbosity set to ‘Detailed,’ and then I came across a couple of interesting messages. Some details left out for brevity.

Project "C:\Project\Project.sln" (1) is building "C:\Project\B.csproj" (4:3) on node 1 (default targets).

  There was a conflict between "D, Version=1.50.2.0, Culture=neutral, PublicKeyToken=null" and "D, Version=1.50.0.0, Culture=neutral, PublicKeyToken=null".
      "D, Version=1.50.2.0, Culture=neutral, PublicKeyToken=null" was chosen because it was primary and "D, Version=1.50.0.0, Culture=neutral, PublicKeyToken=null" was not.
      References which depend on "D, Version=1.50.2.0, Culture=neutral, PublicKeyToken=null" [C:\Project\packages\D.1.50.2\lib\net451\D.dll].
          C:\Project\packages\D.1.50.2\lib\net451\D.dll
            Project file item includes which caused reference "C:\Project\packages\D.1.50.2\lib\net451\D.dll".
              D, Version=1.50.2.0, Culture=neutral, processorArchitecture=MSIL
      References which depend on "D, Version=1.50.0.0, Culture=neutral, PublicKeyToken=null" [].
          C:\Project\packages\C.1.0.1\lib\net45\C.dll
            Project file item includes which caused reference "C:\Project\packages\C.1.0.1\lib\net45\C.dll".
              C, Version=1.0.1.0, Culture=neutral, processorArchitecture=MSIL
...              
Project "C:\Project\Project.sln" (1) is building "C:\Project\A.csproj" (3) on node 1 (default targets).  
...
  Dependency "D, Version=1.50.0.0, Culture=neutral, PublicKeyToken=null".
      Could not resolve this reference. Could not locate the assembly "D, Version=1.50.0.0, Culture=neutral, PublicKeyToken=null". Check to make sure the assembly exists on disk. If this reference is required by your code, you may get compilation errors.
          For SearchPath "C:\Project\B\bin\Release".
          Considered "C:\Project\B\bin\Release\D.winmd", but it didn't exist.
          Considered "C:\Project\B\bin\Release\D.dll",
              but its name "D, Version=1.50.2.0, Culture=neutral, PublicKeyToken=null"
              didn't match the expected name "D, Version=1.50.0.0, Culture=neutral, PublicKeyToken=null".
      Required by "C:\Project\B\bin\Release\B.dll".

The plot thickens. What you see here is that during the build of project B, MSBuild finds a conflict between two versions of D, version 1.50.0.0 and version 1.50.2.0. It ends up picking 1.50.2.0, because that is a direct dependency. It then lists in detail that project B depends directly on version 1.50.2.0 of package D, while package C depends on version 1.50.0.0 of package D.

A -> B -> (1.50.2.0) C -> (1.50.0.0)

This makes sense; C is a utilities library, and for libraries you usually want to depend on the lowest possible version, to maximize compatibility. However, when you’re writing application code, you typically want to depend on the highest possible version, because that might have fewer bugs. This is pretty much what happened; we’d already added a reference to version 1.50.2.0 of package D to project B, and only later did we add a reference to package C.

A few things stand out from the MSBuild output:

  • MSBuild is saying that package D is what caused the reference to ... itself?
  • If this reference is required by your code, you may get compilation errors. Yet the build still completes successfully?
  • It’s incorrectly stating that, when building project A, B.dll is what depends on version 1.50.0.0 of package D. It is actually C.dll that depends on that version.

These are not major issues (except for the build completing succesfully), but they might slow down an investigation into why something is not working.

So, to recap: because project B has a direct reference to package D, version 1.50.2.0 of that package gets copied to the output directory for project B. When building project A, MSBuild is not able to resolve the reference, because it only sees that package C depends on version 1.50.0.0 of package D. It does irk me that this kind of stuff does not fail the build.

However, at run-time this will cause exceptions; the runtime will load C.dll and see that it has a reference to version 1.50.0.0 of D.dll. It will attempt to load D.dll, but this is not available, so .NET will throw a File​Not​Found​Exception.

Binding redirects

The ‘proper’ way of fixing this issue is to create a binding redirect in the app.config or web.config for project A. This is a pretty cumbersome process, as you have to figure out the exact assembly version of the dependency, which might be different from the package version, and in a lot of cases you also have to copy and paste the public key token. In short, this is not a very pleasant experience. Unfortunately, for classic MSBuild projects, it is the only real solution.

<dependentAssembly>  
  <assemblyIdentity name="D" culture="neutral" publicKeyToken="1234567890abcdef" />
  <bindingRedirect oldVersion="0.0.0.0-1.50.2.0" newVersion="1.50.2.0" />
</dependentAssembly>  

Tree-shaking

Shake that thang But wait, we’re not done, yet. I made a remark earlier, which is significant to the problem.

However, there is not a single line of code that references D.

If you do add a line of code that references that dependency, then the binding redirect does not appear to be necessary. Apparently, MSBuild does some form of tree-shaking to figure out which dependencies are really required and which ones are only there to satisfy indirect dependencies.

When project B directly uses package D, then MSBuild sees it as a required dependency, and therefore uses the version that project B depends on. Otherwise, it uses the version that package C depends on (even though it reports it incorrectly).

.NET Core to the rescue

Fortunately, there is an even easier solution: use the .NET Core build system (or ‘the new .csproj format, or dotnet, or whatever you want to call it).

The new .csproj format only requires you to specify the direct reference. In our case, that would be package C.

<PackageReference Include="C" Version="1.0.1" />  

When the project is built, .NET Core will first restore all packages, and figure out that package D is a dependency of package C. The big difference is that it doesn’t require project B to have a direct reference to package D.

If you want to upgrade to a different version of package D, you can do this by simply adding a direct reference to it to project B and specifying the version you need. Direct dependencies take precedence over indirect ones, and when packages are restored, .NET Core will resolve the versions correctly.

<PackageReference Include="C" Version="1.0.1" />  
<PackageReference Include="D" Version="1.50.2.0" />  

From my experiments, it did not seem to be necessary to have any binding redirects. However, when that does turn out to be necessary, you can simply enable some properties to instruct .NET Core to generate the binding redirects for you automatically!

<PropertyGroup>  
  <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
  <GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
</PropertyGroup>  

Conclusion

Even with NuGet (or maybe especially with NuGet), version hell is still alive and kicking. Do yourself a favor and upgrade to the .NET Core build pipeline. It comes with so many other benefits (such as not having to update your .csproj when you add or remove a file), and this is just an added bonus.