Mastering Directory.Build.props in .NET

December 08 2025

 

Struggling with slow builds, tricky bugs, or hard-to-understand performance issues?
dotUltimate fixes all of that.
It’s the all-in-one toolbox for serious .NET developers.

 

👉 Upgrade your .NET workflow.

 

 
 

Introduction

 
 

Every .NET developer eventually hits the same wall:

 

• 20+ projects in a solution
• 20+ copies of the same TargetFramework, Nullable, and LangVersion
• 20+ places to flip TreatWarningsAsErrors
• 20+ csproj files full of repeated PackageReference and build settings

 

You tweak one setting…and then spend the next 15 minutes hunting it down across the solution.

 

In .NET 9 (and any modern SDK-style project), there’s a better way: Directory.Build.props.

 

With a single file, you can:

 

• Define global build rules for all projects in a folder tree
• Keep each .csproj small and focused
• Standardize code style, analyzers, and warnings
• Control versioning and metadata for all assemblies from one place

 

In this article, we’ll walk through:

 

1. What Directory.Build.props actually is, and how MSBuild discovers it
2. A realistic multi-project solution scenario
3. A step-by-step implementation for .NET 9
4. Advanced patterns: scoped props, versioning, analyzers, and opt-out tricks
5. Gotchas and best practices

 
 

What is Directory.Build.props?

 
 

Directory.Build.props is an MSBuild file that MSBuild automatically imports before your project file. Any properties and items you define there will be applied to all projects in that directory and its subdirectories.

 

The import process works like this:

 

1. When MSBuild loads a project (e.g. Api.csproj), it first imports Microsoft.Common.props.
2. Microsoft.Common.props then walks up the directory tree from the project’s folder, looking for the first Directory.Build.props.
3. When it finds one, it imports it.
4. Anything defined in Directory.Build.props is now available in the project file.

 

That means:

 

• Put Directory.Build.props at the solution root → every project below will inherit it.
• Put another Directory.Build.props in tests/ → test projects can have extra settings on top of the root ones.
• If you put a dummy Directory.Build.props next to an individual project, MSBuild will stop there and won’t keep searching upwards - effectively shielding that project from the upper-level props.

 

This works the same in .NET 9 as in previous modern SDK versions—the big difference is that .NET 9 projects typically lean even more on analyzers, nullable, and modern build features, which makes centralization even more valuable.

 
 

A Real-World Scenario: Cleaning Up a .NET 9 Microservices Solution

 
 

Imagine you’re working on a real solution that looks like this:

src/
    Api/
        Api.csproj
    Worker/
        Worker.csproj
    Web/
        Web.csproj
tests/
    Api.Tests/
        Api.Tests.csproj
    Worker.Tests/
        Worker.Tests.csproj
    Shared.Testing/
        Shared.Testing.csproj
Directory.Build.props
tests/Directory.Build.props
Typical problems:
• Every project repeats:

<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
• Every project copies:

<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
• All projects share the same company metadata & repository URL.
• Test projects repeatedly reference xunit, FluentAssertions, and coverlet.collector.

 

If you change TargetFramework from net8.0 to net9.0 or decide to tighten analyzers, you have to change every .csproj manually.

 

Let’s fix that with Directory.Build.props.

 
 

Step 1: Create a Solution-Level Directory.Build.props

 
 

At the solution root, create a file named Directory.Build.props:

<Project>
  <!-- Shared configuration for all .NET 9 projects in the repo -->
  <PropertyGroup>

    <!-- Targeting -->
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <LangVersion>latest</LangVersion>

    <!-- Code quality -->
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisLevel>latest</AnalysisLevel>

    <!-- Assembly metadata -->
    <Company>TheCodeMan</Company>
    <Authors>Stefan Djokic</Authors>
    <RepositoryUrl>https://github.com/thecodeman/your-repo</RepositoryUrl>
    <RepositoryType>git</RepositoryType>

    <!-- Output layout -->
    <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
    <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
    <BaseOutputPath>artifacts\bin\</BaseOutputPath>
    <BaseIntermediateOutputPath>artifacts\obj\</BaseIntermediateOutputPath>

  </PropertyGroup>

  <!-- Packages shared by most projects -->
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
    <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
  </ItemGroup>

