From Debt to Discipline: Managing NuGet Dependency Technical Debt in .NET

From Debt to Discipline: Managing NuGet Dependency Technical Debt in .NET

Author - Abdul Rahman (Bhai)

NuGet

6 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Every week you skip a NuGet update you are not deferring work — you are compounding interest on a debt that will collect itself. A package that is one minor version behind costs an hour to upgrade. Three major versions behind costs a sprint. Five major versions behind, with no tests and no record of why the old version was pinned, can cost a full rewrite and an emergency incident postmortem.

NuGet dependency technical debt is the accumulated future cost of deferred upgrades, undocumented deferral decisions, unexamined transitive references, and third-party types that have quietly spread across your domain layer. Unlike a missed feature, it does not announce itself. It compounds silently until a CVE advisory or an end-of-life notice forces your hand under pressure.

The earlier articles in this series covered how to find vulnerabilities, how to wrap and replace packages, and how to verify replacements with characterization and contract tests. This article covers the organizational layer that makes all of those practices sustainable: how to classify what you owe, how to document deferral decisions so future developers are never left guessing, how to automate continuous scanning so risk surfaces early, and how to assign ownership so the debt has someone accountable for paying it down.

Why we gonna do?

Version Lag Compounds Exponentially, Not Linearly

Skipping a single minor release is recoverable. Skipping five major releases of a heavily used package is a different category of problem entirely. Each skipped major version introduces a new set of breaking API changes that must be addressed before the next version can be applied. If each major version requires two days of migration work, five versions is not ten days — it is ten days of cascading changes where each step depends on completing the previous one, multiplied by the risk that the package's dependencies have also drifted.


# Snapshot of a project that deferred upgrades for 18 months.
# Each row is a compounding obligation, not an independent task.
dotnet list package --outdated

Project 'OrdersApi' has the following updates to its packages
   [net10.0]:
   Top-level Package          Requested  Resolved  Latest
   > LegacyCsvProcessor       2.1.0      2.1.0     5.0.1   # 3 major versions behind
   > Contoso.PdfRenderer      1.3.4      1.3.4     4.1.0   # 3 major versions behind
   > Acme.ReportingEngine     3.0.0      3.0.0     3.7.2   # minor — still manageable
            

LegacyCsvProcessor 2.1.0 to 5.0.1 is not one migration — it is three, each depending on completing the one before it. And the package that looked manageable at v2 may have deprecated its entire async API by v4.

Dependency Sprawl Makes Every Upgrade Riskier Than the Last

When a third-party type leaks out of the layer that was supposed to contain it and spreads into your domain logic, you no longer have a dependency — you have a structural constraint. The upgrade cost grows with every call site that has absorbed the package's types directly rather than through an abstraction.


// Dependency sprawl in practice: LegacyCsvProcessor types have leaked
// from the infrastructure layer into the application and domain layers.
// Every one of these references must be touched during an upgrade.

// Infrastructure layer — expected
using LegacyCsvProcessor.Readers;
public class CsvOrderImporter { ... }

// Application layer — a warning sign
using LegacyCsvProcessor.Configuration;
public class ImportOrdersCommand
{
    private readonly CsvReaderOptions _options; // LegacyCsvProcessor type in app layer
}

// Domain layer — a red flag
using LegacyCsvProcessor.Models;
public class Order
{
    public CsvRow? SourceRow { get; init; } // LegacyCsvProcessor type in domain model
}
            

When CsvRow appears in a domain model, upgrading LegacyCsvProcessor is no longer an infrastructure concern — it is a domain-layer breaking change. The Anti-Corruption Layer pattern covered in the previous article in this series exists precisely to prevent this spread.

Transitive Dependencies Are Your Responsibility Too

A package you deliberately chose has its own dependencies. When one of those transitive dependencies carries a CVE, that CVE is in your application's attack surface regardless of whether you ever referenced the vulnerable package directly. Most teams discover this the hard way — during a security scan, not during routine maintenance.


