Project types, target frameworks and multi-targeting
Choosing the right project type and the right target framework is the cheapest architectural decision you will make, and the hardest to undo.
Project types and their defaults
| Template | Use it for | Key property |
|---|---|---|
console | CLI tools, one-off jobs | OutputType is Exe |
classlib | Reusable code, domain models | No entry point, packed as a NuGet package |
worker | Background services, queue consumers | Includes the generic host and a hosted service |
web / webapi | HTTP services, minimal APIs | Adds the ASP.NET Core framework reference |
xunit / nunit / mstest | Test projects | Pulls in a test SDK and runner |
razor / blazor | Server-rendered or interactive UI | Razor SDK with view compilation |
webapp | Razor Pages and MVC | Depends on the ASP.NET Core shared framework |
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- One or more target frameworks; semicolons separate them -->
<TargetFrameworks>net8.0;net48</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>CS1591</WarningsNotAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<!-- Conditional items, not conditional code, wherever the SDK allows it -->
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
</Project>- Target frameworks are named
net8.0,net8.0-windows,netstandard2.0ornet48. The platform suffix selects a platform-specific API surface. netstandard2.0is the widest portable target and the right choice for a library that must also be consumable from .NET Framework.ImplicitUsingsis convention, not magic: it adds a set of namespace imports per SDK. Turn it off if the implicit surface confuses reviewers.Nullableturned on in an existing project produces a wall of warnings. Turn it on per file, or accept a period of clean-up, but do not leave it half enabled.
Multi-targeting in practice
// Runtime feature detection, with the compiler enforcing that both branches exist
#if NET8_0_OR_GREATER
using System.Text.Json;
#else
using Newtonsoft.Json;
#endif
public static class Json
{
public static string Serialise<T>(T value)
{
#if NET8_0_OR_GREATER
return JsonSerializer.Serialize(value);
#else
return JsonConvert.SerializeObject(value);
#endif
}
}// Prefer a source-level abstraction to conditional compilation where you can.
// This keeps the public API identical on every target.
public interface IJsonCodec
{
string Serialise<T>(T value);
}
public sealed class SystemTextJsonCodec : IJsonCodec
{
public string Serialise<T>(T value) =>
System.Text.Json.JsonSerializer.Serialize(value);
}
public sealed class NewtonsoftCodec : IJsonCodec
{
public string Serialise<T>(T value) =>
Newtonsoft.Json.JsonConvert.SerializeObject(value);
}⚠️
Every extra target framework doubles the build and test matrix and quietly doubles the support surface. Multi-target only when a real consumer needs it, and record which consumer that is — an unused target is dead weight that will block a future upgrade.
FAQ
Can a library target net8.0 only?
Yes, and it should if every consumer is on modern .NET. Multi-target down to netstandard2.0 only when a .NET Framework or older consumer genuinely exists.
What is the difference between LangVersion and the target framework?
LangVersion selects the compiler's syntax; the target framework selects the API surface and the runtime. You can use C# 12 syntax while targeting an older framework, but the APIs still have to be available.
Related
What .NET is: runtime, libraries and support policy NuGet and dependency management
Last refreshed 2026-09-18.