|
| 1 | +--- |
| 2 | +applyTo: "**/Directory.Packages.props,**/*.csproj,**/Directory.Build.props,**/*.nuspec" |
| 3 | +--- |
| 4 | +# Choosing Package Versions in Multi-Targeted Projects |
| 5 | + |
| 6 | +Guidance for choosing NuGet package versions in multi-targeted projects (e.g. `net462;net8.0;net9.0`). |
| 7 | + |
| 8 | +## Rule |
| 9 | +For runtime-aligned packages, **the package major must match the target runtime major**: 8.x on `net8.0`, 9.x on `net9.0`, 10.x on `net10.0`, and so on. TFMs that aren't tied to a specific runtime major (`net462`, `netstandard2.0`) get the major of the floor LTS. Other categories are versioned as described below. |
| 10 | + |
| 11 | +Split package references into three categories: |
| 12 | + |
| 13 | +### 1. Runtime-aligned packages — **version per TFM, matching the runtime band** |
| 14 | + |
| 15 | +Packages whose major version ships with (or is tightly coupled to) a specific .NET runtime: |
| 16 | + |
| 17 | +- `Microsoft.Extensions.*` (Logging, DependencyInjection, Configuration, Hosting, Options, Caching, Http, Primitives, ...) |
| 18 | +- `Microsoft.AspNetCore.*` |
| 19 | +- `Microsoft.EntityFrameworkCore.*` |
| 20 | +- `System.Text.Json`, `System.Memory`, `System.IO.Pipelines`, `System.Formats.Asn1`, `System.Security.Cryptography.Pkcs` |
| 21 | +- `Microsoft.Bcl.*` |
| 22 | + |
| 23 | +Use the major version that matches the TFM. For TFMs without a corresponding runtime major (`net462`, `netstandard2.0`, etc.), use the major of the **lowest supported modern TFM** — typically the floor LTS (e.g. `8.x` while net8 is supported). This keeps the legacy targets on a long-lived, well-patched band and avoids dragging in transitive deps from a newer major: |
| 24 | + |
| 25 | +```xml |
| 26 | +<!-- Defaults: lowest supported runtime band (e.g. net8 LTS); also applies to net462 / netstandard2.0 --> |
| 27 | +<ItemGroup> |
| 28 | + <PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" /> |
| 29 | + <PackageVersion Include="System.Text.Json" Version="8.0.5" /> |
| 30 | +</ItemGroup> |
| 31 | + |
| 32 | +<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'"> |
| 33 | + <PackageVersion Update="Microsoft.Extensions.Logging" Version="9.0.0" /> |
| 34 | + <PackageVersion Update="System.Text.Json" Version="9.0.0" /> |
| 35 | +</ItemGroup> |
| 36 | +``` |
| 37 | + |
| 38 | +When the floor LTS drops out of support, bump the default block to the new floor LTS major and drop any conditional block that becomes redundant. |
| 39 | + |
| 40 | +### 2. Independent packages — **single version across all TFMs** |
| 41 | + |
| 42 | +Packages whose versioning is decoupled from the .NET runtime: |
| 43 | + |
| 44 | +- `Newtonsoft.Json`, `Polly.*`, `Serilog.*` |
| 45 | +- `Azure.*`, `Microsoft.Identity.*`, `Microsoft.IdentityModel.*` |
| 46 | +- `Microsoft.Data.SqlClient`, `Dapper`, `StackExchange.Redis` |
| 47 | +- Most third-party packages |
| 48 | + |
| 49 | +Reference one (latest stable) version unconditionally: |
| 50 | + |
| 51 | +```xml |
| 52 | +<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" /> |
| 53 | +<PackageVersion Include="Azure.Identity" Version="1.17.1" /> |
| 54 | +``` |
| 55 | + |
| 56 | +### 3. Polyfills — **conditional presence, single version** |
| 57 | + |
| 58 | +Packages that only exist (or are only needed) on older TFMs. The polyfill major doesn't have to match any runtime band (older TFMs have no in-box equivalent), so pick the latest stable available: |
| 59 | + |
| 60 | +```xml |
| 61 | +<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0' OR '$(TargetFramework)' == 'net462'"> |
| 62 | + <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.0" /> |
| 63 | +</ItemGroup> |
| 64 | +``` |
| 65 | + |
| 66 | +Condition the *presence* of the reference, not the version. |
| 67 | + |
| 68 | +## How to categorize a package |
| 69 | + |
| 70 | +The named lists above aren't exhaustive. To classify a package you don't recognize, work through these steps in order: |
| 71 | + |
| 72 | +### 1. Read the nuget.org description |
| 73 | + |
| 74 | +Pure polyfills almost always say so explicitly. For example, `Microsoft.Bcl.TimeProvider`'s page reads: *"For apps targeting .NET 8 and newer versions, referencing this package is unnecessary, as the types it contains are already included in the .NET 8 and higher platform versions."* |
| 75 | + |
| 76 | +If the description says "for apps targeting .NET X and earlier" or "unnecessary on .NET X+", treat as polyfill candidate and continue to step 2 to confirm. If it makes no such claim and the package owner is Microsoft + a major number tracks the .NET release train, treat as runtime-aligned candidate. |
| 77 | + |
| 78 | +### 2. Inspect the package's `lib/` layout |
| 79 | + |
| 80 | +Look at the "Frameworks" tab on nuget.org or open the `.nupkg`: |
| 81 | + |
| 82 | +| `lib/` layout | Category | |
| 83 | +|---|---| |
| 84 | +| Only older TFM folders (`netstandard2.0`, `net462`) — no modern TFMs | Pure polyfill | |
| 85 | +| `lib/net8.0/_._`, `lib/net9.0/_._` placeholders + real DLL only on older TFMs | Pure polyfill (no-op on modern TFMs) | |
| 86 | +| Real DLLs in `lib/net8.0/`, `lib/net9.0/`, `lib/net10.0/`, differing per band | Runtime-aligned | |
| 87 | +| Real DLLs on every TFM including older ones, single major doesn't track .NET releases | Independent | |
| 88 | + |
| 89 | +### 3. Check the release cadence |
| 90 | + |
| 91 | +- Runtime-aligned: new major every November in lockstep with .NET (8.0, 9.0, 10.0, ...) plus monthly servicing patches. |
| 92 | +- Independent: releases on its own schedule, major doesn't correlate with .NET versions. |
| 93 | +- Pure polyfill: usually freezes at one major and rarely bumps; new majors only to ride the build train. |
| 94 | + |
| 95 | +### 4. Functional test — remove the reference and rebuild |
| 96 | + |
| 97 | +The decisive test for the polyfill-vs-runtime-aligned boundary: remove the `PackageReference` on a modern TFM (e.g. net8) and build. |
| 98 | + |
| 99 | +- Builds clean → package was acting as a polyfill on that TFM. Confirm category 3. |
| 100 | +- Fails with `CS0246`/`CS1061` (missing type or method) → the package contributes API the in-box BCL doesn't have. Treat as runtime-aligned (category 1), even if the description sounds polyfill-ish. |
| 101 | + |
| 102 | +### Beware hybrids |
| 103 | + |
| 104 | +Some packages look like polyfills but add API beyond the in-box BCL even on modern TFMs. Treat these like runtime-aligned packages. |
| 105 | + |
| 106 | +### When in doubt |
| 107 | + |
| 108 | +Treat as **runtime-aligned** (category 1) and reference on every TFM with per-TFM majors. |
| 109 | + |
| 110 | +- Cost of mis-classifying a true polyfill this way: a redundant `_._` asset at restore. Harmless. |
| 111 | +- Cost of mis-classifying a hybrid as a pure polyfill: a compile break on the TFMs where you dropped the reference. |
| 112 | + |
| 113 | +## Why |
| 114 | + |
| 115 | +### Why latest minor/patch always |
| 116 | + |
| 117 | +`PackageReference` versions are minimums (`[X, ∞)`), so writing a stale minor or patch buys nothing for downgrade-safety and only loses fixes: |
| 118 | + |
| 119 | +- **Security**: BCL and Extensions packages ship CVE patches in minor/patch bumps. Pinning to an older patch means a customer who doesn't transitively pull a newer version stays on the vulnerable floor. |
| 120 | +- **Bug fixes**: Same logic for non-security fixes. We have no reason to anchor customers to an older `8.0.0` when `8.0.5` is available. |
| 121 | +- **NU1605 risk is small and easy to fix**: NU1605 fires on *any* downgrade, including minor/patch within the same major (e.g. our `8.0.5` transitive vs. a customer's direct `8.0.0`). In practice this is rare and trivial to resolve — the customer bumps their direct reference to a current patch. The cost of *not* tracking latest (stale security/bug fixes for every consumer who doesn't override) is larger than the cost of an occasional one-line bump in a consumer project. |
| 122 | +- **Reduces noise from automated bumps**: Dependabot/Renovate PRs disappear if we already track latest. |
| 123 | + |
| 124 | +This rule applies to all three categories. The category decides the major; "latest" decides the minor and patch. |
| 125 | + |
| 126 | +### NuGet `PackageReference` versions are minimums, not pins |
| 127 | + |
| 128 | +`Version="X"` means `[X, ∞)`. The resolver picks the highest version requested across the graph, with one critical exception: a **direct** reference wins over a transitive one (nearest-wins). |
| 129 | + |
| 130 | +### NU1605 fires when the customer's direct version is *lower* than your transitive version |
| 131 | + |
| 132 | +If your library transitively requires `Microsoft.Extensions.Logging 10.0.0` and the consuming app has a direct `<PackageReference Version="8.0.0" />`, NuGet detects a downgrade and emits **NU1605**. In modern SDKs this is an **error**, not a warning — the customer's build fails. |
| 133 | + |
| 134 | +The reverse (customer's direct version higher than your transitive) resolves cleanly with no warning. |
| 135 | + |
| 136 | +### The asymmetry drives the rule |
| 137 | + |
| 138 | +| Your transitive version | Customer's direct version | Result | |
| 139 | +|---|---|---| |
| 140 | +| 10.x | 8.x | **NU1605 error** | |
| 141 | +| 10.x | 10.x or higher | Clean | |
| 142 | +| 8.x | 8.x or higher | Clean | |
| 143 | + |
| 144 | +Pinning runtime-aligned packages to the **band matching each TFM** means a net8 consumer transitively gets 8.x (no friction with their own 8.x reference), and a net10 consumer transitively gets 10.x. |
| 145 | + |
| 146 | +Pinning everything to the latest major (e.g. `10.x` unconditionally) forces every net8 customer to roll their direct references forward or hit NU1605. |
| 147 | + |
| 148 | +### Independent packages don't have this problem |
| 149 | + |
| 150 | +`Newtonsoft.Json 13.x`, `Azure.Identity 1.17.x`, etc. aren't tied to a runtime version. Customers don't have a "matching" version in mind, and the package's own multi-targeted assets handle TFM selection internally. One version is simpler and avoids needless conditional blocks. |
| 151 | + |
| 152 | +### Framework-provided assemblies win at runtime anyway |
| 153 | + |
| 154 | +On .NET 8+, packages like `System.Text.Json` and `System.Memory` are part of the shared framework. Even if you reference `System.Text.Json 9.0.0`, a net8 app uses the in-box net8 copy at runtime. Referencing 10.x on net8 just creates restore-graph noise with no runtime benefit — another reason to match the band. |
| 155 | + |
| 156 | +## Tradeoffs and alternatives considered |
| 157 | + |
| 158 | +### Per-TFM (the rule) vs. single lowest-LTS version |
| 159 | + |
| 160 | +A "pin everything to the lowest supported LTS major (e.g. 8.x everywhere) and bump only when that LTS drops" policy is also downgrade-safe and simpler in `Directory.Packages.props`. We rejected it because: |
| 161 | + |
| 162 | +- Customers on newer runtimes lose access to perf/feature work that landed in later package majors for packages that *aren't* fully overridden by the in-box shared framework (e.g. `Microsoft.Extensions.Caching.Memory`, `System.Configuration.ConfigurationManager`). |
| 163 | +- The simplification is small: one extra conditional `ItemGroup` per runtime-aligned package. |
| 164 | +- A coordinated bump when the floor LTS drops is a larger, riskier change than incremental per-TFM updates. |
| 165 | + |
| 166 | +Per-TFM keeps each runtime on its matching band and confines change to one `Update` line at a time. |
| 167 | + |
| 168 | +### Why no upper bounds |
| 169 | + |
| 170 | +Customers can transitively pull in a *higher* major than your reference. NuGet resolves nearest-wins with no warning, so a major that broke API can produce `MissingMethodException`/`TypeLoadException` at runtime rather than a restore-time failure. |
| 171 | + |
| 172 | +An upper bound (e.g. `Version="[8.0.0, 10.0.0)"`) would convert that runtime failure into a restore-time `NU1107` and is the only way to guarantee compatibility. We still avoid it because: |
| 173 | + |
| 174 | +- It blocks customers from rolling forward to fix CVEs or take perf wins in the newer major. |
| 175 | +- It propagates conflicts deep into customer dependency graphs that we can't see. |
| 176 | +- Microsoft's [library guidance](https://learn.microsoft.com/dotnet/standard/library-guidance/dependencies#nuget-dependency-version-ranges) explicitly says **AVOID upper bounds**, and the foundational Microsoft libraries (EF Core, ASP.NET Core, Aspire, Orleans, the BCL itself, Azure SDK) follow it. |
| 177 | +- Exact pins (`[X]`) in Microsoft repos are reserved for host-coupled scenarios: Roslyn analyzers (`Microsoft.CodeAnalysis.*`), MSBuild API consumers (`Microsoft.Build`), VS extensibility. None apply to runtime libraries like SqlClient. |
| 178 | + |
| 179 | +We accept the SemVer-break risk in exchange for not breaking customer rollforward. If a specific package is known to break compatibility at a future major, document it in code review and revisit — don't blanket-bound. |
| 180 | + |
| 181 | +### Downgrade direction matters |
| 182 | + |
| 183 | +| Your transitive | Customer's direct | Result | |
| 184 | +|---|---|---| |
| 185 | +| Higher | Lower | **NU1605 error** (we cause this) | |
| 186 | +| Lower | Higher | Clean restore, customer's wins | |
| 187 | +| Equal | Equal | Clean | |
| 188 | + |
| 189 | +The asymmetry is the entire reason per-TFM matching works: it makes us the *lower* or *equal* for any customer who has aligned their own references with their runtime. |
| 190 | + |
| 191 | +## Checklist before changing a package version |
| 192 | + |
| 193 | +1. Is the package in the runtime-aligned list above? → use per-TFM conditional `PackageVersion Update`, latest minor/patch in each band. |
| 194 | +2. Is it independent? → single `PackageVersion`, latest stable. |
| 195 | +3. Is it a polyfill? → conditional `PackageReference` only on TFMs that need it, latest stable. |
| 196 | +4. Always pick the latest available minor/patch within the chosen major. Don't carry forward a stale minor when bumping or adding. |
| 197 | +5. Prefer Central Package Management (`Directory.Packages.props`) over per-project versions. |
| 198 | +6. Never use exact-version (`[X]`) or upper-bound (`[X, Y)`) ranges on `PackageReference` unless you have a documented compatibility reason. |
| 199 | + |
| 200 | +## Sources |
| 201 | + |
| 202 | +- [NU1605 — Detected package downgrade](https://learn.microsoft.com/nuget/reference/errors-and-warnings/nu1605) |
| 203 | +- [NuGet Package versioning — version ranges](https://learn.microsoft.com/nuget/concepts/package-versioning#version-ranges) |
| 204 | +- [NuGet dependency resolution](https://learn.microsoft.com/nuget/concepts/dependency-resolution) |
| 205 | +- [Library guidance — Dependencies](https://learn.microsoft.com/dotnet/standard/library-guidance/dependencies) |
| 206 | +- [Library guidance — Cross-platform targeting](https://learn.microsoft.com/dotnet/standard/library-guidance/cross-platform-targeting) |
0 commit comments