# --include-transitive surfaces vulnerabilities you never explicitly took on.
dotnet list package --vulnerable --include-transitive

Project 'OrdersApi' has the following vulnerable packages
   [net10.0]:
   Top-level Package      Requested   Resolved   Severity   Advisory URL
   > LegacyCsvProcessor   2.1.0       2.1.0      High       https://github.com/advisories/GHSA-r2ab-cd34

   Transitive Package                   Resolved   Severity   Advisory URL
   > Contoso.InternalXmlParser          1.0.8      Critical   https://github.com/advisories/GHSA-xy99-ef12
            

Contoso.InternalXmlParser is not in your .csproj. You did not choose it. But it is in your deployment artifact, it is running in your process, and the CVE in it is your responsibility to remediate — typically by upgrading the parent package (LegacyCsvProcessor) to a version that pulls in a patched transitive.

Undocumented Deferrals Are the Most Insidious Debt of All

A package that is two major versions behind with a pinned version comment in the .csproj, an ADR explaining why, and an owner responsible for the upgrade plan is managed debt. A package that is two major versions behind with no comment, no record, and no owner — where the developer who chose to pin it left the organisation eighteen months ago — is rot. Rot is not just deferred work; it is deferred understanding. Nobody acts on what nobody understands, and rot is always growing.

How we gonna do?

Step 1: Classify Every Deferred Decision as Healthy Debt or Rot

Not all debt is equal. The classification that matters most is not how old the package is or how many versions behind it sits — it is whether someone deliberately chose to defer the upgrade and documented why. Deliberate, documented deferral is healthy debt. No-decision, undocumented deferral is rot. Use this two-axis framework to classify every deferred dependency in your project:


  ┌──────────────────────────┬───────────────────────────────┬───────────────────────────────────┐
  │                          │ Documented                    │ Undocumented                      │
  ├──────────────────────────┼───────────────────────────────┼───────────────────────────────────┤
  │ Deliberate decision      │ HEALTHY — review the ADR on   │ FRAGILE — write the ADR now;      │
  │                          │ its scheduled trigger date    │ risk is growing with no record    │
  ├──────────────────────────┼───────────────────────────────┼───────────────────────────────────┤
  │ No decision made         │ DRIFTING — assign an owner    │ ROT — highest priority; someone   │
  │                          │ and write the ADR immediately │ must own and document this today  │
  └──────────────────────────┴───────────────────────────────┴───────────────────────────────────┘
            

The goal is not to eliminate all debt immediately — that is rarely feasible. The goal is to move every deferred dependency from the right column into the left column. A team that knows exactly what it owes, why it owes it, and when it intends to pay it down is in a fundamentally different position from a team that is discovering its debt under pressure.

Step 2: Document Every Deferral Decision with an Architectural Decision Record

An Architectural Decision Record (ADR) is a short Markdown file that captures a technical decision at the moment it is made — the context that forced the decision, the options that were considered, the rationale for the choice, and the consequences that follow. For NuGet dependencies, an ADR is what separates a deliberate deferral from rot. Commit ADRs alongside your code in a docs/adr/ folder at the repository root.

Here is a concrete ADR for a team that discovered a CVE in a package they cannot immediately upgrade due to a breaking API change in the newer version:


# ADR-0004: Pin LegacyCsvProcessor to v2.4.2 and Defer v3 Migration

**Status**: Accepted
**Date**: 2026-08-15
**Owner**: @jane.doe
**Review trigger**: 2026-11-15 OR when LegacyCsvProcessor v3.1.0 is released — whichever comes first

## Context

LegacyCsvProcessor v3.0 was released on 2026-07-01 with a breaking change to `ICsvReader`:
the `ReadAll<T>()` method changed from returning `IEnumerable<T>` to `IAsyncEnumerable<T>`,
requiring async rewrites across 14 call sites in OrdersApi.

