Designing a Library for NuGet

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

Most problems consumers have with a package were decided before it was packed: the frameworks it targets, the dependencies and ranges it declares, the assembly versions it ships and the API changes between releases. This page applies Microsoft’s open-source library guidance to Ledgerline, using .NET SDK 10.0.402 (NuGet 7.9.0). The guidance is written as ✔️ DO / CONSIDER and ❌ AVOID / DO NOT rules; the ones that matter most are quoted below. The C# Reference’s Framework Design Guidelines cover the API design itself.

Choosing target frameworks

The guidance, in short:

  • ✔️ DO start with including a net8.0 target or later for new libraries — modern .NET brings the newest APIs and enables trimming and AOT.

  • ✔️ CONSIDER including a netstandard2.0 target if you need broad compatibility or .NET Framework support.

  • ✔️ CONSIDER adding a net462 target when you also target netstandard2.0 — .NET Framework consumers then get an assembly built for them instead of the .NET Standard facade set.

  • ❌ AVOID netstandard1.x — it drags in a large graph of small packages; ❌ DO NOT include a Portable Class Library target.

  • ✔️ CONSIDER multi-targeting even if the source is the same when the library has package dependencies, so consumers don’t need binding redirects; ❌ AVOID it when there are no dependencies and the code is identical.

  • ❌ AVOID different assembly names per target framework — the assembly name must be the same for every TFM.

Ledgerline follows this: net10.0 and net8.0 for modern .NET, and netstandard2.0 for .NET Framework and other runtimes, with the polyfill packages only where needed:

<PropertyGroup>
  <TargetFrameworks>net10.0;net8.0;netstandard2.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
  <PackageReference Include="Microsoft.Bcl.HashCode" />
  <PackageReference Include="System.Text.Json" />
</ItemGroup>

The resulting package has one lib/ folder per target, and the dependency group of each framework lists only what it needs — net10.0 and net8.0 have none:

<dependencies>
  <group targetFramework="net10.0" />
  <group targetFramework="net8.0" />
  <group targetFramework=".NETStandard2.0">
    <dependency id="Microsoft.Bcl.HashCode" version="6.0.0" exclude="Build,Analyzers" />
    <dependency id="System.Text.Json" version="10.0.13" exclude="Build,Analyzers" />
  </group>
</dependencies>

How NuGet picks a folder for a consumer, and the net8.0 and net48 consumers of this package, are on Package Layout and Multi-Targeting.

One assembly per package

NuGet recommends one assembly per package, with package dependencies to other assemblies: consumers can then include, exclude or privatize each one independently, and a type is never shipped by two packages. Ledgerline follows it — the MSBuild task (Ledgerline.Build), the command-line tool (Ledgerline.Cli) and the templates are separate packages.

Strong naming

A strong name is the assembly’s name, version and culture plus a public key and signature, so the assembly identity includes a public key token. It is not required for libraries that target only .NET (Core) 5 or later: the guidance says to strong-name open-source libraries if their targets include .NET Framework or .NET Standard, because .NET Framework applications that are themselves strong-named can only reference strong-named assemblies.

dotnet build src/Ledgerline -c Release -f net8.0 -p:SignAssembly=true -p:AssemblyOriginatorKeyFile=Ledgerline.snk
dotnet run show.cs -- src/Ledgerline/bin/Release/net8.0/Ledgerline.dll   # prints AssemblyName.FullName
Ledgerline, Version=1.0.0.0, Culture=neutral, PublicKeyToken=719c79c5e8e6e9af

(The .snk was written by a three-line file-based app using RSACryptoServiceProvider.ExportCspBlob, because the .NET Framework sn.exe tool does not exist on Linux.) The rules:

  • ✔️ CONSIDER adding the key pair to source control — a strong name is an identity, not a security boundary; use Authenticode and NuGet package signing for publisher identity (Signing Packages).

  • ❌ DO NOT add, remove or change the key — that changes the assembly identity and breaks every compiled consumer.

  • ❌ DO NOT publish strong-named and non-strong-named variants (Contoso.Api and Contoso.Api.StrongNamed).

Dependencies

