📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do Directory.Build.props and Directory.Build.targets work, and what belongs in each?

Shared build configuration: how MSBuild finds and imports Directory.Build.props early and Directory.Build.targets late, nearest-file-wins and chaining with GetPathOfFileAbove, what to put in each, conditions per project type, and debugging with preprocess and binary logs.

Mid-Level Theory Intermediate build-governance directory-build-props msbuild project-system

🎯 Key points

  • MSBuild searches upwards from each project's folder and imports the first Directory.Build.props it finds, early, before the project's own content
  • Directory.Build.targets is imported late, after the project content and most SDK targets
  • Put default properties in .props (TFM, Nullable, LangVersion, analysers, package metadata); put targets, and any property or item logic that depends on values the project sets, in .targets
  • MSBuild evaluates all properties first, then items, then runs targets, which explains most surprises with conditions
  • Only the nearest file is imported; to layer them, the inner file imports the outer one with GetPathOfFileAbove
  • Use conditions such as '$(IsTestProject)' == 'true' or '$(MSBuildProjectName)' to vary settings by project type
  • Debug with dotnet msbuild -preprocess or a binary log (-bl) viewed in the MSBuild Structured Log Viewer
  • Changes affect every project, so review them like shared infrastructure

⏱ 30-second answer

Directory.Build.props and Directory.Build.targets are ordinary MSBuild files that the SDK imports automatically into every project below them. For each project, MSBuild searches upwards and uses the nearest one it finds. .props is imported early, before the project's own content, so it's for default properties and items: target framework, Nullable, analysers, package metadata. Projects can still override those values. .targets is imported late, after the project, so it's for targets and logic that need final values. Only the nearest file is picked up. To layer them, the inner file imports the outer one with GetPathOfFileAbove. I debug them with dotnet msbuild -preprocess or a binary log.

🗣 Full answer (senior)

How I would answer this in an interview

"Directory.Build.props and Directory.Build.targets are ordinary MSBuild files that the SDK imports automatically into every project below them. For each project, MSBuild searches upwards from the project's folder and uses the nearest one. The .props file comes in early in the SDK's import order, before the project's own content, so it's for defaults the project can still override: target framework, Nullable, LangVersion, analysers and package metadata. Package versions I'd keep separately in Directory.Packages.props.

The .targets file comes in late, after the project and most SDK targets, so it's for custom targets and any logic that depends on values the project sets, like OutputType or IsPackable. That's because MSBuild evaluates all properties first, then items, then runs targets, so a condition in .props can't see a value the project hasn't set yet. To vary settings by project type I use conditions such as '$(IsTestProject)' == 'true' or $(MSBuildProjectName).

Parent files aren't merged: only the nearest file is imported, so a nested file has to import the outer one with GetPathOfFileAbove. The mistake I see is adding tests/Directory.Build.props without that import, and every test project silently loses TreatWarningsAsErrors. When a value surprises me, I check it with dotnet msbuild -preprocess or a binary log with -bl rather than guessing.

Because one line changes every project, I treat these files as shared infrastructure: a small documented root file, CODEOWNERS protection, and a CI job that prints the key properties per project so reviewers see exactly what moved. That's what lets it scale to governance across many teams."


How they're found

The SDK's Microsoft.Common.props looks for Directory.Build.props starting in the project's folder and walking up to the drive root. It imports the first one found. Microsoft.Common.targets does the same for Directory.Build.targets. Nothing else is imported automatically.

Import order

  1. SDK Sdk.props (includes Directory.Build.props)
  2. Your project file
  3. SDK Sdk.targets (includes Directory.Build.targets)

So a .props value is a default the project can override, and a .targets file sees the project's final values.

What goes where

Directory.Build.props Directory.Build.targets
TargetFramework, LangVersion, Nullable, ImplicitUsings Custom targets (BeforeTargets="Build"), for example a version stamp or a licence check
TreatWarningsAsErrors, AnalysisLevel, EnforceCodeStyleInBuild Settings that depend on values the project sets, such as OutputType or IsPackable
Package metadata: Authors, Company, RepositoryUrl Items that must be added after the project's own items
Analyser PackageReferences (with CPM, GlobalPackageReference in Directory.Packages.props)
ArtifactsPath / output layout

Rule of thumb: if it reads a value that the project might set, it belongs in .targets.

Layering

