79083334

Date: 2024-10-13 14:19:15
Score: 1
Natty:
Report link

It's been a minute! Only just stumbled upon this question while searching for something else, but since I'm kind of passionate about this topic and have argued for it in the past, and I definitely think this is worth hearing, let me explain why and when you should use this property. I want to preface this by saying, that I've had this conversation with quite a few other C# developers, including some of the C# compiler devs, and have found strong disagreement with my position. I hope that I can convince more people of my stance on the issue with this post.

First, just to clear up some confusion to your question (even though you probably resolved whatever issue it was that you were having in the three years since this was posted, but hey, the question was phrased in a generic fashion, so time should not be an issue):

Let's say that I have the following projects:

Project A

Project B - References Project A

Project C - References Project B

If I set true in Project C's .csproj, Project C will not compile since Project A is not explicitly references by Project C. If I do not set this DisableTransitiveProjectReferences flag, it will compile.

This is not generally true - it just means that Project C also depended on A, without referencing it directly - it relied on the transitive reference to A from B. If C didn't use anything from A, it wouldn't need a reference to it, and you could keep the DisableTransitiveProjectReference property.

Now, a small anecdote, and why I came to this question in the first place, to show one of the issues that arises from this behavior. I just wanted to add an instance of JsonSerializerOptions to my code, but I didn't know the specific name. In my head it was something with "Settings", so I started typing JsonSerializerSettings, and saw a suggestion for this specific class. I hit enter, thought I solved the problem. But I got an error about incompatible types. Turns out it included the wrong namespace, and what I really wanted was JsonSerializerOptions, not Settings. I wondered why there were two separate classes that seemed to do the same thing. Turns out one is the built in System.Text.Json version, which I wanted, the other is from Newtonsoft.Json. I was perplexed - I don't use Newtonsoft, and haven't ever used it in this project. Where did it come from? Nothing in my projects referenced it.

Turns out a certain NuGet package that we relied on referenced it. And suddenly, it was in our project! Easily accessible from everywhere, even though it's absolutely nothing we want to use. So now, the predictive abilities of the suggestion feature are diminished, because we polluted the available namespaces with things we never intended to and don't need. If a new developer joined, and didn't know we used System.Text.Json, they would type JsonSerializer and the suggestion shows both Newtonsoft.Json and System.Text.Json, because that name exists in both. But the serialization behavior is different! In the ideal case, it would try to pass an object from one library to something expecting an object of the other library, then the static analyzer would catch it (as in my case above). In the worst case, it sends differently formatted JSON strings to a receiver who can't handle them, or, even worse, handles them differently without erroring. This can be a nightmare to even notice, let alone debug.

There are several more issues with that behavior. And one of those is actually exactly your example. Imagine three projects, A, B, which references A, and C, which references B. Now, C uses features from A, without referencing it - why would anyone have added a reference, no one even knows that it doesn't reference A, since all the types from A have always been available to it. But now B is refactored, and it turns out, that it doesn't need the reference to A anymore. So now that dev removes the reference to A. Suddenly, project C no longer compiles, despite nothing in it having changed!

And this is the simplest case, imagine a project reference chain from A to Z, where A is referenced by B, which is referenced by C, and so on, and finally Z references Y, thus also including a reference to all previous projects. Now, imagine half of those projects use features from A. No one bothered to include A directly, and now B loses the reference to A. Half the projects don't compile anymore! Now you either have to figure out which one of them is the "lowest" in the reference chain, and make sure to reference A from that one, which is C in that case (and projects aren't usually this neatly ordered, figuring that out can be time consuming), and now you have created another weak link, capable of making the remaining projects not compile with a reference change. Alternatively, you have to add the reference to A to every other project that doesn't compile, which is really annoying to have to do all at once, and completely ruins the advantages of transitive references in the first place.

Another, and this time quite devious, issue arises when two projects in a reference chain share a namespace-qualified type name. Now, I know what you're gonna say "that shouldn't ever happen!" and you're right, it shouldn't! But it absolutely can, and often you can't do anything about it, especially in larger projects. Not only are different classes in different projects allowed to share the same namespace, they can also exist in the global namespace. And if that does happen, boy are you out of luck. Now you can't use either of the types, without aliasing the assembly, even if you don't need one of the assemblies at all, just because it was referenced transitively! Let's say you have the same structure as above, C, which references B, which references A. Let's say A and B both have class Foo defined, without a namespace (or the same namespace, whichever). Now in C, you want to use B's foo. How do you specify that? Again, you can't, without an assembly alias. Now you need to alias the B assembly, just to use Foo, because of a conflict with another assembly you don't need and never even wanted. And nowhere in A or B do you get an error or even a warning! Only when you try to use the type from B does the compiler get antsy.

