NuGet and Dependency Management
By the end of this lesson
Add packages, understand version resolution, and keep dependencies current.
A NuGet package is an archive containing compiled assemblies, some metadata, and a list of the packages it needs in turn. NuGet is the client that finds them, works out which versions are compatible, and downloads them into a cache on your machine.
What you commit is the declaration, not the download. A PackageReference in your project file says which package and which version, and restore reconstructs the rest. That single decision is what makes a build reproducible: the repository holds a statement of intent that is a few hundred bytes, and any machine can turn it into the same set of files.
Commit the packages themselves and you get a repository that is large, slow to clone, hard to review, and still not reproducible, because nothing stops the folder drifting from what the project file claims.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Npgsql" Version="9.0.2" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>- Include names the package; Version states which one you want. Written this way the version is a minimum rather than a lock, and NuGet picks the lowest available version that satisfies every requirement in the graph.
- RestorePackagesWithLockFile makes restore write a packages.lock.json listing every resolved version, direct and transitive. Commit that file. It turns an implicit resolution into a recorded one.
- PrivateAssets all means the dependency stops here — projects that reference this one do not inherit it. It is the right setting for analyzers and build-time-only tools, which nothing downstream should see.
- A version with a suffix after the dash, like the beta above, is a prerelease. NuGet never selects one unless you ask for it explicitly, which is why a prerelease dependency has to be written out in full.
Versions follow semantic versioning: major.minor.patch, where each position carries a promise about compatibility.
- Patch — 9.0.1 to 9.0.2
- Fixes only. Your code should compile and behave the same. Safe to take, and the one class of update worth automating.
- Minor — 9.0 to 9.1
- New features, nothing removed. Existing calls should still work, though new overloads occasionally make a previously unambiguous call ambiguous.
- Major — 9.x to 10.0
- Breaking changes are allowed. Read the release notes before you start, and expect to change code. Treat it as a task, not as a version bump.
- Prerelease — 10.0.0-preview.3
- Not final. Ordered before the matching stable release, and excluded from resolution unless requested. Acceptable in a spike; a liability in a production dependency.
- Ranges and floating versions
- Bracket syntax such as [9.0.0,10.0.0) means at least 9.0.0 and below 10.0.0. A floating version such as 9.0.* takes the newest matching patch at restore time. Both hand version choice to whenever restore ran, which is exactly what a reproducible build is trying to avoid.
Most of your dependency graph is not yours. A single package reference frequently brings ten more with it, and those are transitive dependencies: you never asked for them, they are in your output folder, and their bugs are your bugs.
When two packages want different versions of the same transitive dependency, NuGet resolves rather than fails. For two indirect requests, the higher version wins. For a conflict between an indirect request and one you declared yourself, your direct reference wins outright — which is the supported way to force a specific version of something you do not reference directly, usually to take a security fix.
The one case NuGet flags loudly is a downgrade: something in the graph needs a higher version than the one being selected. Depending on your SDK version that is a warning or an error, and either way it means the graph is inconsistent and the resolved set may not work. Do not suppress it without understanding why it appeared.
In a solution with several projects, a single Directory.Packages.props file with central package management moves every version into one place and leaves the project files listing names only. It is worth doing early, because the alternative is finding out that three projects reference three different versions of the same package when one of them starts behaving oddly.
# Add a package and record it in the project file
dotnet add src/Anvi.Employees.Api package Npgsql --version 9.0.2
# What do I actually depend on, including what came along for the ride?
dotnet list package --include-transitive
# What has a newer version available?
dotnet list package --outdated
# What has a known vulnerability, anywhere in the graph?
dotnet list package --vulnerable --include-transitive
# Restore exactly what the lock file records, and fail if the graph has changed
dotnet restore --locked-mode- dotnet add package resolves the version, downloads it, and edits your project file. Doing it by hand is possible and gets version numbers wrong.
- --include-transitive is the flag that shows the real size of your dependency graph. The first run on a mature project is usually a surprise.
- --vulnerable checks resolved versions against the advisory data on your package sources. Without --include-transitive it only inspects your direct references, which is the half of the graph you already know about. Run it in CI, not once a year.
- --locked-mode tells restore to use the committed lock file and fail if it cannot. That converts an accidental dependency change into a red build instead of a silent difference between your machine and the server.
Summary
- A PackageReference in the project file is the dependency; the downloaded package is a cached artifact you never commit
- Semantic versioning tells you what to expect: patch is safe, minor adds, major breaks
- Most of your graph is transitive — the higher version wins between indirect requests, and your direct reference wins over any of them
- A committed packages.lock.json plus restore --locked-mode turns an accidental dependency change into a failed build
- Check maintenance, usage, licence and transitive weight before depending on a package, and check the base class library first
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Look at your own graph
On any project with a few packages, run dotnet list package --include-transitive, then dotnet list package --outdated, then dotnet list package --vulnerable --include-transitive.
Count the direct references and the transitive ones. Pick one transitive package you have never heard of and find out which of your direct references brought it in.
Show solution
The transitive count is usually several times the direct one. That ratio is the honest measure of how much external code you are shipping, and it is the number worth thinking about before adding the next reference.
Tracing a transitive package back to its parent is a skill you will need under pressure, when a scanner reports a vulnerability in something you do not reference. The fix is either upgrading the parent that requested it, or adding a direct reference at a patched version so that your declaration wins.
Think about it
One method, one dependency
A reviewer objects to a new package added to the employees API. It converts department names to title case and nothing else. It has 4,000 downloads, one maintainer, and its last release was four years ago.
Set out the case for and against, then decide.
Show solution
For: it works, it is tested by someone, and writing your own title-casing has edge cases around acronyms, hyphens and non-English names that are easy to get wrong.
Against: the download count and the release date together say almost nobody depends on it and nobody is watching it. You are taking on a package that will never be patched, and the standard library already has culture-aware text casing available.
The reasonable decision here is to decline and use the platform, because the need is small and well covered. The general rule that survives the specific case: weigh the size of the problem against the size of the commitment, and remember that a dependency is permanent in practice even when it is trivial in principle.
The opposite answer is defensible for a different package. Nobody should write their own JSON parser, database driver or cryptography to avoid a dependency.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.