Skip to content

MSBuild & the .NET Build System

Key Points

  • TargetFramework is just a moniker (e.g. net10.0) — the SDK derives TargetFrameworkIdentifier/Version from it. OS-specific TFMs like net10.0-windows add platform APIs on top of the base.
  • Central Package Management (CPM) with Directory.Packages.props is the modern way to pin NuGet versions across a solution. One file, every project; transitive pinning becomes opt-in via CentralPackageTransitivePinningEnabled.
  • Directory.Build.props / Directory.Build.targets auto-import upward from the project — your "global" settings file. Multiple parent files require explicit <Import>.
  • PublishAot=true implies trimming, AOT analyzers, and self-contained publish; IsAotCompatible=true is the signal that this library is AOT-safe and turns on the analyzers.
  • RuntimeIdentifier (RID) no longer implies SelfContained since .NET 8. You must set SelfContained explicitly when you want a self-contained deployment without PublishAot/PublishTrimmed.
  • Multi-targeting (<TargetFrameworks>net8.0;net10.0</TargetFrameworks>) gives you $(TargetFramework) conditions in props/targets — including Directory.Packages.props for per-TFM package versions.

Concepts (deep dive)

The shape of a modern .NET project

Modern .NET uses SDK-style projects: tiny .csproj files that opt into a Microsoft-published SDK (typically Microsoft.NET.Sdk or Microsoft.NET.Sdk.Web). The SDK supplies the build logic; your .csproj only declares what's different.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

That's it. No <ItemGroup> listing every .cs file (the SDK globs them); no boilerplate references; no targets file. The SDK auto-includes *.cs under the project directory.

How the SDK assembles your build

        ┌─────────────────────────────────────────────────────────────┐
        │              dotnet build / dotnet publish                  │
        └────────────────────────────┬────────────────────────────────┘
                  ┌──────────────────┼─────────────────────┐
                  │                  │                     │
        ┌─────────▼────────┐  ┌──────▼──────────┐  ┌───────▼─────────┐
        │  YourProject.    │  │ Directory.      │  │ Directory.      │
        │  csproj          │  │ Build.props     │  │ Packages.props  │
        │  (SDK="...")     │  │ (auto-imported  │  │ (CPM, optional) │
        └─────────┬────────┘  │  bottom-up)     │  └───────┬─────────┘
                  │           └──────┬──────────┘          │
                  │                  │                     │
                  └──────────────────┼─────────────────────┘
                          ┌──────────▼──────────┐
                          │  Microsoft.NET.Sdk  │
                          │  (props + targets   │
                          │   from .NET SDK)    │
                          └──────────┬──────────┘
                              ┌──────▼──────┐
                              │  MSBuild    │
                              │  evaluation │
                              └──────┬──────┘
                         ┌───────────┼───────────┐
                         │           │           │
                    ┌────▼───┐  ┌────▼───┐  ┌────▼───┐
                    │ Compile│  │ Restore│  │ Publish│
                    │ (Roslyn)│ │ (NuGet)│  │ (deps  │
                    └────────┘  └────────┘  │  json) │
                                            └────────┘

The order matters: Directory.Build.props is imported before SDK props (so you can set defaults the SDK reads); Directory.Build.targets is imported after SDK targets (so you can override targets after the SDK defines them).

TargetFramework, TFM, and OS-specific TFMs

TargetFramework is an alias. The SDK turns it into three properties:

Alias TargetFrameworkIdentifier TargetFrameworkVersion Platform
net10.0 .NETCoreApp v10.0 (none)
net10.0-windows .NETCoreApp v10.0 windows
net10.0-android .NETCoreApp v10.0 android
netstandard2.0 .NETStandard v2.0 (none)

OS-specific TFMs include the base TFM's API surface plus the platform additions. So net10.0-windows has everything in net10.0 plus the Windows Desktop / WinUI / WPF / WinForms surfaces.

SupportedOSPlatformVersion is separate from the TFM and gates platform-version warnings (e.g., calling a Win11-only API from a project that supports Win10).

Multi-targeting

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

The build runs once per TFM. Inside props/targets you can write $(TargetFramework)-conditioned settings:

<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
  <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
  <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
</ItemGroup>

Common reasons to multi-target:

  1. A library that wants to support an LTS (net8.0) plus the latest (net10.0).
  2. A library that targets netstandard2.0 for legacy consumers plus a modern TFM for performance optimizations only available in newer runtimes.
  3. Conditional features that use newer language/library APIs only on newer TFMs.

Central Package Management (CPM)

Without CPM, every project lists <PackageReference Include="X" Version="..." /> and version drift across the solution is constant low-grade pain. CPM moves the version pinning into a single Directory.Packages.props:

<!-- Directory.Packages.props at the solution root -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>

  <ItemGroup>
    <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
    <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
    <PackageVersion Include="xunit" Version="2.9.0" />
  </ItemGroup>
</Project>

Project files then become:

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Logging" />
  <PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>

no version on the project's PackageReference.

CentralPackageTransitivePinningEnabled=true extends pinning to transitive dependencies, promoting them to "explicit" status in the produced nuspec so downstream consumers see the same versions.

💡 Senior insight: CPM is the single biggest reduction in NuGet drift you can make in a solution with more than ~5 projects. Treat it as default for any solution older than a quarter.

Directory.Build.props / .targets

These files auto-import for every project under their containing folder. Closest-first wins:

solution-root/
├── Directory.Build.props          (applies to ALL projects)
├── src/
│   ├── Directory.Build.props      (applies to projects under src/)
│   ├── ProjectA/ProjectA.csproj
│   └── ProjectB/ProjectB.csproj
└── tests/
    └── ProjectA.Tests/ProjectA.Tests.csproj

Common uses:

<!-- Directory.Build.props at solution root -->
<Project>
  <PropertyGroup>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
    <DebugType>portable</DebugType>
    <DeterministicSourcePaths>true</DeterministicSourcePaths>
  </PropertyGroup>
</Project>

⚠️ Gotcha: the auto-import is by file name. If you have a non-MSBuild file named Directory.Build.props somewhere in a parent directory, MSBuild will try to import it and fail. Rename or move.

RIDs (Runtime Identifiers)

A RID describes a target runtime: linux-x64, win-x64, osx-arm64, linux-musl-arm64, etc. RIDs gate which native dependencies (e.g., SQLite, image libraries) NuGet picks up.

Set RuntimeIdentifier (one) or RuntimeIdentifiers (semicolon-separated) on a project. The latter is mainly for libraries that want to ship native assets for multiple platforms.

⚠️ .NET 8 breaking change: setting RuntimeIdentifier on dotnet build no longer implies SelfContained=true. It only implies self-contained at dotnet publish time, and only when the target is explicitly self-contained, AOT, or trimmed. If you want a self-contained build, set SelfContained=true explicitly.

NativeAOT and trimming knobs

Property Effect
PublishAot=true Full NativeAOT — single native binary, no JIT, requires self-contained, full trimming.
PublishTrimmed=true Trim unused IL during publish; smaller output, JIT still present.
IsAotCompatible=true Library opts in: turns on IsTrimmable, EnableTrimAnalyzer, EnableAotAnalyzer. Use this on every library you ship.
EnableTrimAnalyzer=true Just the trim analyzer (warnings on incompatible patterns).
EnableAotAnalyzer=true Just the AOT analyzer.
IsTrimmable=true Marks the assembly as trim-safe — consumers' trimmer treats it as such.
TrimMode=full | partial Aggressiveness; default full for top-level, partial for libraries.

Cross-references: full deep-dive in NativeAOT & Trimming.


How it works under the hood

MSBuild evaluates a project in two phases:

   ┌──────────────────────────────────┐
   │  Phase 1: Property/Item evaluation│
   │  — top to bottom, last-write-wins │
   │  — properties and items computed  │
   │  — conditions evaluated lazily    │
   └──────────────────┬───────────────┘
   ┌──────────────────▼───────────────┐
   │  Phase 2: Target execution        │
   │  — dependency-ordered             │
   │  — Inputs/Outputs drive incremental│
   │  — Tasks (compiled .NET classes) run│
   └──────────────────────────────────┘

A project's full evaluation includes (in order):

  1. SDK props (Microsoft.NET.Sdk props, etc.)
  2. The first Directory.Build.props walking up from the project folder
  3. The project's <PropertyGroup> and <ItemGroup>
  4. SDK targets
  5. The first Directory.Build.targets walking up

Inside the project, properties evaluate top-to-bottom; later writes win unless Condition is false.

💡 Diagnostics: dotnet build -bl produces a binary log that you can open in MSBuild Structured Log Viewer. Best tool for understanding "why did MSBuild use that version of that package".


Code: correct vs wrong

❌ Wrong: setting RuntimeIdentifier and assuming self-contained

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
  <!-- Wrong assumption: this used to imply SelfContained=true. -->
  <!-- Since .NET 8 it does not. -->
</PropertyGroup>

Result: framework-dependent app silently produced; dotnet runtime required on the target machine.

✅ Correct

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
  <SelfContained>true</SelfContained>
  <!-- Or, if you want NativeAOT (which DOES imply self-contained): -->
  <!-- <PublishAot>true</PublishAot> -->
</PropertyGroup>

❌ Wrong: per-project version pinning under CPM

<!-- With CPM enabled, this throws NU1008 -->
<ItemGroup>
  <PackageReference Include="Serilog" Version="4.0.0" />
