SDK-Style Projects, MSBuild and Directory.Build.props
Objective
A modern .csproj is often six lines long, yet dotnet build compiles every
.cs file under it, adds System.Linq to every file, references the whole
Microsoft.NETCore.App framework and writes generated code into obj/. All of
that comes from the SDK named in the first line, not from the file. This
concept opens the box: what Sdk="Microsoft.NET.Sdk" imports, how the implicit
globbing decides which files are compiled, how to ask MSBuild what it really
evaluated (-getProperty, -getItem, -pp and binary logs), how
Directory.Build.props and .targets share settings across a solution, why
evaluation order makes some of those settings silently do nothing, and how a
solution file and the artifacts folder fit in. Everything below was run on
SDK 10.0.100 (MSBuild 18.0.2) in throwaway projects.
Use Cases
- Putting
Nullable,TreatWarningsAsErrorsand the target framework in one place instead of repeating them in twelve project files. - Finding out why a stray
.csfile broke the build, or why a file you wanted excluded is still compiled. - Debugging "why does this property have this value" without guessing.
- Choosing between
Microsoft.NET.Sdk,.Weband.Workerfor a new project. - Moving all build output out of the source tree so tools and CI know where to look.
Deep Dive
What the SDK attribute does
<Project Sdk="Microsoft.NET.Sdk"> is shorthand for two imports: Sdk.props
at the very top of the file and Sdk.targets at the very bottom, both found
under dotnet/sdk/10.0.100/Sdks/Microsoft.NET.Sdk/. The properties file sets
defaults, your project body then overrides them, and the targets file defines
the build logic using the final values. Asking MSBuild for the fully expanded
project shows how much hides behind six lines:
text$ dotnet msbuild -pp:full.xml # preprocess: inline every import $ wc -l full.xml 17147 full.xml
Each imported file appears in full.xml between comment banners with its full
path, so the file also answers "where does this value come from?". The
questions are cheaper to ask with -getProperty and -getItem, which print
the evaluated result as JSON without building:
text$ dotnet msbuild -getProperty:AssemblyName -getProperty:RootNamespace -getProperty:Version {"Properties":{"AssemblyName":"c","RootNamespace":"c","Version":"1.0.0"}}
The assembly and namespace names come from the project file name, and
Version defaults to 1.0.0. The three common SDKs differ in what they add
on top of that:
| SDK | Framework references | Extra implicit usings | Extra default items |
|---|---|---|---|
Microsoft.NET.Sdk |
Microsoft.NETCore.App |
System, System.IO, System.Linq, System.Net.Http, System.Threading... |
none |
Microsoft.NET.Sdk.Web |
Microsoft.NETCore.App and Microsoft.AspNetCore.App |
Microsoft.AspNetCore.*, Microsoft.Extensions.*, System.Net.Http.Json |
appsettings*.json as Content |
Microsoft.NET.Sdk.Worker |
Microsoft.NETCore.App |
Microsoft.Extensions.Hosting, Logging, Configuration, DependencyInjection |
none (the template adds a Microsoft.Extensions.Hosting package reference) |
The usings column is read from the generated obj/Debug/net10.0/*.GlobalUsings.g.cs
of a dotnet new console, web and worker project. ImplicitUsings is
just a switch that turns that file on, and Nullable is the compiler's
nullable context for the whole project.
Implicit globbing: the file system is the project
Old project files listed every source file. SDK-style projects declare a glob instead: the preprocessed output contains
plaintext<Compile Include="**/*$(DefaultLanguageSourceExtension)" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" Condition=" '$(EnableDefaultCompileItems)' == 'true' " />
so every .cs below the project folder is compiled, in any subfolder, and
DefaultItemExcludes removes only bin/, obj/, *.user, project and
solution files, and a few editor files. A file named Old.cs.bak or
Skip.txt is not matched; a .cs file in an arbitrary folder is. The
experiment: a library with A.cs, sub/B.cs and a leftover
obj_ignored/Broken.cs.
text$ dotnet build obj_ignored/Broken.cs(1,14): error CS1040: Preprocessor directives must appear as the first non-whitespace character on a line
dotnet msbuild -getItem:Compile lists exactly what the glob picked up, which
is the first thing to run when a build compiles something unexpected. The fix
is an explicit Remove:
plaintext<ItemGroup> <Compile Remove="obj_ignored/**" /> </ItemGroup>
After that -getItem:Compile shows A.cs and sub/B.cs only. Adding a
file by name that the glob already includes is an error, not a no-op:
texterror NETSDK1022: Duplicate 'Compile' items were included. The .NET SDK includes 'Compile' items from your project directory by default. You can either remove these items from your project file, or set the 'EnableDefaultCompileItems' property to 'false' ...
EnableDefaultCompileItems=false empties the list (verified: Compile becomes
[]), and EnableDefaultItems=false turns off the globs for all item types.
The Web SDK adds a similar glob for Content, which is how
appsettings.json ends up in the item list of a web project and not in a
console one.
Conditions, labels and who wins
A PropertyGroup takes a Condition, and Label is a free-text tag for
tools:
plaintext<PropertyGroup Condition="'$(Configuration)' == 'Release'" Label="Release only"> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup>
text$ dotnet msbuild -getProperty:TreatWarningsAsErrors -> false $ dotnet msbuild -p:Configuration=Release -getProperty:TreatWarningsAsErrors -> true
Command-line properties (-p:) are global properties and beat anything in
the files: with <Greeting>from-file</Greeting> in the project,
-p:Greeting=from-cli evaluates to from-cli. Among files, the last
assignment wins, which is why the import order in the next section matters.
Directory.Build.props and Directory.Build.targets
When MSBuild evaluates a project it looks for Directory.Build.props in the
project folder and then in each parent folder, and imports the first one it
finds. Directory.Build.targets is found the same way and imported at the end.
A typical file at the repository root:
plaintext<Project> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> </Project>
With that file, Core.csproj can be <Project Sdk="Microsoft.NET.Sdk" />.
The settings are enforced: a method with name.ToUpper() on a string? fails
the build with error CS8602: Dereference of a possibly null reference, which
without the file would have been a warning. A binary log confirms the file was
imported into each project (Importing project ".../Directory.Build.props" into project ".../Microsoft.Common.props"), and -pp shows its properties inline,
right before the project body.
Two behaviors surprise people, both measured here.
Only the nearest file is imported. A second tests/Directory.Build.props
hides the one at the root; the tests project then evaluates with an empty
TargetFramework and TreatWarningsAsErrors=false:
text{"Nullable":"","TargetFramework":"","IsTestProject":"true","TreatWarningsAsErrors":"false"}
Chaining is explicit, from the nested file:
plaintext<Project> <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" /> <PropertyGroup><IsTestProject>true</IsTestProject></PropertyGroup> </Project>
and now the same query returns Nullable=enable, TargetFramework=net10.0
and TreatWarningsAsErrors=true. dotnet new buildprops --inherit generates
this import line.
A .props file cannot read what the project sets. It is evaluated before
the project body. Given the same condition in both files, only the .targets
version fires:
text<PropertyGroup Condition="'$(OutputType)' == 'Exe'"><FromProps>... -> FromProps: "" (the same group in Directory.Build.targets) -> FromTargets: "exe-in-targets"
The rule from the MSBuild documentation: defaults go in .props, anything
derived from what a project chose goes in .targets, and items in .props
let a project Remove them later.
Directory.Packages.props is found the same way and holds package versions;
that is a separate topic, covered in the concept "Central Package Management
and Package Source Mapping".
Solutions, generated folders and ArtifactsPath
A solution only lists projects for IDEs and for dotnet build at the root. The
XML format .slnx is smaller than the old GUID-based .sln:
text$ dotnet new sln -n Demo --format slnx && dotnet sln add src/Core src/App <Solution> <Folder Name="/src/"> <Project Path="src/Core/Core.csproj" /> <Project Path="src/App/App.csproj" /> </Folder> </Solution>
The documentation says that "starting with .NET 10" the default format of
dotnet new sln is slnx. On SDK 10.0.100 here it was not: dotnet new sln
wrote Demo.sln, the help shows Default: sln, and --format slnx was
needed. Treat the default as version-dependent and pass --format in
scripts. dotnet sln migrate converts an existing .sln and wrote a
Demo.slnx next to it.
Beside each project, a build writes bin/Debug/net10.0/ (the .dll,
.pdb, .deps.json, .runtimeconfig.json and the apphost) and obj/, which
holds NuGet's project.assets.json, the generated AssemblyInfo.cs and
GlobalUsings.g.cs, and a reference assembly under obj/.../ref/. The
InternalsVisibleTo item of the modular monolith concept ends up in that
generated file:
csharp[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Core.Tests")]so Core.csproj needs no AssemblyInfo.cs (see the concept "Modular Monolith
Module Boundaries").
To keep output out of the source folders, set ArtifactsPath in
Directory.Build.props (or UseArtifactsOutput=true for the default
location). After adding <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>,
the layout was artifacts/bin/App/debug/, artifacts/obj/Core/debug/ and so
on, and src/Core held only Core.csproj and its sources. publish and
package get sibling folders per the documentation, and dotnet new buildprops --use-artifacts generates the file.
Binary logs
dotnet build -bl:build.binlog writes a 470 KB log of everything MSBuild did.
It can be replayed through the normal loggers without building again:
text$ dotnet msbuild build.binlog -v:diag > replay.txt # 154,666 lines
and searched for imports, property values and task parameters (here the
TreatWarningsAsErrors=true task parameter passed to the compiler). The
MSBuild Structured Log Viewer reads the same file in a tree view; it is a
separate tool and was not used here.
Trade-offs
- Implicit globbing removes ceremony and hides the file set. Nothing in the
project says which files compile, so a stray file in any subfolder joins the
build.
-getItem:CompileandCompile Removeare the escape hatch, andEnableDefaultCompileItems=falseis the nuclear option that makes you list every file again. - Shared defaults versus project autonomy. A root
Directory.Build.propsgives uniform rules, but a library that needsNullableoff must override it in its own project, and a vendored project copied into the tree silently inherits your settings.plaintext<PropertyGroup><Nullable>disable</Nullable></PropertyGroup> <!-- project body wins over Directory.Build.props --> - One file wins, not all of them. Nested
Directory.Build.propsfiles replace each other instead of stacking. Chain them withGetPathOfFileAboveor keep exactly one per tree. - Evaluation order is the main source of "my setting does nothing". Put
defaults in
.propsand anything that depends on a project's choice in.targets; verify with-getPropertyinstead of reading files. TreatWarningsAsErrorseverywhere is strict. It turns analyzer upgrades into build breaks. A narrower option is to list codes inWarningsAsErrors..slnxis smaller and diffs well, but not every tool reads it yet. The default changes across SDK versions, so pin the format explicitly.ArtifactsPathhelps tooling and breaks hard-coded paths. Scripts that look forbin/Releasenext to a project stop working.