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

TemplateUse it forKey property
consoleCLI tools, one-off jobsOutputType is Exe
classlibReusable code, domain modelsNo entry point, packed as a NuGet package
workerBackground services, queue consumersIncludes the generic host and a hosted service
web / webapiHTTP services, minimal APIsAdds the ASP.NET Core framework reference
xunit / nunit / mstestTest projectsPulls in a test SDK and runner
razor / blazorServer-rendered or interactive UIRazor SDK with view compilation
webappRazor Pages and MVCDepends 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.0 or net48. The platform suffix selects a platform-specific API surface.
  • netstandard2.0 is the widest portable target and the right choice for a library that must also be consumable from .NET Framework.
  • ImplicitUsings is convention, not magic: it adds a set of namespace imports per SDK. Turn it off if the implicit surface confuses reviewers.
  • Nullable turned 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.

What .NET is: runtime, libraries and support policy NuGet and dependency management

Last refreshed 2026-09-18.