Referencing code in .NET: NuGet PackageReference versus ProjectReference, version ranges and floating versions, transitive flow, PrivateAssets/IncludeAssets/ExcludeAssets, ReferenceOutputAssembly, and when to use each.
A PackageReference pulls a specific version of a NuGet package from a feed. A ProjectReference builds another project in the same repo and references its output. You use project references inside a codebase and package references across teams or repos that release separately. Both are transitive by default: whoever references your project also gets your dependencies. PrivateAssets="all" stops that for things consumers shouldn't inherit, such as analysers or Source Link. A plain Version="4.2.0" means "4.2.0 or higher". NuGet then picks the lowest version that satisfies every constraint, and the nearest declaration wins. dotnet list package --include-transitive shows the full graph.
"A
PackageReferencepulls a versioned NuGet package from a feed, while aProjectReferencebuilds another project in the same repo and references its output. So I choose by release boundary: project references for code that ships together, package references for third-party code and for libraries other teams release on their own cadence, often from a private company feed.Both are transitive by default: whoever references my project also gets my dependencies. A plain
Version="4.2.0"is a minimum, not an exact version;[4.2.0]is exact, and4.*floats to the newest match, so it needs a lock file to stay reproducible. NuGet picks the lowest version that satisfies every constraint, and the nearest declaration wins, so if my direct reference is lower than something deeper needs, I get NU1605. The mistake I see is suppressing NU1605 instead of raising the direct reference, which can surface asMissingMethodExceptionat run time. Once versions drift across many projects, I'd move to Central Package Management.To control what flows,
PrivateAssets="all"keeps build-time tools like analysers and Source Link out of my consumers' graph, andIncludeAssetsandExcludeAssetspick which parts of a package I use. On the project side,ReferenceOutputAssembly="false"gives me build order without referencing the DLL, for example for a code generator. I check what actually resolved withdotnet list package --include-transitive.Packaging buys independent releases but costs versioning, compatibility promises and upgrade lag, so I don't turn shared code into packages too early; when I do, I own its SemVer through
dotnet pack. Either way, I keep the graph intentional: direct references for what I use, private assets for tools, and lock files and audit for safety."
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<ProjectReference Include="..\Orders.Domain\Orders.Domain.csproj" />
</ItemGroup>
| PackageReference | ProjectReference | |
|---|---|---|
| Source | A NuGet feed, a fixed version | Source in the same repo, built with yours |
| Versioning | Explicit, SemVer | Always the current code |
| Build | Restored, not rebuilt | Built first (MSBuild orders the graph) |
| Debugging | Needs symbols/Source Link | Step straight into source |
| Best for | Third-party code, shared libraries across teams | Code you release together |
If Orders.Api references Orders.Domain, and Orders.Domain references Contoso.Abn, then Orders.Api can use Contoso.Abn without referencing it. This applies to both kinds of reference. Convenient, but it hides dependencies; reference a package directly if your code uses it directly.
Version="4.2.0" means >= 4.2.0. NuGet picks the lowest applicable version.[4.2.0]) and floating versions (4.*) exist; floating versions without a lock file make builds unrepeatable.| Metadata | Effect |
|---|---|
PrivateAssets="all" |
Use it yourself, but don't pass it to consumers (analysers, Source Link, Microsoft.EntityFrameworkCore.Design) |
IncludeAssets / ExcludeAssets |
Choose which assets to use: compile, runtime, build, buildTransitive, analyzers, contentfiles, native |
ReferenceOutputAssembly="false" (ProjectReference) |
Build the other project first but don't reference its DLL, for example a code generator or a tool |
A shared library referenced StyleCop.Analyzers without PrivateAssets="all". Every service that consumed the library suddenly got hundreds of style warnings, and the ones with TreatWarningsAsErrors failed. Adding PrivateAssets="all" in a patch release removed the analyser from consumers' graphs. Later, moving to Central Package Management let the team declare analysers once with GlobalPackageReference, which is private by default.
dotnet list package --vulnerable --include-transitive finds vulnerable transitive packages.Checks understanding of dependency flow, not just syntax.
Green flags: explains transitive flow, version resolution and NU1605; uses PrivateAssets correctly; links reference type to release boundaries.
Red flags: thinks Version is exact; doesn't know dependencies flow to consumers; makes every shared folder a NuGet package.
ProjectReference PackageReference
---------------- ----------------
Orders.Api.csproj Orders.Api.csproj
| builds first, same commit | restore downloads a fixed version
v v
Orders.Domain.csproj (source) ~/.nuget/packages/contoso.abn/1.4.0/
| lib/net10.0/Contoso.Abn.dll
v (already compiled, never rebuilt)
bin/.../Orders.Domain.dll
| Question | ProjectReference | PackageReference |
|---|---|---|
| Where does the code come from? | Source in the same repo | A NuGet feed |
| Which version? | Whatever is checked out | The version or range you declare |
| Built when you build? | Yes, in dependency order | No, restored |
| Change both sides in one pull request? | Yes | No: release the package, then update consumers |
| Debugging | Step into source directly | Needs symbols and Source Link |
| Best for | Code that ships together | Third-party code, and libraries shared across teams or repos with their own release cadence |
Orders.Api
|-- PackageReference Serilog.AspNetCore >= 9.0.0
| `-- Serilog >= 4.2.0 (transitive requirement)
|-- ProjectReference Orders.Domain
| |-- PackageReference Contoso.Abn >= 1.4.0 (flows up to Api)
| `-- PackageReference StyleCop.Analyzers (PrivateAssets=all) X does not flow
`-- PackageReference Serilog >= 4.3.0 (direct: nearest wins)
Resolved for Orders.Api (obj/project.assets.json):
Serilog.AspNetCore 9.0.0, Serilog 4.3.0, Contoso.Abn 1.4.0, no StyleCop
NuGet applies four rules:
| Rule | Meaning | Example |
|---|---|---|
| Lowest applicable version | For >= 4.2.0, take 4.2.0 if the feed has it, not the newest |
Predictable, but you don't get fixes unless you ask for them |
| Nearest wins | A declaration closer to the project beats one deeper in the graph | The direct Serilog >= 4.3.0 decides, not the transitive >= 4.2.0 |
| Cousin dependencies | Siblings at the same depth: pick the lowest version that satisfies all of them | >= 4.0.0 and >= 4.2.0 resolve to 4.2.0 |
| Floating versions | 4.* takes the highest matching version on the feed |
Changes over time unless a lock file pins it |
The results are written to obj/project.assets.json, which the build reads. Nothing in bin/ decides versions.
| Written | Meaning |
|---|---|
4.2.0 |
4.2.0 or higher (a minimum) |
[4.2.0] |
Exactly 4.2.0 |
[4.2.0,5.0.0) |
At least 4.2.0, below 5.0.0 |
(4.2.0,) |
Higher than 4.2.0 |
4.* |
Highest 4.x on the feed (floating) |
4.2.*-* |
Highest 4.2.x, including prereleases |
Exact and upper-bounded ranges look safe but cause conflicts when two packages need different exact versions. Prefer minimums, with lock files for reproducibility.
| Code | Meaning | Usual fix |
|---|---|---|
| NU1605 | Detected package downgrade: a direct reference is lower than a transitive requirement | Raise the direct reference (don't suppress it) |
| NU1107 | Version conflict: two dependencies need incompatible versions | Add a direct reference to a version both accept, or upgrade one dependency |
| NU1608 | A resolved version is outside a dependency's declared range | Upgrade the package that declared the range, or confirm compatibility |
| NU1603 | The requested version wasn't found, a higher one was used | Fix the version number or the feed |
| NU1101 / NU1102 | Package or version not found on any source | Check the feed, source mapping and authentication |
| NU1901 to NU1904 | A resolved package has a known vulnerability (NuGet Audit) | Upgrade it; for transitive ones, add a direct or pinned reference to a fixed version |
A package is made of assets:
| Asset | Folder in the package | Used for |
|---|---|---|
compile |
lib/, ref/ |
Types you compile against |
runtime |
lib/, runtimes/ |
Assemblies copied to the output |
native |
runtimes/<rid>/native/ |
Native libraries |
build |
build/ |
.props and .targets imported into the project that references the package directly |
buildTransitive |
buildTransitive/ |
.props and .targets that also apply to projects further up the graph |
analyzers |
analyzers/ |
Roslyn analysers and source generators |
contentFiles |
contentFiles/ |
Source or content files added to the project |
Three metadata values control them:
| Metadata | Default | Effect |
|---|---|---|
IncludeAssets |
all |
Which assets this project uses |
ExcludeAssets |
none |
Assets this project doesn't use |
PrivateAssets |
contentfiles;analyzers;build |
Assets that don't flow to projects referencing this one |
So by default the package itself, its compile and runtime assets and its buildTransitive files flow to consumers, while its build, analyzers and contentFiles stay private. The dependency still appears in consumers' graphs and in your package's .nuspec. Setting PrivateAssets="all" removes it entirely, which is what you want for anything used only at build time:
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" PrivateAssets="all" />
Packages marked as development dependencies (developmentDependency in their .nuspec) get PrivateAssets="all" automatically when you add them with dotnet add package or the IDE.
| Metadata | Effect | Typical use |
|---|---|---|
ReferenceOutputAssembly="false" |
Build the project first, but don't reference its DLL | A code generator or tool that must exist before this build |
OutputItemType="Analyzer" (with ReferenceOutputAssembly="false") |
Load the project's output as an analyser | A source generator in the same repo |
Private="false" |
Don't copy the referenced DLL to the output | Plug-in hosts that load from elsewhere |
PrivateAssets="all" |
Don't flow this project to consumers | A test helper or build-only project |
SetTargetFramework="TargetFramework=net10.0" |
Pick a specific target of a multi-targeted project | Rare; usually NuGet's nearest-match logic is enough |
Project references are also transitive by default. <DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences> stops that and forces each project to declare what it uses, which some large repos prefer for clearer layering.
| Question | Command |
|---|---|
| What does this project resolve, including transitive packages? | dotnet list package --include-transitive (in .NET 10 also dotnet package list --include-transitive) |
| Why is this package here? | dotnet nuget why src/Orders.Api Newtonsoft.Json |
| Any known vulnerabilities? | dotnet list package --vulnerable --include-transitive |
| What is outdated? | dotnet list package --outdated |
| Exactly which version and which assets? | Open obj/project.assets.json, or read the restore section of a binary log |
Orders.Api
`-- Contoso.Reporting 3.1.0
`-- System.Text.Encodings.Web 4.5.0 <-- NU1903 high severity
Options, best first:
Contoso.Reporting to a version that depends on a fixed version.Orders.Api (nearest wins).CentralPackageTransitivePinningEnabled and list the fixed version in Directory.Packages.props, so it applies to every project.In .NET 10, NuGet also prunes packages that the target framework already provides (package pruning is on by default for net10.0 projects). Many old transitive System.* packages then disappear from the graph and stop producing audit warnings.
| Situation | Choose |
|---|---|
| One team, one deployable, one repo | ProjectReference |
| A monorepo where call sites can be fixed in the same pull request | ProjectReference |
| A library used by several products with different release schedules | PackageReference to a versioned internal package |
| Code from another organisation | PackageReference |
| Two repos that always change together | Consider merging the repos before you create a package |
A package brings versioning, compatibility promises and upgrade lag. Project references bring coupling: every consumer gets every change at once.
--locked-mode in CI so a compromised or republished version can't slip in.PackageReference and ProjectReference behaviour is stable. NuGet ships with the SDK, so audit, pruning and CLI commands can change between SDK versions; read the NuGet release notes for the .NET 11 SDK before relying on new defaults. Keep production on the .NET 10 SDK until you've tested an upgrade.
App with a project reference to Lib, and add a package to Lib only.App and build. It compiles because of transitive flow.PrivateAssets="all" to the package in Lib and build App again. Read the compile error.dotnet list App package --include-transitive and dotnet nuget why App <package>.App and see NU1605.<DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences> in a three-project chain and see which references you now have to declare.obj/project.assets.json and find the resolved version and asset list for one package.<!-- src/Orders.Domain/Orders.Domain.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- Flows to consumers: they need it at run time -->
<PackageReference Include="Contoso.Abn" Version="1.4.0" />
<!-- Build-time only: don't impose this on consumers -->
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<!-- Build the generator first, but don't reference its assembly -->
<ProjectReference Include="..\Orders.CodeGen\Orders.CodeGen.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
dotnet list src/Orders.Api package --include-transitive
dotnet list src/Orders.Api package --vulnerable --include-transitive
dotnet list src/Orders.Api package --outdated
Mistake: relying on transitive references for packages your code uses directly.
When the intermediate dependency upgrades or drops the package, your project stops compiling, or silently gets a different version.
Instead: add a direct PackageReference for every package your code uses, and check with dotnet nuget why when you're unsure where one comes from.
Mistake: leaving analysers and build tools without PrivateAssets="all" in a library.
The dependency stays in your consumers' graph and in your package's .nuspec, so they inherit packages they never asked for, and possible version conflicts.
Instead: mark analysers, Source Link, Microsoft.EntityFrameworkCore.Design and similar build-time packages with PrivateAssets="all", or declare them once with GlobalPackageReference under Central Package Management.
Mistake: suppressing NU1605 instead of fixing the downgrade.
The warning means a package you depend on needs a newer version than you declared. Suppressing it can give you MissingMethodException or TypeLoadException at run time.
Instead: raise the direct reference to at least the version the dependency needs.
Mistake: using floating versions without a lock file.
4.* resolves to whatever is newest on the feed today, so builds change with no code change and can't be reproduced.
Instead: use explicit minimum versions, or if you float, enable lock files and restore with --locked-mode in CI.
Mistake: thinking Version="4.2.0" means exactly 4.2.0.
It's a minimum. A dependency deeper in the graph can raise it, and people are surprised by which version they actually run.
Instead: read the resolved version in dotnet list package --include-transitive. Use [4.2.0] only when you really need an exact version, knowing it can cause conflicts.
Mistake: shared libraries that ignore Semantic Versioning. Consumers and transitive resolution assume a higher minor or patch is compatible. Removing a public method in 2.2.0 breaks every consumer that resolves it, often only at run time. Instead: put breaking changes in a new major version, and turn on package validation with a baseline version to catch accidental ones.
Mistake: packaging code too early. Turning projects that always ship together into internal packages adds a release step, version bumps and upgrade lag to every change, with no benefit. Instead: use project references inside one repo and one release cadence, and create a package only when consumers genuinely release independently.
Mistake: referencing projects in another repo by relative path or submodule. The path breaks on clean checkouts and in CI, and the consumer builds whatever commit happens to be checked out, with no version at all. Instead: consume the other repo's code as a versioned package, or move the code into one repo.
Mistake: ignoring vulnerable transitive packages.
The vulnerable package isn't in your project file, so it's easy to assume it isn't your problem, but it ships in your output.
Instead: run dotnet list package --vulnerable --include-transitive in CI, and upgrade the parent package, add a direct reference, or use transitive pinning to move to a fixed version.
Mistake: mixing packages.config and PackageReference during a migration.
Two restore systems with different resolution rules make conflicts very hard to debug.
Instead: migrate each project completely to PackageReference, keeping only top-level packages, before moving on.
The choice between project and package references is really a choice about release boundaries and coupling:
The cost of packaging is real: versioning, compatibility promises, upgrade lag across consumers, and diamond-dependency conflicts. The cost of project references across a large codebase is coupling: every consumer gets every change immediately. A senior engineer picks deliberately, revisits the choice as team structure changes, and keeps the dependency graph intentional either way: direct references for what you use, PrivateAssets for build-time tools, lock files and audit for safety, and Central Package Management once more than a handful of projects share packages.
I'd add a Directory.Packages.props at the repo root that sets ManagePackageVersionsCentrally to true and lists one PackageVersion per package. Then I'd strip the Version from every PackageReference, or restore fails with NU1008. First I inventory the current versions with dotnet list package and choose one version for each package, usually the highest that the tests pass with. Analysers go in as GlobalPackageReference items. Transitive pinning lets the central file also patch vulnerable transitive packages. VersionOverride covers genuine exceptions, but only with review. The pitfalls: nested Directory.Packages.props files hiding the root one, NU1507 when there are several feeds without source mapping, and upgrades that become "big bang" unless Dependabot or Renovate update the central file in small batches.
First I read the exact error. NU1301 means a source couldn't be reached, 401 or 403 means authentication, and NU1101 means the package isn't on any configured source. It works on my machine because I have cached packages and stored credentials, while CI starts clean. Then I check which nuget.config CI actually uses, since config files are merged from the repo upwards. I make the repo's config explicit with <clear />, and I authenticate CI the supported way, for example the Azure Artifacts credential provider or a token from the pipeline's secret store, never a committed password. I also add package source mapping and lock files so restores are reproducible and protected against dependency confusion.
dotnet pack builds the project and produces a .nupkg whose metadata comes from MSBuild properties in the project: PackageId, Version, Authors, Description, a licence expression, a repository URL and a README. I set the version from CI using Semantic Versioning, so a breaking change means a new major version. I publish symbols as a .snupkg with Source Link, so consumers can step into the exact source. I turn on package validation to catch accidental breaking changes. Then dotnet nuget push uploads to nuget.org or an internal feed, and organisations that need it can sign packages with dotnet nuget sign.
Last updated 1 Nov 2026.