Best Practices for Developing Internal NuGet Packages
Practical workflows for debugging, structuring, and automating internal NuGet packages without turning every code change into a rebuild-and-republish cycle.
Internal NuGet packages are a great way to share code across products — right up until debugging one becomes harder than debugging a directly referenced project, and every small edit turns into a full rebuild-and-republish cycle. None of that friction is inherent to NuGet itself; it’s almost always a workflow problem, and it has workflow solutions.
Making iteration fast
Use project references during active development. When a library is changing frequently, reference it as a project in the same solution instead of pulling it in as a NuGet package. Edits show up the moment you rebuild the consumer — no pack, no publish, no update cycle. Switch back to the NuGet reference once the library stabilizes.
Set up a local NuGet feed for quick testing. Point a folder on your machine to act as a local package source, script the pack step to drop the .nupkg there, and configure your consuming project to restore from it with high priority. Because it’s a local pre-release feed, you’re not burning a real version number on every test iteration, and nobody else is affected by your interim builds.
Enable symbol packages and Source Link. Publishing .pdb files alongside the package (dotnet pack -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg), combined with Source Link pointing back at your repository, lets you step into package code as if it were part of your own solution. Azure DevOps Artifacts includes an integrated symbol server for exactly this — point Visual Studio’s symbol settings at it once, and debugging into a package stops being a special case.
“Munge” the project into your solution when nothing else is enough. For genuinely hard debugging, temporarily swap the NuGet reference for a project reference by pulling the library’s .csproj into your solution directly. Tools like the NuGet Reference Switcher extension automate the swap and the swap-back, which matters — doing this by hand risks accidentally committing the temporary reference change and breaking the build for everyone else.
Structuring solutions for both modes
Develop in a single solution with project references when a library and its consumer are evolving together — this is the fastest loop, and it’s fine to maintain a separate standalone solution for the library alongside it.
Publish as a NuGet package once the library stabilizes, or as soon as it needs to be shared outside the immediate team. This gives you proper isolation and versioning, and lets Project B (possibly in an entirely different repository) treat Library A as a normal external dependency that only changes when a new version ships.
Use a reference-switching tool rather than hand-editing .csproj files. Manually flipping between <ProjectReference> and <PackageReference> is exactly the kind of thing that’s easy to get wrong and easy to forget to revert.
Keep each shared library as its own package. dotnet pack doesn’t automatically bundle project references into the resulting package — it expects each referenced project to be published separately, with your primary package declaring a NuGet dependency on the others. Fighting this by hand-rolling a .nuspec to include extra DLLs is possible but fragile; treating every reusable component as a first-class package avoids the problem entirely.
Automating versioning and publishing
Let CI build and pack on every commit. A pipeline that restores, builds, and runs dotnet pack on each change gives you a testable artifact for every meaningful change, even if not every build gets promoted to a stable release.
Automate version numbers rather than hand-tracking them. Semantic versioning (Major.Minor.Patch) with the pipeline auto-incrementing the patch or build number removes an entire category of human error — forgetting to bump a version, or worse, accidentally reusing one.
Use pre-release tags for continuous builds. A suffix like 1.3.0-alpha.5 distinguishes a CI build from a finished release, letting developers pull early builds for testing without mistaking them for something stable.
Publish to an internal feed — Azure Artifacts on an on-premises Azure DevOps Server works well here — so there’s a single source of truth every consuming project restores from.
Publish symbols alongside the package, and gate publishing on passing tests. A package should never ship — especially not a stable release — without having cleared the test suite first.
Pitfalls worth watching for
Frequent rebuild/publish cycles killing momentum. If every tweak requires a formal package release, developers will resent the package boundary. Project references and local feeds exist specifically to avoid this.
Version confusion. Different consumers quietly drifting onto different old versions is a slow-motion problem. Automate version increments, never reuse a version number for different content (it causes caching problems), and consider tools like NuKeeper or Renovate to open update PRs automatically when a new internal version lands.
Missing debug information. If nobody can step into the package, check first whether PDBs were actually published and whether the symbol source is configured — this is almost always the actual cause, not some deeper tooling failure.
Reference mix-ups. Accidentally committing a temporary project reference (or an updated package reference nobody else is ready for) breaks builds for everyone. Treat the NuGet reference as the default state, and be disciplined about reverting temporary swaps before committing.
Missing transitive dependencies. If your package itself depends on other internal libraries, publish those as packages too and declare proper NuGet dependencies between them — don’t try to smuggle their DLLs into your package directly.
Over-coupling from insufficient tests. If confidence in a library only comes from manually running the consuming app, that’s a sign the library needs a real test suite. Strong test coverage on the library side is what actually lets consumers stop live-debugging it on every update.
The bottom line
None of these practices are exotic — they’re the difference between a NuGet package that feels like a tax on every change and one that genuinely delivers on the promise of code reuse. The common thread: make the package behave as close to “source” as possible during development (project references, local feeds, symbol servers), and only pay the packaging overhead once the code is actually ready to be shared more broadly.