</Project>

What this does

 

Every project now targets net9.0, uses nullable reference types, implicit usings, and the latest language features.
• Code quality is enforced everywhere via TreatWarningsAsErrors and AnalysisLevel.
• All binaries end up under artifacts\bin\ and artifacts\obj\ instead of being spread across bin/Debug folders.
• Common PackageReferences are no longer duplicated in each project.

 

Your individual .csproj files can now be tiny:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <AssemblyName>MyCompany.Api</AssemblyName>
  </PropertyGroup>
</Project>
Cleaner, easier to review, and harder to misconfigure.

 

Note: For package management itself (centralizing versions), you’ll often pair this with Directory.Packages.props in modern .NET. Directory.Build.props is primarily for build configuration, not for central package versioning - though it can hold PackageReferences if you need defaults.

 

 
 

Step 2: Add a Test-Specific Directory.Build.props

 
 

Now let’s treat tests differently: they often need extra packages and slightly relaxed rules.

 

Create tests/Directory.Build.props:

<Project>
  <!-- This file is imported AFTER the root Directory.Build.props for test projects -->

  <PropertyGroup>

    <!-- Optional: tests may not treat all warnings as errors -->
    <TreatWarningsAsErrors>false</TreatWarningsAsErrors>

    <!-- Mark these assemblies as test assemblies -->
    <IsTestProject>true</IsTestProject>

  </PropertyGroup>

  <ItemGroup>

    <!-- Shared test libraries -->
    <PackageReference 
        Include="xunit" 
        Version="2.9.0" />

    <PackageReference 
        Include="xunit.runner.visualstudio" 
        Version="2.8.2" />

    <PackageReference 
        Include="FluentAssertions" 
        Version="6.12.0" />

    <PackageReference 
        Include="coverlet.collector" 
        Version="6.0.0">
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>

  </ItemGroup>

</Project>
Now:

 

• Any project under tests/ automatically gets test packages.
• You can slightly relax test projects (e.g., no warnings-as-errors) without affecting production code.
• New test projects become trivial to create - almost no build configuration is needed inside the .csproj

 
 

Step 3: Centralized Versioning with Directory.Build.props

 
 

Another powerful use case is central assembly versioning. Instead of repeating version info in each project, you can define it once and push CI metadata through MSBuild properties.

 

Extend the root Directory.Build.props:

<Project>
  <PropertyGroup>

    <!-- Base semantic version for the whole solution -->
    <VersionPrefix>1.4.0</VersionPrefix>

    <!-- Optional manually-set suffix for prereleases -->
    <VersionSuffix>beta</VersionSuffix> 
    <!-- e.g. "", "beta", "rc1" -->

    <!-- Assembly versions -->
    <AssemblyVersion>1.4.0.0</AssemblyVersion>

    <!-- FileVersion may include build number from CI -->
    <FileVersion>1.4.0.$(BuildNumber)</FileVersion>

    <!-- InformationalVersion is what you see in "Product version" and NuGet -->
    <InformationalVersion>$(VersionPrefix)-$(VersionSuffix)+build.$(BuildNumber)</InformationalVersion>

  </PropertyGroup>
</Project>
In CI (GitHub Actions / Azure DevOps / GitLab), you pass BuildNumber:

dotnet build MySolution.sln /p:BuildNumber=123
Result:
• All projects share consistent versioning.
• Changing VersionPrefix once upgrades the whole solution.
• You can differentiate between internal Release builds and Deploy builds by controlling the properties passed from CI.

 
 

Step 4: Enforcing Code Style and Analyzers Centrally

 
 

You’re already using .editorconfig (I know you are 😄). But analyzers and build-level enforcement still live in MSBuild.

 

Directory.Build.props is a perfect place to wire that up:

<Project>
  <PropertyGroup>

    <!-- Enforce analyzers globally -->
    <AnalysisLevel>latest</AnalysisLevel>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>

    <!-- Treat all analyzer diagnostics as errors (unless overridden) -->
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>

  </PropertyGroup>

  <ItemGroup>

    <!-- Example analyzer packages -->
    <PackageReference 
        Include="Roslynator.Analyzers" 
        Version="4.12.0" 
        PrivateAssets="all" />

    <PackageReference 
        Include="SerilogAnalyzer" 
        Version="0.15.0" 
        PrivateAssets="all" />

  </ItemGroup>