Only the nearest file is imported, so a tests/Directory.Build.props hides the root one unless it imports it:

<Project>
  <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
  <PropertyGroup>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
</Project>

Production example

In a 60-project repo, every project file repeated the same ten properties, and they had drifted: some projects had nullable warnings off, two still targeted net8.0. Moving them into a root Directory.Build.props made the project files a few lines each, and a TFM upgrade to net10.0 became one line plus fixes. Later, someone added tests/Directory.Build.props without importing the root one, and every test project silently lost TreatWarningsAsErrors. A binary log showed which file was imported, and the GetPathOfFileAbove import fixed it.

Best practices, performance and security

  • Keep the root file small and well commented; it's shared infrastructure.
  • Prefer properties over custom targets; targets run on every build and slow it down.
  • Don't put secrets in these files; they're committed to source control.
  • Protect the root build files with CODEOWNERS so changes are reviewed by the platform owners.

🔍 What the interviewer is checking

Checks practical MSBuild knowledge for real repos.

Green flags: explains search order and early/late import; knows nearest-file-wins and GetPathOfFileAbove; puts the right things in each file; debugs with -preprocess or -bl.

Red flags: copies the same properties into every project; thinks all parent files are merged automatically; never looked at a binary log.

📚 Learn in depth

1. Where the files are imported

 Orders.Tests.csproj  (<Project Sdk="Microsoft.NET.Sdk">)

  Sdk.props
    Microsoft.Common.props
      search upwards from the project folder for Directory.Build.props
      import the FIRST one found                      <-- early
    Directory.Packages.props (if Central Package Management is used)
    SDK default properties and item globs
  ---------------------------------------------------------------
  the project's own content (can override anything above)
  ---------------------------------------------------------------
  Sdk.targets
    Microsoft.CSharp.targets -> Microsoft.Common.targets
      ... most build targets defined ...
      search upwards for Directory.Build.targets
      import the FIRST one found                      <-- late
    remaining SDK targets

The search starts in the project's folder ($(MSBuildProjectDirectory)) and walks up to the drive root. It stops at the first match. Parent files are never merged automatically.

2. How MSBuild evaluates a project

Most surprises with these files come from MSBuild's evaluation passes, which run in this order across all imported files:

Pass What happens Consequence
1. Environment and global properties Environment variables and -p: values are loaded -p:Configuration=Release overrides anything a file sets, unless the file lists it in TreatAsLocalProperty
2. Properties and imports Every <PropertyGroup> in import order; <Import> elements are followed A property set later wins. A condition can only see properties set earlier
3. Item definitions <ItemDefinitionGroup> defaults for item metadata
4. Items Every <ItemGroup> in import order, including SDK globs Items can use any property, because all properties are already final
5. Targets Targets are only collected here and run later, during the build Inside a target, properties and items reflect everything evaluated, plus changes made by earlier targets

So in Directory.Build.props:

<!-- WRONG: OutputType is set in the project file, which hasn't been evaluated yet -->
<PropertyGroup Condition="'$(OutputType)' == 'Exe'">
  <SelfContained>true</SelfContained>
</PropertyGroup>

The condition sees an empty OutputType (or the SDK default, Library). The same block in Directory.Build.targets sees the project's value.

3. What goes where

Put it in Directory.Build.props when... Put it in Directory.Build.targets when...
It is a default the project may override (TargetFramework, Nullable, LangVersion) It depends on a value the project sets (OutputType, IsPackable, IsTestProject set by hand)
It affects paths or behaviour the SDK computes early (UseArtifactsOutput, ArtifactsPath, BaseIntermediateOutputPath) It defines or hooks a target (BeforeTargets="Build", AfterTargets="Pack")
It is shared metadata (Company, Authors, RepositoryUrl, PackageLicenseExpression) It adds items that must come after the project's items
It switches on repo-wide quality settings (TreatWarningsAsErrors, AnalysisLevel, EnforceCodeStyleInBuild) It enforces a rule on final values, such as failing the build if Nullable was turned off

IsTestProject is a useful case: test SDK packages set it in their own .props or .targets, so whether it is visible in your .props depends on the package. Test with -getProperty before you rely on it, or condition on the folder or project name instead.

4. Layering nested files

