Multi-Targeting and Conditional Builds: TargetFrameworks, #if, Platform APIs and Libraries Across Versions
To build a library for several .NET versions you write TargetFrameworks, but if Directory.Build.props sets a single TargetFramework the project silently builds one target; you have to clear it in the project. We build Bookstore.Contracts for net8.0 and net10.0 with a .NET 8 fallback for .NET 10's NumericOrdering, run the tests on both frameworks, and show the cost of netstandard2.0, CA1416 platform checks and a consumer still on .NET 8, with real output.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404); the net8.0 output ran on the .NET 8.0.34 runtime, and the Reporting samples ran on SDK 11.0.100.
Guess First
A service still on .NET 8 also wants to use Bookstore.Contracts, the package that holds Bookstore's shared types. We add one line to the project file:
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
How many dlls does dotnet build src/Bookstore.Contracts produce? The answer should be two, but:
Bookstore.Contracts -> C:\src\Bookstore\artifacts\bin\Bookstore.Contracts\debug_net10.0\Bookstore.Contracts.dll
Only net10.0 was built, and the SDK gave no warning. The reason is the Directory.Build.props we set up in post 3, which sets TargetFramework as a single value. When TargetFramework is set, MSBuild treats the project as single-targeted and ignores TargetFrameworks. In this post we use Bookstore, the online bookstore used as the shared example across this site's series, to build one library for several frameworks: TargetFrameworks, framework-conditional code and package references, platform checks, and tests that run on every framework.
TargetFrameworks: One Project, Several Builds
In a multi-targeted project MSBuild first runs an outer build, which then starts a separate inner build for each TFM in the list with the TargetFramework global property set. So for each TFM the project is evaluated, compiled and output separately. Once we clear the single value from the shared file in the project file, the outer build kicks in:
<PropertyGroup>
<!-- Clear the single TargetFramework from Directory.Build.props, otherwise TargetFrameworks is ignored. -->
<TargetFramework></TargetFramework>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<Version>1.1.0</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
Bookstore.Contracts -> C:\src\Bookstore\artifacts\bin\Bookstore.Contracts\debug_net10.0\Bookstore.Contracts.dll
Bookstore.Contracts -> C:\src\Bookstore\artifacts\bin\Bookstore.Contracts\debug_net8.0\Bookstore.Contracts.dll
In the artifacts layout, the TFM was added to the pivot folder (debug_net8.0). dotnet msbuild -getProperty:IsCrossTargetingBuild now returns true. This problem shows up sooner or later in every repo with a shared Directory.Build.props; the alternative is not writing a single value in the shared file at all and leaving each project its own TFM. Most projects in Bookstore are single-targeted, so we chose to keep the shared value and clear it in the exceptions.
The package carries the output of both frameworks too. We set the version to 1.1.0 by hand here; automatic ways to generate versions are in the Versioning and Release Management series.
dotnet pack src/Bookstore.Contracts
lib/net10.0/Bookstore.Contracts.dll
lib/net10.0/Bookstore.Contracts.xml
lib/net8.0/Bookstore.Contracts.dll
lib/net8.0/Bookstore.Contracts.xml
NuGet picks the folder closest to the consuming project's TFM: net10.0 and net11.0 projects get lib/net10.0, net8.0 and net9.0 projects get lib/net8.0.
Which TFMs? net8.0, net10.0 and netstandard2.0
A library's consumers decide its targets. net10.0 gives the newest APIs and the best performance; net8.0 is needed for older services; netstandard2.0 only makes sense for libraries that must also run on platforms that aren't .NET Core, such as .NET Framework. Support for .NET 8 ended on November 10, 2026, but services on an out-of-support runtime live on in organizations for a while; supporting them for one more version of a shared library makes their migration easier.
To see the cost of netstandard2.0, we added it to the list and built. The first error came from the language version, because the SDK picks C# 7.3 for netstandard2.0, which doesn't support the Nullable setting from Directory.Build.props:
CSC : error CS8630: Invalid 'nullable' value: 'Enable' for C# 7.3. Please use language version '8.0' or greater.
After raising LangVersion the language itself is fine, but the types the compiler expects and BCL APIs are missing:
C:\src\Bookstore\src\Bookstore.Contracts\BookSummary.cs(9,39): error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported
C:\src\Bookstore\src\Bookstore.Contracts\IsbnChecksum.cs(6,33): error CS0246: The type or namespace name 'ReadOnlySpan<>' could not be found (are you missing a using directive or an assembly reference?)
...
7 Error(s)
This is where the polyfill approach comes in: filling in the missing pieces, only for the old target, with a package or with your own code. With a framework-conditional PackageReference we added PolySharp, which generates the types records need as source code, and System.Memory, which brings the Span types, to netstandard2.0 only:
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="PolySharp" PrivateAssets="all" />
<PackageReference Include="System.Memory" />
</ItemGroup>
C:\src\Bookstore\src\Bookstore.Contracts\Isbn.cs(20,25): error CS8602: Dereference of a possibly null reference.
C:\src\Bookstore\src\Bookstore.Contracts\Isbn.cs(21,53): error CS0117: 'char' does not contain a definition for 'IsAsciiDigit'
...
5 Error(s)
The language features are covered now, but two problems remain. char.IsAsciiDigit is an API that came with .NET 7 and doesn't exist in netstandard2.0. The old BCL also lacks the nullable attributes on string.IsNullOrWhiteSpace, so the compiler raises a null warning even after the check. We could solve these with our own code or broader polyfill packages too, but Bookstore has no consumer on .NET Framework. We took netstandard2.0 out of the list. Every target you add has a cost: a separate build, a separate test run and a separate source of bugs.
#if and Version-Specific APIs
Bookstore's catalog has book series: "Volume 2" has to come before "Volume 10", but ordinary string comparison puts "1" before "2". .NET 10 added CompareOptions.NumericOrdering for this; .NET 8 doesn't have it. The SDK defines compilation symbols such as NET8_0, NET8_0_OR_GREATER and NET10_0_OR_GREATER for each TFM by itself:
public static class BookTitleComparer
{
public static IComparer<string> Instance { get; } = Create();
private static IComparer<string> Create()
{
#if NET10_0_OR_GREATER
return StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering | CompareOptions.IgnoreCase);
#else
return StringComparer.OrdinalIgnoreCase;
#endif
}
}
In the first version we put plain comparison in for .NET 8, thinking "at least it compiles". Once the test project ran on both frameworks, the difference showed up right away:
failed Bookstore.Contracts.Tests.BookTitleComparerTests.Sorts_volume_numbers_numerically (11ms)
from C:\src\Bookstore\artifacts\bin\Bookstore.Contracts.Tests\debug_net8.0\Bookstore.Contracts.Tests.dll (net8.0|x64)
Assert.Equal() Failure: Collections differ at index 2
↓ (pos 7)
Expected: "Volume 2"
Actual: "Volume 10"
↑ (pos 7)
The same code passed on net10.0 and failed on net8.0. Had the tests run only on net10.0, this bug would have reached the production of the service on .NET 8. For .NET 8 we wrote a small NaturalTitleComparer that compares runs of digits as numbers. Instead of burying the class inside #if, we put it in a separate folder and included it in the build with an MSBuild condition:
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">
<!-- net10.0 and later have CompareOptions.NumericOrdering; the fallback is only for older frameworks. -->
<Compile Remove="Compat/**" />
</ItemGroup>
dotnet msbuild src/Bookstore.Contracts -getItem:Compile -p:TargetFramework=net8.0
dotnet msbuild src/Bookstore.Contracts -getItem:Compile -p:TargetFramework=net10.0
"Identity": "BookSummary.cs",
"Identity": "BookTitleComparer.cs",
"Identity": "Compat/NaturalTitleComparer.cs",
"Identity": "Isbn.cs",
"Identity": "IsbnChecksum.cs",
...
"Identity": "BookSummary.cs",
"Identity": "BookTitleComparer.cs",
"Identity": "Isbn.cs",
"Identity": "IsbnChecksum.cs",
IsTargetFrameworkCompatible asks "is this TFM compatible with net10.0?", and keeps working when net11.0 is added. Equality checks such as '$(TargetFramework)' == 'net8.0' silently fall into the wrong branch when a new target is added. #if is fine for one-line differences; moving version-specific code longer than a few lines into a separate file keeps the main file readable. The single #if left in BookTitleComparer.cs just says which implementation is chosen.
Running the Tests on Every TFM
The test project targets both frameworks too and carries the same line that clears TargetFramework:
<PropertyGroup>
<TargetFramework></TargetFramework>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
</PropertyGroup>
dotnet test --project tests/Bookstore.Contracts.Tests
C:\src\Bookstore\artifacts\bin\Bookstore.Contracts.Tests\debug_net10.0\Bookstore.Contracts.Tests.dll (net10.0|x64) passed (125ms)
C:\src\Bookstore\artifacts\bin\Bookstore.Contracts.Tests\debug_net8.0\Bookstore.Contracts.Tests.dll (net8.0|x64) passed (128ms)
Test run summary: Passed!
total: 16
failed: 0
succeeded: 16
skipped: 0
The number of tests doubled, because each test ran separately on each framework. To run the net8.0 tests the machine needs the .NET 8 runtime; in CI you need to add that runtime to the SDK installation. Without the runtime the tests fail rather than being skipped silently. This is the simplest safeguard against code that is built for one TFM and never run on another.
OS-Specific TFMs and Platform Checks
Besides the framework, you can target an operating system as well. A TFM such as net10.0-windows opens Windows-specific APIs to the project; that is why WPF and Windows Forms projects end in -windows. You can also check the platform at run time instead of in the TFM, and the SDK's platform compatibility analyzer (CA1416) checks this during the build. When we added a Windows-only call to bookstore-admin:
static int Beep()
{
Console.Beep(800, 200);
return 0;
}
C:\src\Bookstore\tools\Bookstore.Admin\Program.cs(56,5): error CA1416: This call site is reachable on all platforms. 'Console.Beep(int, int)' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416) [C:\src\Bookstore\tools\Bookstore.Admin\Bookstore.Admin.csproj]
The analyzer noticed that the call could also run on Linux or macOS, and TreatWarningsAsErrors turned it into an error. Guarding the call with OperatingSystem.IsWindows() made the warning go away, because the analyzer recognizes this check:
static int Beep()
{
if (OperatingSystem.IsWindows())
{
Console.Beep(800, 200);
}
return 0;
}
Switching the project to net10.0-windows also removes the warning, but it also removes the option of running the tool in a Linux container. With this TFM the build can still run outside Windows; the SDK writes the target platform into the project properties:
{
"Properties": {
"TargetPlatformIdentifier": "Windows",
"TargetPlatformVersion": "7.0",
"SupportedOSPlatformVersion": "7.0"
}
}
The rule: if the whole app depends on one platform, use an OS-specific TFM; if only a few calls do, use a run-time check. bookstore-admin must run everywhere, so this experiment didn't make it into the code.
Configurations and Your Own Compilation Symbols
Besides the TFM symbols, there are symbols that come from the configuration. You can ask for the value after a build with -getProperty:
dotnet build src/Bookstore.Contracts -f net10.0 -getProperty:DefineConstants
dotnet build src/Bookstore.Contracts -f net10.0 -c Release -getProperty:DefineConstants
TRACE;DEBUG
TRACE;RELEASE
The TFM symbols don't appear in this list; the SDK passes them to the compiler separately. When adding your own symbol, keep the existing list: <DefineConstants>$(DefineConstants);BOOKSTORE_DIAGNOSTICS</DefineConstants>. If you forget the $(DefineConstants); prefix you erase the symbols the SDK added, and #if DEBUG blocks aren't compiled even in a Debug build.
Compilation symbols decide at build time; the same package brings the same behavior to everyone. If you want to switch a feature on or off per environment or customer, that is the job not of a compilation symbol but of configuration read at run time or a feature flag. Keep #if for platform and framework differences.
Bookstore: The Export Service Still on .NET 8
The reporting team's old export service under samples/reporting targets net8.0 and exports series books in shelf order for the accounting system. With version 1.0.0 of the package, restore ended like this:
C:\src\Bookstore\samples\reporting\Bookstore.LegacyExport\Bookstore.LegacyExport.csproj : error NU1202: Package Bookstore.Contracts 1.0.0 is not compatible with net8.0 (.NETCoreApp,Version=v8.0). Package Bookstore.Contracts 1.0.0 supports: net10.0 (.NETCoreApp,Version=v10.0) [C:\src\Bookstore\samples\reporting\Bookstore.Reporting.slnx]
The error message states clearly which frameworks the package supports. We packed 1.1.0, pushed it to the feed from the previous post and raised the version in Reporting's Directory.Packages.props. The first restore still couldn't find 1.1.0: NuGet kept the feed's package list in its HTTP cache, so it didn't see a version pushed a few minutes earlier yet. We tried again bypassing the cache:
dotnet restore --no-http-cache
dotnet run --project Bookstore.LegacyExport
dotnet run --project Bookstore.Reporting
Restored C:\src\Bookstore\samples\reporting\Bookstore.LegacyExport\Bookstore.LegacyExport.csproj (in 33 ms).
Restored C:\src\Bookstore\samples\reporting\Bookstore.Reporting\Bookstore.Reporting.csproj (in 33 ms).
The Hobbit
volume 1
Volume 2
Volume 10
Contracts 1.1.0.0 on 8.0.34
...
Contracts 1.1.0.0 on 10.0.15
The same package reached the two services with two different dlls: the export service runs on .NET 8.0.34 with the fallback comparer from lib/net8.0, and Reporting runs on .NET 10 with NumericOrdering from lib/net10.0. The order is the same in both. Because the version changed, both lock files were updated and went into the commit.
In Production
Every extra TFM increases CI time and the number of tests. In a multi-targeted library, CI must build and test every target; but review the target list against the consumers regularly and remove targets nobody uses. Dropping the .NET 8 target once the last service using it has migrated is a job for the next major version.
Test the behavior differences in fallback code. NaturalTitleComparer compares ASCII digits as numbers and orders letters ordinally; the culture-aware comparison in .NET 10 can give different results for punctuation and accented letters. In Bookstore's samples both produce the same order; pinning that difference down with a test protects it from someone changing it one day.
Targeting an out-of-support runtime means that service gets no security patches. Building the library for .NET 8 makes migration easier, but it doesn't replace it.
Common Pitfalls
Adding TargetFrameworks to a project while Directory.Build.props sets TargetFramework. The project silently builds a single target; you won't notice without looking at the output folder or the package's lib folder.
Code that becomes unreadable with #ifs. If a method has three separate #if blocks, move the version-specific part into a separate file or type and include it with an MSBuild condition.
Code that is built for one TFM and never tested on another. If the library is multi-targeted, the test project must be multi-targeted too; otherwise the #else branch ships to production untested.
TFM conditions written as equality. '$(TargetFramework)' == 'net10.0' falls into the wrong branch when a new target is added; use "at least" checks such as IsTargetFrameworkCompatible or #if NET10_0_OR_GREATER.
Being surprised that restore doesn't see a freshly pushed version. NuGet's HTTP cache keeps the package list for a while; bypass it with --no-http-cache.
A Small Experiment
★☆☆ Add net9.0 to the targets of Bookstore.Contracts and run dotnet build and dotnet test. Which pivot folders were created, and was the Compat folder compiled in the net9.0 build?
★★☆ Add tests to NaturalTitleComparer that compare "Volume 02" with "Volume 2" and "Dune" with "dune"; make sure you get the same result on both frameworks. If the results differ, decide which one you consider correct and write the test accordingly.
★★★ Add the netstandard2.0 target back and fix the remaining errors with your own code: write a small helper in the Compat folder for char.IsAsciiDigit and change the flow for the nullable warning. Then note the extra cost this target adds to the package and the tests.
In this post we built and packaged a library for several frameworks. In the next post we look at the output itself: we publish the same bookstore-admin tool as framework-dependent, self-contained, single-file and Native AOT, and compare their sizes and startup times.