Every dotnet build, dotnet test and dotnet publish you run is MSBuild underneath, and most .NET developers get years into their career treating it as a black box that occasionally produces a confusing error. This guide is for developers who want to actually control that build: how SDK-style projects evaluate properties, items and targets; how to centralize settings across a repository with Directory.Build.props; how Central Package Management removes per-project version drift; how multi-targeting and conditional compilation work; the new .slnx solution format; and how to diagnose a slow or broken build with a binary log instead of guesswork.

What Is the .NET Project System? SDK-Style Projects Explained#

The "project system" is the combination of MSBuild, the .csproj file format, and the .NET SDK's own targets and tasks that turn a short, mostly-declarative project file into a full build. Since .NET Core, every new project is SDK-style: it opens with <Project Sdk="Microsoft.NET.Sdk"> instead of the verbose, fully-enumerated file-list format that classic .NET Framework projects used. The SDK attribute is not cosmetic; it imports a large, versioned set of .props and .targets files that already know how to compile C#, restore NuGet packages, and produce a DLL or executable, so your project file only needs to state what is different about your project, not how compilation works in general.

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

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>

That is a complete, working project file. Everything else you add, package references, compiler settings, custom build steps, layers on top of what Microsoft.NET.Sdk already provides. Web projects use Microsoft.NET.Sdk.Web, test projects commonly reference Microsoft.NET.Sdk plus a test SDK package, and each SDK is really just a different starting set of imported targets.

How MSBuild Evaluates a Project: Properties, Items and Targets#