</Project>
Now:

 

• Every project uses the same analyzer level and code-style enforcement.
• You don’t have to remember to add analyzer packages in each .csproj.
• Build breaks if someone violates rules, regardless of their IDE settings - perfect for CI and team consistency.

 

If a particular project truly needs to relax something, you can still override locally:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>

    <!-- opt-out from global warnings-as-errors for this project -->
    <TreatWarningsAsErrors>false</TreatWarningsAsErrors>

  </PropertyGroup>
</Project>

 
 

Step 5: Layering and Scoping: Multiple Directory.Build.props Files

 
 

You’re not limited to just one props file. MSBuild lets you create a hierarchy of Directory.Build.props files, and it will pick the first one it finds while walking up directories from the project location.

 

Common patterns:

 

Solution root: core settings for all projects
• src/Directory.Build.props: production-only settings and packages
• tests/Directory.Build.props: test-only packages and relaxed rules
• tools/Directory.Build.props: for small CLI tools that don’t need analyzers or warnings-as-errors

 

Example structure:

Directory.Build.props         // global defaults
src/Directory.Build.props     // overrides for production code
tests/Directory.Build.props   // overrides for test code
tools/Directory.Build.props   // overrides for tiny internal utilities
A src/Directory.Build.props might look like:

<Project>
  <PropertyGroup>

    <!-- Only for production code -->
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>

    <NoWarn>CS1591</NoWarn> 
    <!-- but don't require XML docs everywhere -->

  </PropertyGroup>
</Project>
And if you really need a project not to inherit any root-level props, you can create a dummy local Directory.Build.props in that project folder:

<Project>
  <!-- intentionally empty to stop inheritance from parent props -->
</Project>
This works because MSBuild stops at the first Directory.Build.props it finds when walking upwards from the project’s directory.

 
 

Directory.Build.props vs Directory.Build.targets

 
 

Another file you’ll see mentioned in the docs is Directory.Build.targets. The short version:

 

Directory.Build.props - for properties and items, imported early in the build. Great for configuration and metadata.
Directory.Build.targets - for targets and custom build actions, imported late in the build. Great for custom build steps (e.g., running a tool after build, generating artifacts, etc.).

 

For this article, we’re focusing on props, but it’s good to keep Directory.Build.targets in mind when you want to centralize “do this after build” logic.

 
 

Conclusion

 
 

Directory.Build.props is one of those features that quietly solves a lot of pain:

 

• It keeps your .csproj files short, readable, and focused
• It lets you enforce consistent rules across your .NET 9 solution
• It centralizes versioning and metadata
• It gives you a clean way to differentiate between src/, tests/, and other areas

 

Once you adopt it, adding a new project becomes almost trivial - no more copying settings from some “template” project or forgetting to turn on nullable or analyzers.

 

If you’re already battling with a big solution today:

 

1. Add a Directory.Build.props at the root.
2. Move TargetFramework, Nullable, ImplicitUsings, and analyzer settings into it.
3. Add a tests/Directory.Build.props for common test packages.
4. Clean up your .csproj files until they’re boring again.

 

Your future self (and your teammates) will thank you.

 

That's all from me for today.

There are 3 ways I can help you:

My Design Patterns Ebooks

1. Design Patterns that Deliver

This isn’t just another design patterns book. Dive into real-world examples and practical solutions to real problems in real applications.Check out it here.


1. Design Patterns Simplified

Go-to resource for understanding the core concepts of design patterns without the overwhelming complexity. In this concise and affordable ebook, I've distilled the essence of design patterns into an easy-to-digest format. It is a Beginner level. Check out it here.


Join TheCodeMan.net Newsletter

Every Monday morning, I share 1 actionable tip on C#, .NET & Arcitecture topic, that you can use right away.


Sponsorship

Promote yourself to 18,000+ subscribers by sponsoring this newsletter.



Join 18,000+ subscribers to improve your .NET Knowledge.

Powered by EmailOctopus

Subscribe to
TheCodeMan.net

Subscribe to the TheCodeMan.net and be among the 18,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.

Powered by EmailOctopus