</ItemGroup>

✅ Correct: pin centrally, reference without version

<!-- Directory.Packages.props -->
<ItemGroup>
  <PackageVersion Include="Serilog" Version="4.0.0" />
</ItemGroup>

<!-- Project.csproj -->
<ItemGroup>
  <PackageReference Include="Serilog" />
</ItemGroup>

❌ Wrong: editing the binary log path inline

dotnet build /bl:"$(pwd)/build.binlog"

Doesn't work on Windows in PowerShell because $(pwd) is bash. Use:

dotnet build -bl
# binlog written to msbuild.binlog in the working directory

Design patterns for this topic

Pattern 1 — "Solution-wide defaults via Directory.Build.props"

  • Intent: define language/analyzer/format defaults once for an entire solution.
  • When to use it: every solution. There is no good reason not to.
  • When NOT to use it: if the solution genuinely has heterogeneous policy across projects (e.g., a library project that targets netstandard2.0 and can't enable Nullable).
  • Code sketch: see "Common uses" above.
  • Trade-offs: projects can still override with <PropertyGroup> blocks; nothing locks them in. The pattern is defaults, not enforcement. For enforcement, use EditorConfig or a custom analyzer.

Pattern 2 — "Library opt-in to AOT readiness"

  • Intent: make a library safely consumable by AOT/trimmed apps.
  • When to use it: any library you publish, especially those used by ASP.NET Core hosting, Blazor WASM, or NativeAOT apps.
  • When NOT to use it: libraries that fundamentally require runtime reflection emit (rare; if you're sure, document with [RequiresDynamicCode]).
  • Code sketch:
<PropertyGroup>
  <IsAotCompatible Condition="'$(TargetFramework)' == 'net10.0'">true</IsAotCompatible>
</PropertyGroup>
  • Trade-offs: you sign up for satisfying the trim and AOT analyzers, which means avoiding Type.MakeGenericType, Activator.CreateInstance(Type) without DAM annotations, etc. Worth it: you become viable in AOT contexts.

Pattern 3 — "Multi-target for ABI compatibility, single-target for productivity"

  • Intent: support multiple TFMs only when there's a real consumer.
  • When to use it: open-source libraries with broad consumer base; libraries in monorepos that bridge legacy and modern apps.
  • When NOT to use it: internal apps. Multi-targeting makes builds slower and adds $(TargetFramework) conditioning everywhere.
  • Trade-offs: multi-target only what you must.

Pattern 4 — "Repo build customization via Directory.Build.{props,targets}"

  • Intent: apply repo-wide rules (e.g., embed git commit hash in assemblies; set deterministic build properties; configure source-link).
  • When to use it: every repo with more than 2 projects.
  • Code sketch:
<!-- Directory.Build.props -->
<Project>
  <PropertyGroup>
    <Deterministic>true</Deterministic>
    <ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>
    <PublishRepositoryUrl>true</PublishRepositoryUrl>
    <EmbedUntrackedSources>true</EmbedUntrackedSources>
  </PropertyGroup>
</Project>

Pros & cons / trade-offs

Aspect Pros Cons
SDK-style projects Tiny .csproj, sane defaults, no-friction Less explicit — sometimes harder to predict what's included
CPM One-source-of-truth versions; killer feature for large solutions Dotnet CLI tools that don't understand CPM (rare in 2026) report misleading errors
Multi-targeting Library reach across runtime versions Build time multiplies by TFM count; per-TFM bug hunting
Directory.Build.props Repo-wide defaults Implicit — newcomers don't see the inheritance unless they look
NativeAOT Smallest binaries; fastest startup; no JIT No reflection-emit; analyzer warnings can block adoption; some libraries don't support

When to use / when to avoid

  • Use CPM in any solution with >5 projects. (Smaller solutions: still fine, just less leverage.)
  • Use multi-targeting only when you genuinely have consumers on multiple TFMs. Don't multi-target speculatively.
  • Use IsAotCompatible=true in every library you ship to NuGet, unless the library cannot be made AOT-safe.
  • Avoid <Reference Include="../../path.dll" /> — file references break versioning. Use ProjectReferences for in-repo, PackageReferences for everything else.
  • Avoid editing obj/ or bin/ — they are ephemeral. Anything you put there is a build artifact, not a source.

Interview Q&A

Q1. What's the difference between TargetFramework and TargetFrameworkMoniker? TargetFramework is the alias you write (e.g., net10.0). The SDK derives TargetFrameworkIdentifier (.NETCoreApp), TargetFrameworkVersion (v10.0), and the platform (e.g., windows) from it. The full moniker TargetFrameworkMoniker is .NETCoreApp,Version=v10.0. NuGet compatibility uses the full moniker, not the alias.

Q2. What does Directory.Build.props do, and where does MSBuild look for it? It's auto-imported by MSBuild from the closest parent directory walking upward from the .csproj. Multiple files at different levels are not auto-chained — only the closest one. To inherit from parents, use <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />.

Q3. With Central Package Management enabled, can a project override a centrally-pinned version? Yes — set <PackageReference Include="X" VersionOverride="Y" /> on the project. This is the escape hatch when a single project genuinely needs a different version. Use sparingly; if you find yourself doing it often, the central version is wrong.

Q4. What's the difference between PublishTrimmed and PublishAot? PublishTrimmed removes unused IL but keeps the JIT and runtime. PublishAot does full Native AOT compilation: no JIT, no reflection-emit, statically linked native binary. AOT implies trimming; trimming does not imply AOT. AOT has stricter analyzer requirements.

Q5. What does IsAotCompatible=true do? It's a library-author signal that the library is safe to consume in AOT scenarios. It defaults IsTrimmable=true, EnableTrimAnalyzer=true, EnableAotAnalyzer=true. Consumers see fewer trim/AOT warnings when referencing your library.

Q6. Why did setting RuntimeIdentifier to linux-x64 stop producing a self-contained build? .NET 8 changed the default. Setting RID alone means "use this RID's native assets" but does not imply self-contained. You must set SelfContained=true explicitly, or use PublishAot=true/PublishTrimmed=true (which do imply self-contained at publish time).

Q7. How does multi-targeting interact with Directory.Packages.props? You can have per-TFM <PackageVersion> blocks via Condition="'$(TargetFramework)' == 'X'". Each TFM evaluates Directory.Packages.props independently with its own $(TargetFramework).

Q8. What's the difference between Directory.Build.props and Directory.Build.targets? .props is imported before SDK props (so you set defaults the SDK reads). .targets is imported after SDK targets (so you can override targets). 90% of customizations go in .props. Only put it in .targets if you're hooking into a specific target like BeforeBuild/AfterPublish.

Q9. How would you embed the git commit hash in your assembly? Set SourceRevisionId in Directory.Build.props from the CI environment, or compute it at build time with a custom target. Combined with Deterministic=true and ContinuousIntegrationBuild=true, you get reproducible builds with the commit hash baked into AssemblyInformationalVersion.

Q10. What is a binary log and when would you produce one? dotnet build -bl writes msbuild.binlog, a complete record of MSBuild evaluation and execution. You produce one when diagnosing a build problem — particularly "why did MSBuild pick this version of this package" or "why is this target running" or "why is my source file not getting compiled". Open in MSBuild Structured Log Viewer.

Q11. What's a "captive" Microsoft.Extensions.* version, and how do CPM and the SDK help? Older patterns let one project pull, say, Microsoft.Extensions.Logging 6.0 while another pulls 8.0; the conflict resolves at runtime. CPM forces a single version across the solution and surfaces the conflict at build time.

Q12. When would you NOT use Directory.Build.props? Practically never. The only cases I'd skip it: a solution of one tiny project, or a vendored third-party project where you don't want to subject it to your conventions. In every other case, use it.

Q13. What does <TreatWarningsAsErrors>true</TreatWarningsAsErrors> do at the build-system level, and how would you tune it? It promotes compiler warnings to errors. Tune it via <WarningsNotAsErrors>CS1591</WarningsNotAsErrors> (a list of warning codes that stay as warnings even with WarningsAsErrors). For analyzer-style escape hatches, use <NoWarn> for full suppression. For per-file fine-grained suppression, use #pragma warning disable.


Gotchas / common mistakes

  • ⚠️ Putting versions in <PackageReference> after enabling CPM — produces NU1008 errors. Move them to Directory.Packages.props.
  • ⚠️ Assuming RuntimeIdentifier implies self-contained — it doesn't, since .NET 8.
  • ⚠️ Mixing <Reference Include="..." /> (file references) with <PackageReference> — file references bypass NuGet, are platform-specific, and break in CI when paths differ.
  • ⚠️ Multi-targeting "just in case" — doubles or triples build time, doubles your debugging surface.
  • ⚠️ Editing Microsoft.NET.Sdk files — never. SDK files live under the dotnet install. Override via your project or Directory.Build.props/.targets.
  • ⚠️ Forgetting Deterministic=true + ContinuousIntegrationBuild=true in CI — your assemblies will have machine-specific source paths embedded in PDBs. Bad for reproducibility.
  • ⚠️ Directory.Packages.props only auto-imports the closest one. A child file shadows the parent — it does not merge. Mistakes here surface as "version not found" errors.
  • ⚠️ Globbing surprises: the SDK auto-includes **/*.cs under the project folder. If you have a generated obj/ from a prior build with .cs files in it (rare but possible from custom tooling), you can get duplicate definitions.

Further reading