On 2026-08-10, GHSA-r2ab-cd34 was published against LegacyCsvProcessor v2.x below v2.4.2.
Severity: High (CVSS 3.1: 7.5). Network-reachable but requires a malformed CSV payload
crafted by the caller. Our import endpoint accepts files from authenticated internal systems
only — attack surface is bounded.

v2.4.2 is a patch release that remediates GHSA-r2ab-cd34 without any API changes.

## Assumptions

- The CVE is not reachable from external callers (confirmed via threat model review 2026-08-14)
- v2.4.2 fully patches the CVE — confirmed against the package release notes
- The async API rewrite is estimated at 3 developer-days; capacity is available in Sprint 42 (2026-10-01)
- No other transitive dependents of LegacyCsvProcessor carry the same vulnerability

## Options Considered

| Option                          | Verdict  | Reason                                                           |
|---------------------------------|----------|------------------------------------------------------------------|
| Upgrade to v3.0 immediately     | Rejected | Breaking API requires 3-dev-day async rewrite; mid-sprint       |
| Pin to v2.4.2 (patch)           | Accepted | Remediates CVE; no breaking changes; buys time for v3 migration |
| Remove the dependency entirely  | Rejected | CSV import is a core feature; no BCL equivalent                 |

## Decision

Pin `LegacyCsvProcessor` to `2.4.2` in all projects immediately to remediate GHSA-r2ab-cd34.
Schedule the v3.0 async migration for Sprint 42. Owner: @jane.doe.

## Consequences

+ GHSA-r2ab-cd34 is remediated without disrupting the current sprint
+ The planned v3 migration is documented, owned, and has a delivery date
- We accept one additional sprint of version lag (v2.4.2 vs v3.0)
- If the v3 migration slips past 2026-11-15, this deferral becomes uncomfortable debt
            

Notice that the ADR records what the team believed to be true at the time of the decision — the reachability assessment, the sprint capacity, the patch availability. Future readers can evaluate whether those assumptions still hold without having to reconstruct the reasoning from code archaeology. An ADR transforms a deferral from rot into healthy debt.

Step 3: Automate a Weekly Dependency Health Check

A dependency that is clean on Monday may carry a published CVE by Friday. Manual checks that happen at irregular intervals miss this window. A scripted health check that runs on a schedule — daily in CI, or at minimum weekly — ensures that new vulnerabilities surface before they accumulate into a backlog of emergencies.


#!/usr/bin/env bash
# scripts/nuget-health-check.sh
# Run from the repository root. Requires .NET SDK 8 or later.
# Works on Linux, macOS, and Windows (Git Bash or WSL).
# Usage: bash scripts/nuget-health-check.sh MyApp.slnx 2>&1 | tee reports/nuget-health-$(date +%F).txt
# If no solution path is given, the first *.slnx file in the current directory is used.

set -euo pipefail

SOLUTION="${1:-$(ls *.slnx 2>/dev/null | head -1)}"
[[ -z "$SOLUTION" ]] && { echo "Error: no solution file found. Pass the path as the first argument."; exit 1; }
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

echo "========================================================"
echo " NuGet Dependency Health Report"
echo " Generated : $TIMESTAMP"
echo " Solution  : $SOLUTION"
echo "========================================================"
echo ""

echo "--- Packages with known CVEs (including transitive) ---"
dotnet list "$SOLUTION" package --vulnerable --include-transitive 2>/dev/null || true

echo ""
echo "--- Packages that have newer versions available ---"
dotnet list "$SOLUTION" package --outdated 2>/dev/null || true

echo ""
echo "--- Packages marked deprecated by their authors ---"
dotnet list "$SOLUTION" package --deprecated 2>/dev/null || true

echo ""
echo "========================================================"
echo " Report complete. Review all findings and create ADRs"
echo " for any deferral decisions before closing this report."
echo "========================================================"
            