MSBuild's data model has three parts. Properties are single named values (<TargetFramework>, <Nullable>, <OutputType>) that configure how the build behaves. Items are lists of inputs, each with its own metadata (<Compile>, <PackageReference>, <None>); the SDK-style project's biggest convenience is that it globs **/*.cs into the Compile item automatically, so you never list source files by hand. Targets are named, ordered sequences of tasks, the actual verbs of a build (Restore, Build, Pack, Publish), each declaring the properties and items it depends on and produces.

Evaluation happens in a specific, learnable order: MSBuild reads the project file top to bottom, resolving Import elements as it encounters them, so a property set late in the file can override one set earlier, and a property used before it is defined evaluates to empty. This is why Directory.Build.props, imported automatically near the start of every SDK-style project, is for defaults you want overridable, while Directory.Build.targets, imported near the end, is for behavior you want to run after the project has already set its own properties. Getting this ordering backwards is the single most common source of "my central setting isn't taking effect" bugs.

Getting Started: Overriding a Property from the Command Line#

Before reaching for shared files, it helps to see how MSBuild resolves a single property, since every other mechanism in this guide is really just a different place to set the same kind of value:

Bash
dotnet build -p:Configuration=Release -p:TargetFramework=net10.0

-p: sets a global property that wins over almost anything the project file sets for that same property, which is exactly how CI pipelines and multi-stage builds parameterize a shared .csproj without editing it. The CI/CD guide uses this same flag to pass Configuration and RuntimeIdentifier into dotnet publish from a pipeline.

Directory.Build.props and Directory.Build.targets: Repository-Wide Settings#

Directory.Build.props is a file MSBuild looks for automatically, walking up from each project's directory toward the drive root, and imports the first one it finds. Put it at your repository root to apply settings to every project without editing each .csproj:

XML
<Project>

  <PropertyGroup>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <Authors>Contoso Engineering</Authors>
    <RepositoryUrl>https://github.com/contoso/contoso-api</RepositoryUrl>
    <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
  </PropertyGroup>

</Project>

Because MSBuild imports the nearest Directory.Build.props, a subfolder can add its own file to layer more settings on top, but it must explicitly import the parent one first, or the parent's settings are lost entirely rather than merged:

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

  <PropertyGroup>
    <IsPackable>true</IsPackable>
  </PropertyGroup>
</Project>

Directory.Build.targets follows the same discovery rule but imports after the project body, which makes it the right place for logic that reacts to properties the project itself sets, such as running a step only for packable projects:

XML
<Project>

  <Target Name="StampBuildInfo" AfterTargets="Build" Condition="'$(IsPackable)' == 'true'">
    <WriteLinesToFile File="$(OutputPath)build-info.txt"
                       Lines="Built $(MSBuildProjectName) $(Version) at $([System.DateTime]::UtcNow)"
                       Overwrite="true" />
  </Target>

</Project>

Central Package Management with Directory.Packages.props#

Central Package Management (CPM) moves every package version out of individual project files and into one Directory.Packages.props at the repository root, which is how you stop ten projects from silently drifting to ten different versions of the same dependency. Enable it with ManagePackageVersionsCentrally, generated for you by dotnet new packagesprops:

XML
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
    <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
    <GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.5.109" />
  </ItemGroup>
</Project>

Individual project files then reference a package without a version, which resolves against the entry above: <PackageReference Include="Microsoft.Extensions.Http.Resilience" />.

CentralPackageTransitivePinningEnabled pins transitive dependency versions the same way direct ones are pinned, so a version bump three levels down the dependency graph shows up as an explicit, reviewable change to Directory.Packages.props rather than silently floating. When one project genuinely needs a different version, for example while migrating it ahead of the rest of the repository, override it locally instead of disabling CPM for that project: <PackageReference Include="Serilog.AspNetCore" VersionOverride="8.0.3" />.

GlobalPackageReference items, shown in the Directory.Packages.props example above, apply to every project automatically, which is the right home for repository-wide tooling packages like source generators or versioning tools that every project needs but nobody wants to reference by hand. The NuGet packaging guide covers package versioning and metadata from the publisher's side; CPM is about consuming versions consistently across a solution you own.

Multi-Targeting and Conditional Compilation#

A library that supports more than one target framework declares TargetFrameworks (plural) instead of TargetFramework, and MSBuild builds the project once per framework in the list:

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

<ItemGroup>
  <PackageReference Include="System.Text.Json" Version="10.0.0" Condition="'$(TargetFramework)' == 'net8.0'" />
</ItemGroup>

Each target framework defines a preprocessor symbol you can branch on directly in C#, useful when an API only exists on newer runtimes:

C#
public static string Describe()
{
#if NET10_0_OR_GREATER
    return "Running on .NET 10 or later";
#else
    return "Running on an older target framework";
#endif
}

NET10_0_OR_GREATER-style symbols are generated automatically for every target framework moniker the SDK recognizes, so you rarely define them yourself; reach for a custom DefineConstants entry only for project-specific build flavors that have nothing to do with the target framework, such as a symbol that distinguishes an internal build from a public one.

The .slnx Solution Format#

.slnx is an XML-based solution file format that replaces the old, hand-edit-hostile .sln format. Starting with the .NET 10 SDK, dotnet new sln generates a .slnx file by default; on .NET 9 and earlier SDKs it still generates the classic .sln. Convert an existing solution with the built-in migration command:

Bash
dotnet sln MyApp.sln migrate
dotnet sln MyApp.slnx add src/Api/Api.csproj src/Core/Core.csproj
dotnet sln MyApp.slnx list

The first line produces MyApp.slnx alongside the original file without touching it. Every dotnet sln subcommand, list, add, and remove, works identically against either extension, and both Visual Studio and the CLI accept .slnx anywhere a .sln path was expected.

The practical win is that .slnx is small, readable, and diffs cleanly in a pull request, unlike the classic format's GUID-heavy, merge-conflict-prone structure. There is no forced migration date: .sln keeps working, and teams typically migrate opportunistically, one repository at a time, rather than as a dedicated project.

Writing Custom Targets#

Beyond the AfterTargets="Build" example earlier, custom targets are how you hook version stamping, code generation, or deployment packaging into the standard build without forking the SDK's own targets. Declaring Inputs and Outputs makes a target participate in MSBuild's incremental build engine, so it is skipped entirely when its outputs are already newer than its inputs:

XML
<Target Name="GenerateBuildManifest"
        BeforeTargets="Build"
        Inputs="$(MSBuildProjectFile)"
        Outputs="$(IntermediateOutputPath)manifest.json">
  <ItemGroup>
    <ManifestLines Include="{ &quot;project&quot;: &quot;$(MSBuildProjectName)&quot;, &quot;version&quot;: &quot;$(Version)&quot; }" />
  </ItemGroup>
  <WriteLinesToFile File="$(IntermediateOutputPath)manifest.json" Lines="@(ManifestLines)" Overwrite="true" />
</Target>

BeforeTargets and AfterTargets hook into the existing target graph without redefining it, which is almost always preferable to overriding a built-in target outright; overriding risks silently losing behavior the SDK adds in a future release that your override no longer calls.

Diagnosing Slow Builds with Binary Logs#

When a build is slow, fails only in CI, or does something you cannot explain from the console output, a binary log captures everything MSBuild did, in a structured, replayable form:

Bash
dotnet build -bl

That produces msbuild.binlog in the working directory, which you open with the MSBuild Structured Log Viewer to see every target, task, property and item value exactly as MSBuild evaluated them, searchable and filterable rather than scrolled through as text. For build performance specifically, the viewer's timeline shows which targets and tasks dominate wall-clock time, which is far more reliable than guessing from -v:diag text output. Two SDK features pair well with binlog-driven investigation: the Terminal Logger, on by default since .NET 8 (--tl:auto), which shows live per-project progress instead of an interleaved wall of text; and the opt-in artifacts output layout, which centralizes every project's bin, obj and publish output under one artifacts/ folder instead of scattering them per-project:

XML
<PropertyGroup>
  <UseArtifactsOutput>true</UseArtifactsOutput>
</PropertyGroup>

With UseArtifactsOutput enabled in Directory.Build.props, output lands at paths like artifacts/bin/MyApp/release_net10.0, which makes it trivial for CI to find and cache exactly what it needs without hardcoding a dozen per-project bin/Release paths, and keeps obj folders out of every source directory. Combine binary logs with the .NET Diagnostics Toolkit when the question shifts from "why is my build slow" to "why is my running application slow"; they are different tools for different phases.

Best Practices#

  • Put shared settings in Directory.Build.props, not in every .csproj. If you find yourself copy-pasting a PropertyGroup across projects, that is the signal to centralize it.
  • Adopt Central Package Management repository-wide, not just for new projects, so a single PR updates a dependency everywhere it is used instead of triggering a multi-project hunt.
  • Enable CentralPackageTransitivePinningEnabled so transitive version bumps show up as reviewable diffs instead of happening invisibly.
  • Keep Directory.Build.targets for behavior, and Directory.Build.props for defaults. Properties set in .targets run too late to be overridden cleanly by an individual project.
  • Give incremental targets real Inputs and Outputs. A custom target without them re-runs on every build, undermining the incremental engine the rest of the SDK relies on.
  • Reach for a binary log before adding -v:diag console noise. It answers "why" questions the console output cannot, with far less scrolling.

Common Pitfalls#

  • Setting a property in Directory.Build.targets and expecting the project to override it. Targets files import after the project body, so the project's own value already lost by the time yours runs.
  • Multiple Directory.Build.props files that the author assumed would merge. Only the nearest one is imported; a child file must explicitly <Import> its parent to combine settings.
  • Disabling CPM per-project with ManagePackageVersionsCentrally=false to work around one version conflict, instead of using VersionOverride, which keeps the project inside CPM's transitive pinning.
  • Multi-targeting without conditional PackageReference items for a package that only supports one of the target frameworks, which fails restore on the other.
  • Treating .slnx migration as mandatory and urgent. .sln still works; migrate when it is convenient, not under deadline pressure.
  • Overriding a built-in SDK target by name instead of hooking BeforeTargets/AfterTargets, which silently drops whatever the SDK's own target did in later SDK versions.

Where Should a Setting Live?#

SettingBest homeWhy
Language version, nullable, implicit usingsDirectory.Build.propsShared default every project should start from
Package versionsDirectory.Packages.propsOne place to see and update every dependency version
A step that runs after every buildDirectory.Build.targetsImported after the project, so it can react to final property values
A project-specific overrideThe project's own .csprojLocal properties win over Directory.Build.props defaults
A CI-only value like ContinuousIntegrationBuildDirectory.Build.props, guarded by a ConditionShared location, but only active when $(CI) is set
A one-off version exceptionVersionOverride on the PackageReferenceKeeps the project inside CPM instead of opting it out

Frequently Asked Questions#

What is the difference between Directory.Build.props and Directory.Build.targets?#

Directory.Build.props is imported near the top of the SDK's own targets, before the project body, so it sets defaults an individual project can still override. Directory.Build.targets is imported near the end, after the project body, so it is for behavior, like a target that reacts to properties the project has already set, rather than values you expect a project to change.

Do I have to migrate every project to Central Package Management at once?#

No. CPM works at the repository level once Directory.Packages.props exists, but you can adopt it incrementally: projects without a matching PackageVersion entry simply keep specifying their own version until you move them over. Most teams still migrate a whole repository in one pass, since partial adoption reintroduces the version drift CPM exists to prevent.

Should I convert my .sln files to .slnx right now?#

There is no deadline. .slnx is smaller and diffs far more cleanly in review, and .NET 10's dotnet new sln already generates it by default, but dotnet sln <file>.sln migrate is a one-command, non-destructive conversion you can run whenever it is convenient for your team, not something to rush ahead of higher-priority work.

Why is my Directory.Build.props setting not taking effect?#

The most common cause is a nearer Directory.Build.props in a subfolder that does not import the parent one, so only the child file's settings apply. The second most common cause is the project itself, or a NuGet package, setting the same property afterward. Open a binary log and search for the property name to see every place that sets it and in what order.

What is the fastest way to find out why a build is slow?#

Run dotnet build -bl and open msbuild.binlog in the MSBuild Structured Log Viewer. Its timeline view sorts targets and tasks by wall-clock time directly, which finds the actual bottleneck far faster than reading verbose console output line by line.

Summary#

  • SDK-style projects import a versioned set of .props and .targets from Microsoft.NET.Sdk, so your project file only needs to state what differs from the defaults.
  • Directory.Build.props (defaults, imported early) and Directory.Build.targets (behavior, imported late) are the two files that centralize settings across a repository.
  • Central Package Management, via Directory.Packages.props, removes per-project version drift; use VersionOverride for exceptions instead of disabling it.
  • Multi-targeting uses TargetFrameworks plus conditional items and #if NETx_y_OR_GREATER symbols for APIs that differ across target frameworks.
  • .slnx is the modern, diff-friendly solution format; dotnet sln <file>.sln migrate converts an existing solution with no forced deadline.
  • A binary log (dotnet build -bl) is the fastest path from "the build is slow or wrong" to knowing exactly why.

Further Reading#