MSBuild & the .NET Build System
Key Points
TargetFrameworkis just a moniker (e.g.net10.0) — the SDK derivesTargetFrameworkIdentifier/Versionfrom it. OS-specific TFMs likenet10.0-windowsadd platform APIs on top of the base.- Central Package Management (CPM) with
Directory.Packages.propsis the modern way to pin NuGet versions across a solution. One file, every project; transitive pinning becomes opt-in viaCentralPackageTransitivePinningEnabled. Directory.Build.props/Directory.Build.targetsauto-import upward from the project — your "global" settings file. Multiple parent files require explicit<Import>.PublishAot=trueimplies trimming, AOT analyzers, and self-contained publish;IsAotCompatible=trueis the signal that this library is AOT-safe and turns on the analyzers.RuntimeIdentifier(RID) no longer impliesSelfContainedsince .NET 8. You must setSelfContainedexplicitly when you want a self-contained deployment withoutPublishAot/PublishTrimmed.- Multi-targeting (
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>) gives you$(TargetFramework)conditions in props/targets — includingDirectory.Packages.propsfor 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
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:
- A library that wants to support an LTS (
net8.0) plus the latest (net10.0). - A library that targets
netstandard2.0for legacy consumers plus a modern TFM for performance optimizations only available in newer runtimes. - 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.propssomewhere 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
RuntimeIdentifierondotnet buildno longer impliesSelfContained=true. It only implies self-contained atdotnet publishtime, and only when the target is explicitly self-contained, AOT, or trimmed. If you want a self-contained build, setSelfContained=trueexplicitly.
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):
- SDK props (
Microsoft.NET.Sdkprops, etc.) - The first
Directory.Build.propswalking up from the project folder - The project's
<PropertyGroup>and<ItemGroup> - SDK targets
- The first
Directory.Build.targetswalking up
Inside the project, properties evaluate top-to-bottom; later writes win unless Condition is false.
💡 Diagnostics:
dotnet build -blproduces 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
Doesn't work on Windows in PowerShell because $(pwd) is bash. Use:
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.0and can't enableNullable). - 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, useEditorConfigor 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=truein 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/orbin/— 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 toDirectory.Packages.props. - ⚠️ Assuming
RuntimeIdentifierimplies 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.Sdkfiles — never. SDK files live under thedotnetinstall. Override via your project orDirectory.Build.props/.targets. - ⚠️ Forgetting
Deterministic=true+ContinuousIntegrationBuild=truein CI — your assemblies will have machine-specific source paths embedded in PDBs. Bad for reproducibility. - ⚠️
Directory.Packages.propsonly 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
**/*.csunder the project folder. If you have a generatedobj/from a prior build with.csfiles in it (rare but possible from custom tooling), you can get duplicate definitions.
Further reading
- .NET target frameworks (TFMs)
- Central Package Management
- Trimming and AOT
- NativeAOT deployment
- Customize the build — directory.build.props/targets reference
- MSBuild Structured Log Viewer
- Source:
dotnet/sdkon GitHub