Yet another reason to disable it, is to prevent leaking internal classes and mechanisms. Assume you have a low level project A that does some pretty complex things and needs to be accessed in a certain way. Handling it is complicated, but it is crucial to your program. To avoid having people on your team misuse it, you decide to write a more user-friendly abstraction on top of it, project B, which encapsulates project A's functionality, without exposing unsuspecting devs to its pitfalls. One of the comments on your question mentioned a project that handles database access, those come to mind, but there are more use-cases for this scenario. Now, other projects should not consume A directly - they should consume B. But if B reference A, all of A's low level complex mechanisms will be exposed to any project C that references B.

And the problem here is even deeper, because you cannot even disable C accessing A by just disabling transitive project references on either A or B - you have to disable them on C! The proper way to disable that with the current mechanism, is to edit the reference to A in B's project file, by giving it the PrivateAssets="All" attribute, and you have to do that for everything you don't want to leak externally. If you don't want to do that, that means, that every consumer of B needs to disable transitive project references, to not have access to A. And that has to be done habitually when you create a new project.

Now, if every programmer on the team is perfectly aware of each project's scope and purpose, that's not an issue, and that's often one of the arguments people in favor of this behavior bring to the discussion. And don't get me wrong, I'm all in favor of educating and educated developers, but this just increases the surface area for design problems, and even bugs. We have tooling for a reason, and if it can prevent issues from happening, why shouldn't it?

So with all this said - why does the feature even exist? What does it bring to the table? Well, if you ask me, not much. It saves you from having to add a reference to a project occasionally, which every C# IDE can do for you anyway. And it's not even consistent about when it saves you from having to do that. Sometimes you may still need to add a refernece - if nothing in your current reference list references it (or even if a project in there does reference it, but has the PrivateAssets="All" reference attribute set). Other times, you may not have to. This, so I'm told, is the advantage.

This entire debate has similarities with other situations. In C++ it's a common idiom to "include what you use", to prevent all issues that I just mentioned here. This idiom was developed over years of people having precisely these issues. And while it's easier to get such issues with included headers, compared to referenced projects, the same (and more!) issues still apply.

Tl;dr when should you disable it? If you ask me - always :) It's a pretty insignificant feature with only a slight argument in its favor, that is only partially applicable, and plenty of reasons against it.


Now, the careful reader may have noticed that my particular problem, from my earlier anecdote, is, although strongly related, actually slightly different from the issue presented here. While semantically the same, technically, I am not dealing with a project reference, but a package reference. And while we have the "luxury" of disabling this "feature" for referenced projects, there is currently no way of doing this for referenced packages. Sadly, I will have to live with Newtonsoft.Json being exposed to my project, because I use a NuGet package that happens to reference that. I will have to remind everyone who contributes code to it, to use the right JSON serializer, because two will be available to them at any point in the project's code, and there is little I can do about it.

And with package references, there is yet another issue to worry about. Imagine your project references package X, and it has a reference to package Y. Your project also needs Y, so you've just been using it from X, since you already had it handy, or maybe you just thought someone else had already included it in the project. Now you update X, and suddenly your project doesn't compile. X, apparently, removed the reference to Y in a newer version. Same scenario as above, essentially, except with another headache. Now you decided to add a reference to Y manually. But your project still doesn't compile! Turns out you relied on a specific version of Y, and Y's API changed since then. Now you have to dig out what the last version of X was that you used, and which version of Y it referenced, to manually include that specific version of Y. And this is, again, the best case - where you could statically tell that there was an issue. What if the version of Y you add as a reference compiles, but some of its behavior is different? Think of the JSON example before, what if the serialization behavior changed? What if there was a bug, that you accidentally relied on? What if there was a new bug introduced? Suddenly you start having issues with Y, despite having updated X!

There is a GitHub issue which advocates for adding a similar DisableTransitivePackageReferences project property, which has been happily ignored for four and a half years. While there was some discussion on it, there seems to be a general lack of interest in fixing this issue. Feel free to throw a like on there, if I was able to convince you and you think this is something worth having. Though personally, I would still much prefer, if the entire trasitive references feature was opt-in, rather than opt-out. Or, alternatively, just gone entirely.

P.S. Before comments start suggesting it, I know there are some unpleasant workarounds to my issue. I've read that GitHub thread in detail, so I'm aware of a number of options, but nothing that would work in a similar, clean and generic way to a DisableTransitivePackageReferences property. And this question is not about that.

Reasons:
  • Blacklisted phrase (1): How do you
  • Whitelisted phrase (-2): I solved
  • RegEx Blacklisted phrase (1): I want
  • RegEx Blacklisted phrase (2): But I got an error
  • Long answer (-1):
  • Has code block (-0.5):
  • Contains question mark (0.5):
Posted by: Stjepan Bakrac