repo/
  Directory.Build.props          <-- root defaults
  src/
    Orders.Api/Orders.Api.csproj  -> uses repo/Directory.Build.props
  tests/
    Directory.Build.props        <-- must import the root file explicitly
    Orders.Tests/Orders.Tests.csproj
  samples/
    Directory.Build.props        <-- may deliberately NOT import the root
<!-- tests/Directory.Build.props -->
<Project>
  <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
  <PropertyGroup>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
</Project>

The search for the parent must start one level up ($(MSBuildThisFileDirectory)../), otherwise it finds the file itself. Use MSBuildThisFileDirectory (the folder of the file being evaluated) rather than MSBuildProjectDirectory (the folder of the project) inside shared files.

5. Related files and switches

File or property Purpose
Directory.Packages.props Central package versions; imported after Directory.Build.props, so it can use its properties
Directory.Solution.props / Directory.Solution.targets Imported when MSBuild builds a solution (the metaproject), not individual projects
Directory.Build.rsp Default command-line switches for msbuild / dotnet build in that folder tree, for example -bl or -m
ImportDirectoryBuildProps=false Turns the search off, for example for a vendored third-party project inside the repo
DirectoryBuildPropsPath Points at a specific file instead of searching
ImportDirectoryBuildTargets=false / DirectoryBuildTargetsPath The same switches for .targets

6. Effect on incremental builds

Every imported file is an input to the build. Editing Directory.Build.props invalidates the up-to-date checks of every project below it, so the next build recompiles everything. Keep the root file stable, and don't write timestamps, Git hashes or other per-build values into properties that feed compilation on developer machines; limit those to CI with a condition such as '$(ContinuousIntegrationBuild)' == 'true'.

7. Debugging

Question Command
What is the final value of a property? dotnet msbuild src/Orders.Api -getProperty:TargetFramework -getProperty:Nullable
Which files were imported, in what order? dotnet msbuild -preprocess:pp.xml src/Orders.Api, then search for Directory.Build
Where was a property set and overridden? dotnet build -bl, open msbuild.binlog in the MSBuild Structured Log Viewer and search for the property name
What items exist after evaluation? dotnet msbuild -getItem:Compile -getItem:PackageReference
Did a target run, and why? The binary log's target view shows each target, its inputs and whether it was skipped

After editing these files, IDEs may show cached values. Reload the project or restart the IDE before concluding a change didn't work.

8. A health check in CI

Because one line can change every project, add a cheap check that asserts the important values:

for proj in $(git ls-files '*.csproj'); do
  twae=$(dotnet msbuild "$proj" -getProperty:TreatWarningsAsErrors)
  nullable=$(dotnet msbuild "$proj" -getProperty:Nullable)
  if [ "$twae" != "true" ] || [ "$nullable" != "enable" ]; then
    echo "::error::$proj has TreatWarningsAsErrors=$twae Nullable=$nullable"
    failed=1
  fi
done
exit ${failed:-0}

Printing the same values before and after a change to the shared files also gives reviewers a clear diff of what moved.

9. Security and governance

  • These files are committed, so never put secrets, tokens or connection strings in them.
  • A custom target in Directory.Build.targets runs on every developer machine and CI agent. Review target changes as you would review a script.
  • Protect Directory.Build.*, Directory.Packages.props, global.json and .editorconfig with CODEOWNERS so platform owners review every change.
  • Be careful when opening an untrusted repository: a Directory.Build.targets in a parent folder (even outside the repo) can run code during a build.

10. .NET 11 awareness

The import model is stable in MSBuild and is not expected to change for .NET 11. New SDK properties appear with each release, so read the release notes before adding them to the root file, and keep production on the .NET 10 SDK until you've tested an upgrade.

11. Try it yourself

  1. Create a repo with src/App and tests/App.Tests.
  2. Add a root Directory.Build.props with <Nullable>enable</Nullable> and <TreatWarningsAsErrors>true</TreatWarningsAsErrors>.
  3. Run dotnet msbuild tests/App.Tests -getProperty:TreatWarningsAsErrors.
  4. Add tests/Directory.Build.props with only <IsPackable>false</IsPackable> and run the same command. The value is now empty.
  5. Add the GetPathOfFileAbove import and run it again.
  6. In the root .props, add a property group conditioned on '$(OutputType)' == 'Exe', check it with -getProperty on a console app, then move it to Directory.Build.targets and check again.
  7. Build with -bl, open the log in the Structured Log Viewer and search for TreatWarningsAsErrors to see every place it was set.