Wire this script into your CI pipeline so it runs on every pull request and on a scheduled nightly trigger. The key discipline here is not just running the scan — it is acting on the output. Any vulnerability or outdated package that is being intentionally deferred needs an ADR before the run is considered complete.

Step 4: Configure GitHub Dependabot for Continuous Monitoring

A scripted health check surfaces the state of your dependencies when you run it. GitHub Dependabot monitors your dependency tree continuously, fires alerts on the same day a CVE is published to the GitHub Advisory Database, and opens pull requests on a configurable schedule — removing the manual overhead of tracking updates across dozens of packages. Place a dependabot.yml file in your repository's .github/ folder to enable it:


# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: nuget
    directory: /
    schedule:
      interval: weekly
      day: monday
      time: "06:00"
      timezone: UTC
    groups:
      # Bundle Microsoft.Extensions.* updates into a single PR
      # to avoid inbox noise from many individual package updates.
      microsoft-extensions:
        patterns:
          - "Microsoft.Extensions.*"
      microsoft-aspnetcore:
        patterns:
          - "Microsoft.AspNetCore.*"
    open-pull-requests-limit: 5
    reviewers:
      - octocat           # replace with your team's GitHub handle
    labels:
      - dependencies
      - nuget
    commit-message:
      prefix: "chore(deps)"
            

The groups key is important for .NET projects: Microsoft releases Microsoft.Extensions.* and Microsoft.AspNetCore.* packages in coordinated batches. Without grouping, Dependabot opens one pull request per package — potentially dozens per release cycle. Grouping collapses those into a single reviewable PR.

Step 5: Define a Team Upgrade SLA so Decisions Are Made Under Planning, Not Pressure

Without a written policy, every CVE becomes an ad-hoc negotiation: how bad is this, whose sprint does it land in, can we defer it? An upgrade SLA is a team-level agreement that defines the maximum time from CVE discovery to remediation action, segmented by severity. It converts reactive firefighting into a planned process.


  ┌──────────────┬────────────┬───────────────────────────┬──────────────────────────────────────────┐
  │ Severity     │ CVSS Score │ Response SLA              │ Deferral criteria                        │
  ├──────────────┼────────────┼───────────────────────────┼──────────────────────────────────────────┤
  │ Critical     │ 9.0 - 10.0 │ Remediate within 24 hours │ None — no deferral permitted             │
  │ High         │ 7.0 - 8.9  │ Remediate within 5 days   │ ADR required + lead sign-off             │
  │ Moderate     │ 4.0 - 6.9  │ Remediate within 30 days  │ ADR required                             │
  │ Low          │ 0.1 - 3.9  │ Remediate in next sprint  │ ADR optional if patch is available       │
  └──────────────┴────────────┴───────────────────────────┴──────────────────────────────────────────┘
            

Add a reachability triage step before applying the SLA row. A Critical-rated vulnerability in a package used only in a background job that has no network access may be treated as High for scheduling purposes — but document that reachability assessment explicitly in the ADR. A vulnerability that looked unreachable in your threat model can become reachable after a refactor. The ADR is the audit trail that proves you evaluated the risk at the time of deferral.

Step 6: Assign an Owner to Every Dependency

All of the scanning, ADR writing, and SLA definition in the world means nothing if no one is responsible for acting on it. Every NuGet package in your project should have a named owner — a developer who monitors that package, performs triage when a CVE fires, creates the backlog stories, and writes the ADRs. Shared ownership is no ownership: if two developers are both responsible, neither one feels the urgency to act.

A lightweight ownership registry committed to your repository makes this explicit. A simple Markdown table in a docs/DEPENDENCY_OWNERS.md file is sufficient — it does not require tooling, and it is discoverable by anyone on the team:


# Dependency Ownership Registry

All NuGet packages with a direct dependency must have a named owner.
Owners are responsible for: monitoring CVE alerts, triage, ADR creation,
and backlog story ownership. Rotate ownership quarterly during sprint planning.