A diamond dependency appears when two packages an application uses depend on different versions of a third. NuGet resolves it to one version — the lowest that satisfies every range (Dependency Resolution) — which breaks only when a range excludes the chosen version or the newer version contains a breaking change. A library cannot know what it will be combined with, so the guidance is to keep dependencies few and ranges open:

  • ✔️ DO review your library for unnecessary dependencies.

  • ❌ DO NOT have package references with no minimum version; ❌ AVOID exact versions ([1.0.0]) and ❌ AVOID upper limits ([1.0.0,2.0.0)). The plain Version="1.0.0" already means "1.0.0 or later", and NuGet’s lowest applicable version rule never picks a newer version than needed.

  • ✔️ CONSIDER shared-source packages (packages whose contentFiles add source files to the consumer) for small internal helpers — referenced with PrivateAssets="All", never exposed in public API and never published to nuget.org.

The package compatibility rules

A newer package version must be able to replace an older one, and within one package an assembly for a more specific framework (net8.0) must be able to stand in for a more general one (netstandard2.0), because a library compiled against one may run against the other:

  • ✔️ DO ensure that assemblies for compatible frameworks expose the same (or a superset of) API surface, and ✔️ CONSIDER enabling package validation to check it (Package Validation and API Compatibility).

  • Assembly compatibility — an equal or higher assembly version and no binary breaking change. ❌ DO NOT decrease the assembly version; ❌ AVOID binary breaking changes.

  • Dependency compatibility — dependencies are part of the contract. ❌ AVOID removing a dependency between compatible versions or frameworks — except polyfills (Microsoft.Bcl.AsyncInterfaces, Microsoft.Bcl.HashCode, Microsoft.Bcl.Memory), which ✔️ should be included only for the older TFMs, as Ledgerline does, provided the package has a TFM-specific assembly for the newer framework.

  • Assembly versioning — ✔️ DO change the assembly version with each release, so patched binaries load, especially on .NET Framework with the GAC and binding redirects.

  • Packages that overlap a shared framework (System.Text.Json, System.Collections.Immutable and others) keep their assembly version at Major.Minor.0.0 for every TFM except .NET Framework, so the framework’s own copy can replace them. The SDK’s conflict resolution prefers the newer version, and the framework’s copy when equal; package pruning (opt-in in the .NET 9 SDK, on by default for net10.0 in the .NET 10 SDK) removes such packages from the graph entirely.

The guidance is not consistent on assembly versions: the compatibility rules (dated 2026-03-31) say to change the assembly version with each release, while the strong-naming page (2026-03-23) says to consider incrementing it only on major version changes and the versioning page (2021-01-26) to consider only including a major version in AssemblyVersion. The compatibility page explains its reason (patched binaries must load on .NET Framework); the other two optimize for fewer binding redirects. See Versioning and Prerelease Packages.

Breaking changes

Kind What happens Example

Source

Consumers fail to compile when they rebuild; already-built code keeps working.

A new overload makes a call ambiguous; a renamed parameter breaks named arguments.

Behaviour

Code compiles and runs, but does something different.

A default changes; a method starts throwing.

Binary

Code compiled against the old version fails at run time (MissingMethodException, TypeLoadException).

A removed or re-signed public member; a renamed assembly; a changed strong-name key.

The guidance: ✔️ DO minimize breaking changes in low-level libraries; ✔️ CONSIDER leaving new behaviour off by default behind a setting; ✔️ CONSIDER [Obsolete] with migration instructions before removing anything, and keeping obsolete members indefinitely when they are cheap; ✔️ CONSIDER publishing a major rewrite as a new package. Removing LedgerReader.ReadFile from Ledgerline is the binary break that package validation reports as CP0002.

Common mistakes

  • Shipping only netstandard2.0 — modern .NET consumers lose trimming annotations and newer APIs; add a net8.0 (or later) target.

  • Different public API per target framework — a consumer compiled against one TFM fails on another.

  • Exact or capped dependency versions — every consumer then hits NU1107/NU1608 when another package needs a newer version.

  • Polyfill dependencies on every TFM — unnecessary packages for modern .NET.

  • Changing the strong-name key "to rotate it" — a binary break for every consumer.