💻 Code

<!-- repo/Directory.Build.props -->
<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>

    <Company>Contoso Pty Ltd</Company>
    <Authors>Contoso Platform Team</Authors>
    <RepositoryUrl>https://github.com/contoso/orders</RepositoryUrl>
  </PropertyGroup>
</Project>
<!-- repo/Directory.Build.targets: logic that needs final project values -->
<Project>
  <!-- Only packable libraries need symbols and package validation -->
  <PropertyGroup Condition="'$(IsPackable)' == 'true' and '$(OutputType)' == 'Library'">
    <IncludeSymbols>true</IncludeSymbols>
    <SymbolPackageFormat>snupkg</SymbolPackageFormat>
    <EnablePackageValidation>true</EnablePackageValidation>
  </PropertyGroup>

  <Target Name="PrintBuildInfo" BeforeTargets="CoreCompile" Condition="'$(ContinuousIntegrationBuild)' == 'true'">
    <Message Importance="high" Text="$(MSBuildProjectName): $(TargetFramework) $(Configuration)" />
  </Target>
</Project>
<!-- repo/tests/Directory.Build.props: layer on top of the root file -->
<Project>
  <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
  <PropertyGroup>
    <IsPackable>false</IsPackable>
    <NoWarn>$(NoWarn);CA1707</NoWarn> <!-- underscores in test method names -->
  </PropertyGroup>
</Project>

