Migrating your project to the latest version of .NET can be a difficult task, especially when you need to maintain compatibility with older versions of .NET during the transition. Ultimately, we want to get our projects to be running on the newest and latest .NET, but this process often requires careful planning and execution so as to not impact our existing application.
Multi-targeting target framework allows you to build and test your project against multiple versions of .NET simultaneously, which can help you maintain your current application while progressively migrating to the latest .NET version. This will also allow you to identify and resolve compatibility issues early in the migration process, which will save time and effort overall.
This rule provides guidance on how to manage compatibility when transitioning between target frameworks.
Here are some strategies to help you manage compatibility between different frameworks:
You can specify multiple target frameworks in your project file using the TargetFrameworks property:
<PropertyGroup><TargetFrameworks>net48;net10.0</TargetFrameworks></PropertyGroup>
This approach lets you continue to maintain your current application while also building and testing for the desired target framework you aim to migrate to. Being able to build and test against both target frameworks makes compatibility issues more visible in CI (because both target frameworks will compiled).
When multi-targeting to migrate to net10, teams should weigh the pros and cons in regards of performance, maintenance and CI.
✅ Pros:
net10 while maintaining the existing codebase on net48, enabling a smoother transition for developers and users.net10 APIs in isolated files or projects without breaking existing consumers.❌ Cons:
These are temporary overheads during migration and will be fixed once the full migration is complete and you can remove the old target framework from TargetFrameworks.
To keep TargetFrameworks and related project properties consistent across many projects, define shared properties in a Directory.Build.props (or a repo-level .props) and reference them from each project. This centralises updates and avoids copy/paste drift.
Directory.Build.props:
<Project><PropertyGroup><LegacyTargetFramework>net48</LegacyTargetFramework><ModernTargetFramework>net10.0</ModernTargetFramework><ModernWindowsTargetFramework>net10.0-windows</ModernWindowsTargetFramework><!-- Common groups for cross-platform and Windows-only scenarios --><CommonTargetFrameworks>$(LegacyTargetFramework);$(ModernTargetFramework)</CommonTargetFrameworks><CommonTargetFrameworksWindows>$(LegacyTargetFramework);$(ModernWindowsTargetFramework)</CommonTargetFrameworksWindows><!-- Helper flags (correct spelling) --><IsLegacyCompatibilityBuild Condition="'$(TargetFramework)' == '$(LegacyTargetFramework)'">true</IsLegacyCompatibilityBuild><IsModernCompatibilityBuild Condition="'$(TargetFramework)' == '$(ModernTargetFramework)'">true</IsModernCompatibilityBuild><IsModernWindowsCompatibilityBuild Condition="'$(TargetFramework)' == '$(ModernWindowsTargetFramework)'">true</IsModernWindowsCompatibilityBuild></PropertyGroup></Project>
Project file usage:
<PropertyGroup><TargetFrameworks>$(CommonTargetFrameworks)</TargetFrameworks><!-- or for Windows-only migrations: $(CommonTargetFrameworksWindows) --></PropertyGroup><!-- Define per target framework symbols using the helper flags --><PropertyGroup Condition="'$(IsLegacyCompatibilityBuild)' == 'true'"><DefineConstants>$(DefineConstants);LEGACY_COMPATIBILITY</DefineConstants></PropertyGroup><PropertyGroup Condition="'$(IsModernCompatibilityBuild)' == 'true'"><DefineConstants>$(DefineConstants);MODERN_COMPATIBILITY</DefineConstants></PropertyGroup><!-- Example per target framework package references --><ItemGroup Condition="'$(IsModernCompatibilityBuild)' == 'true'"><PackageReference Include="Some.Modern.Package" Version="10.*" /></ItemGroup><ItemGroup Condition="'$(IsLegacyCompatibilityBuild)' == 'true'"><PackageReference Include="Some.Legacy.Package" Version="1.*" /></ItemGroup>
This approach provides a single place to declare common project properties, reduces merge conflicts, and makes bulk migrations easier.
During a migration you may want to keep the legacy TFM (for reference) but stop building it in CI and reduce local build time once approaching the end of the migration process. You can use a small MSBuild flag to opt-in/opt-out the legacy target framework without the need to remove code.
Directory.Build.props:
<PropertyGroup><LegacyTargetFramework>net48</LegacyTargetFramework><ModernTargetFramework>net10.0</ModernTargetFramework><IncludeLegacyTarget Condition="'$(IncludeLegacyTarget)'==''">true</IncludeLegacyTarget><!-- default: include legacy build --><TargetFrameworks Condition="'$(IncludeLegacyTarget)' == 'true'">$(LegacyTargetFramework);$(ModernTargetFramework)</TargetFrameworks><!-- exclude legacy only when the flag is false --><TargetFrameworks Condition="'$(IncludeLegacyTarget)' == 'false'">$(ModernTargetFramework)</TargetFrameworks></PropertyGroup>
When targeting Windows-only APIs (WinForms, WPF), it is preferred to use explicit Windows target frameworks so you can opt into platform-specific tooling and runtime behavior.
<PropertyGroup><TargetFrameworks>net48;net10.0-windows</TargetFrameworks><UseWPF>true</UseWPF><UseWindowsForms>true</UseWindowsForms></PropertyGroup>
✅ Good example - Using Windows-specific target frameworks
The main pros and cons of this approach are dependent on whether your goal is to migrate to a cross-platform framework (Linux/macOS) or to remain Windows-only.
✅ Pros:
net10.0-windows (App SDK, WinRT wrappers) without losing the old build.❌ Cons:
net48 or net10.0-windows -> net10.0)SupportedOSPlatform warnings may appear; developers must handle or suppress appropriately.When the Windows-specific codebase is small, you can isolate it with #if guards or partial classes; when large, it is recommended to separate into Windows-only projects to keep the shared libraries clean.
❔Decision guidance:
net10.0-windows (or a Windows-only project) to minimise rewrites, keep tooling and installers, and reduce short-term migration risk.net10) and extract Windows UI/interop into a separate Windows-only project.Ultimately, the decision to maintain Windows-specific target frameworks or migrate directly to cross-platform depends on the goals, timelines, and constraints of your migration project.
#if, #elif, #endif)You can use C# preprocessor directives to compile code exclusively for a specific target framework. This technique also simplifies the removal process during post-migration cleanup, especially for incompatible code segments. Whenever possible, consider using dependency injection or factory patterns to inject the appropriate implementation based on the target framework you are targeting. This approach promotes code flexibility and maintainability, as it abstracts away target framework-specific details.
public static class WebClientFactory{public static IWebClient GetWebClient(){#if NET48return new CustomWebClient();#elsereturn new CustomHttpClient();#endif}}
✅ Code: Good example - Using preprocessor directives and a factory pattern
Tip: Prefer explicit version checks when possible, as they scale better than #else:
#if NET// Use the modern .NET implementation#else// Use the .NET Framework implementation#endif
If you want #if NET48 to work reliably, define it in your project file (so it’s available when compiling the net48 target):
<PropertyGroup Condition="'$(TargetFramework)' == 'net48'"><DefineConstants>$(DefineConstants);NET48</DefineConstants></PropertyGroup>
✅ Code: Good example - Defining custom preprocessor symbols per target framework
You can use MSBuild conditions to add references to different libraries that are only compatible with a specific target framework. This enables you to manage references dynamically based on the target framework in use.
<ItemGroup Condition="'$(TargetFramework)' == 'net48'"><Reference Include="System.Web" /><Reference Include="System.Web.Extensions" /><Reference Include="System.Web.ApplicationServices" /></ItemGroup>
✅ Code: Good example - Using MSBuild conditions
In SDK-style projects, you can use a similar approach to separate target-specific NuGet packages:
<ItemGroup Condition="'$(TargetFramework)' == 'net48'"><PackageReference Include="Some.Legacy.Package" Version="1.*" /></ItemGroup><ItemGroup Condition="'$(TargetFramework)' == 'net10.0'"><PackageReference Include="Some.Modern.Package" Version="10.*" /></ItemGroup><ItemGroup><PackageReference Include="Common.Package" Version="5.*" /></ItemGroup>
✅ Code: Good example - Conditional NuGet packages per target framework
If the implementation differs a lot, keep the code in separate files and include them conditionally:
<ItemGroup Condition="'$(TargetFramework)' == 'net48'"><Compile Include="Compatibility\\WebClientFactory.net48.cs" /></ItemGroup><ItemGroup Condition="'$(TargetFramework)' == 'net10.0'"><Compile Include="Compatibility\\WebClientFactory.net10.cs" /></ItemGroup>
✅ Code: Good example - Conditional source files per target framework
Once the main branch has fully moved to the new target framework:
TargetFrameworks#if in domain/business logic)