| Package                   | Current Version | Owner          | Last Reviewed  | Notes                              |
|---------------------------|-----------------|----------------|----------------|------------------------------------|
| LegacyCsvProcessor        | 2.4.2           | @jane.doe     | 2026-08-15     | ADR-0004: v3 migration due 2026-Q4 |
| Contoso.PdfRenderer       | 1.3.4           | @bob.smith    | 2026-06-01     | v2 migration blocked on PDF spec   |
| Acme.ReportingEngine      | 3.7.2           | @alice.jones  | 2026-07-20     | Current; no open issues            |
| Microsoft.AspNetCore.*    | 10.0.3          | @bob.smith    | 2026-07-01     | Grouped via Dependabot             |
            

Keep this file current by reviewing it at the start of each sprint alongside your vulnerability scan report. A package whose "Last Reviewed" date is more than 60 days old is a signal that its owner has drifted and the dependency is becoming unmanaged.

Putting It All Together: From Weekly Cadence to a Debt Backlog

The six steps above form a repeatable workflow. Here is how they combine in practice week to week:


  Weekly NuGet dependency health workflow

  Monday morning (automated)
  ───────────────────────────
  1. Dependabot fires any new CVE alerts from the weekend
  2. CI runs nuget-health-check.sh and posts the report as an artifact

  Monday planning (manual — 15 minutes)
  ───────────────────────────────────────
  3. Dependency owners review Dependabot alerts and the health report
  4. For each new finding, apply the SLA table to determine the required response window
  5. Classify each finding using the healthy-debt / rot framework:
       - Is there already an ADR? → Confirm the ADR still applies; update the review date
       - No ADR and deliberate deferral needed? → Write the ADR before closing the meeting
       - No ADR and no decision? → Assign an owner and create a backlog story today
  6. Dependabot PRs for non-breaking updates are merged or assigned to the relevant owner

  Sprint planning (quarterly)
  ────────────────────────────
  7. Review docs/DEPENDENCY_OWNERS.md — rotate ownership where needed
  8. Schedule any ADR-deferred migrations that have hit their review trigger date
  9. Retire ADRs for migrations that have shipped
            

The most important discipline in this workflow is step 5. A finding that closes without either an ADR or a merged fix is a finding that has been converted into undocumented rot. Every finding should leave a trail — either a merged PR that closes the issue or an ADR that records the deliberate decision to defer it.

Summary

NuGet dependency technical debt is not a sign that your team is doing something wrong — it is a normal consequence of shipping software in a world where packages evolve, vulnerabilities are discovered, and sprint capacity is finite. The difference between a team that manages it well and one that does not is not the size of the debt; it is the visibility and ownership of it.

  • Classify before you act: Use the deliberate-vs-accidental and documented-vs-undocumented framework to distinguish healthy debt from rot. Rot is always growing; healthy debt can be managed, minimized, and eliminated.
  • Document every deferral: An ADR committed alongside your code transforms a deferred upgrade from rot into healthy debt. Future developers should never have to reconstruct a deferral decision from code archaeology.
  • Automate discovery: A weekly script using dotnet list package --vulnerable --include-transitive combined with GitHub Dependabot ensures new CVEs surface within hours, not months.
  • Define your SLA: A written upgrade policy converts reactive firefighting into planned work. Critical findings get 24-hour windows; lower severities get scheduled sprint slots.
  • Assign ownership: Every direct NuGet dependency should have a named owner. Shared ownership is no ownership — if two developers are responsible, neither one acts with urgency.

For the techniques that make safe upgrades possible once you have decided to act — wrapping strategies, Anti-Corruption Layers, characterization tests, and contract tests — see the earlier articles in this series starting with How to Find NuGet Dependency Vulnerabilities Before They Find You in .NET.

  • Nuget
  • Technical Debt
  • Architectural Decision Record
  • ADR
  • Dependency Ownership
  • Dependabot
  • dotnet list package
  • Upgrade SLA
  • CVE
  • Version Lag
  • .NET