⚠ Common mistakes

  • Mistake: adding a nested Directory.Build.props without importing the parent. MSBuild imports only the nearest file, so every project below the new file silently loses the repo-wide settings, such as TreatWarningsAsErrors or Nullable. Instead: start every nested file with an <Import> of the parent using $([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../')), unless you deliberately want to cut it off.

  • Mistake: conditioning .props settings on values the project sets. .props is evaluated before the project's content, so OutputType, IsPackable or a hand-set property is still empty (or the SDK default) at that point, and the condition never matches. Instead: move logic that depends on project values into Directory.Build.targets, which is evaluated after the project.

  • Mistake: putting everything in Directory.Build.targets. Values set late override the project's own settings, so a project can no longer opt out of a default. Instead: put defaults in .props so projects can override them, and use .targets only for targets and logic that need final values.

  • Mistake: putting package versions in Directory.Build.props. Shared PackageReference items with versions there are hard to override and mix two concerns, and they conflict with Central Package Management. Instead: keep build properties in Directory.Build.props and package versions in Directory.Packages.props.

  • Mistake: using $(MSBuildProjectDirectory) to find files next to the shared file. It's the folder of the project being built, so paths break for projects at different depths. Instead: use $(MSBuildThisFileDirectory) inside Directory.Build.* files for paths relative to the file itself.

  • Mistake: adding heavy custom targets that run on every build. Every developer pays for them on every inner-loop build, and targets without Inputs and Outputs are never skipped. Instead: prefer properties over targets, give targets Inputs/Outputs so they can be skipped, and limit CI-only work with Condition="'$(ContinuousIntegrationBuild)' == 'true'".

  • Mistake: writing per-build values such as timestamps into shared properties. Every build sees a changed input, so incremental builds recompile every project. Instead: compute build numbers and commit hashes only in CI, and keep developer builds deterministic.

  • Mistake: trusting what the IDE shows straight after editing these files. IDEs cache evaluation results, so you can see old values and conclude a change didn't work. Instead: check with dotnet msbuild -getProperty:<Name> from the command line, and reload the project in the IDE.

  • Mistake: debugging by guesswork. Adding more properties to fix a value you don't understand leaves layers of overrides that nobody can follow. Instead: use dotnet msbuild -preprocess to see the import order, and a binary log (-bl) to see exactly where each property was set.

  • Mistake: no owner for the shared build files. Anyone can change settings for every project in one line, and a bad change isn't noticed until much later. Instead: protect Directory.Build.* with CODEOWNERS, describe the effect in each pull request, and add a CI check that asserts key properties for every project.

🏛 Senior insight: trade-offs

Directory.Build files are the main lever for consistency across a repo, so a senior engineer treats them as a product: a small, documented root file with the organisation's defaults, explicit layering for tests and samples, logic in .targets only when it needs final values, and CODEOWNERS protection. Changes go through pull requests with a short explanation of the effect on every project, ideally with a CI job that prints the key properties per project before and after, so a reviewer can see exactly what moved and a bad change can be reverted in one commit. They also know the failure modes (nearest-file-wins, evaluation order) and debug with binary logs rather than guesswork. Combined with Central Package Management, global.json and .editorconfig, these files let a TFM upgrade or a new analyser rule roll out in one reviewed pull request.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate puts package versions in Directory.Build.props. Why: Package versions belong in Directory.Packages.props, not Directory.Build.props.
    How to answer

    I'd add a Directory.Packages.props at the repo root that sets ManagePackageVersionsCentrally to true and lists one PackageVersion per package. Then I'd strip the Version from every PackageReference, or restore fails with NU1008. First I inventory the current versions with dotnet list package and choose one version for each package, usually the highest that the tests pass with. Analysers go in as GlobalPackageReference items. Transitive pinning lets the central file also patch vulnerable transitive packages. VersionOverride covers genuine exceptions, but only with review. The pitfalls: nested Directory.Packages.props files hiding the root one, NU1507 when there are several feeds without source mapping, and upgrades that become "big bang" unless Dependabot or Renovate update the central file in small batches.

  2. When: Candidate mentions layering props files for src and tests. Why: Scales shared build files up to governance across many teams.
    How to answer

    I'd treat the build as a product, with a contract that lives in files at the repo root:

    • global.json for the SDK.
    • Layered Directory.Build.props/.targets for framework, nullable, analysers and output layout.
    • Directory.Packages.props with transitive pinning for versions.
    • nuget.config with source mapping, plus lock files.
    • .editorconfig for style and analyser severities.

    Project files stay tiny. A small platform group owns those files through CODEOWNERS, and there's a documented exception process for overrides and suppressions. CI uses shared templates that run exactly what developers run locally. The warning policy ratchets stricter over time. SDK, framework and package upgrades arrive as automated, grouped pull requests. I'd measure it all (build time, warnings, drift, time to patch) so governance helps teams instead of just blocking them.

  3. When: Candidate can't say whether the project or the props file wins. Why: Checks where Directory.Build files sit in the SDK's import order.
    How to answer

    An SDK-style project begins with <Project Sdk="Microsoft.NET.Sdk">. That one attribute pulls in all the standard build logic, so the file only holds what's specific to your project: the target framework, the output type, a few settings and its references. Source files are picked up automatically by globbing, so you don't list each .cs file. Packages are PackageReference items. Old-style .NET Framework project files listed every file, imported targets by hand and used packages.config. That made them long, noisy in merges and tied to Visual Studio on Windows.

🛠 Real-world problems: how you handle them

Nested Directory.Build.props silently drops warnings-as-errors for tests
Situation
A 60-project .NET 10 repo used a root Directory.Build.props with TreatWarningsAsErrors, nullable enabled and analysers. A developer added tests/Directory.Build.props to mark test projects as not packable.
Signals
Weeks later, test projects had dozens of nullable warnings that never failed CI; a reviewer noticed analyser warnings in a test project's build output that would have failed elsewhere.
Investigation
Ran dotnet msbuild -getProperty:TreatWarningsAsErrors on an API project and a test project and got 'true' and an empty value. A binary log showed the test project imported only tests/Directory.Build.props, because MSBuild stops at the nearest file.
Resolution
Added an Import of the parent file using GetPathOfFileAbove at the top of tests/Directory.Build.props, then fixed the warnings that had built up in test projects.
Prevention
CODEOWNERS on all Directory.Build.* files; a CI check that prints key properties for every project and fails if TreatWarningsAsErrors or Nullable is missing; a comment at the top of the root file explaining nearest-file-wins.

How to tell it (STAR)

**Situation:** In our [60-project] repo at [company], test projects had quietly stopped failing on warnings after someone added a nested Directory.Build.props. **Task:** I wanted to find out why and make sure repo-wide settings couldn't be dropped silently again. **Action:** Comparing final property values across projects showed the test projects only imported the nested file, because MSBuild uses the nearest one. I chained the parent file with GetPathOfFileAbove, cleaned up [N] warnings, and added a CI check that asserts key properties for every project. **Result:** Settings became consistent across all projects again, and the check has since caught [N] similar mistakes before merge.

↩ Usually asked after

More in Project System

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me