Anatomy of a csproj: SDK-Style Projects, Common Properties, Items and Implicit Behaviors
An SDK-style csproj stays short thanks to the imports added by the Sdk attribute, Directory.Build.props and default globs such as **/*.cs; dotnet msbuild -getProperty shows a property's real value, -getItem shows items and -preprocess the whole file. We read Bookstore's project files, remove template duplication and show with real output why appsettings.json isn't copied in a console project, plus InvariantGlobalization, PrivateAssets and InternalsVisibleTo.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404, MSBuild 18.9).
Guess First
We added an appsettings.json to the project we created with the CLI in the previous post for Bookstore's bookstore-admin tool. This is the project file, and the folder contains Program.cs and appsettings.json:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>bookstore-admin</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Bookstore.Contracts\Bookstore.Contracts.csproj" />
</ItemGroup>
</Project>
Three questions: which framework does this project target? Which .cs files does it compile? Can dotnet run -- info read the API address from the settings file? There is no TargetFramework in the file, yet the project targets net10.0. There is no file list, yet every .cs file in the folder is compiled. The answer to the third question surprises most people:
Runtime: .NET 10.0.15
Environment: (not set)
API: (appsettings.json not found)
The file is in the folder but it is not copied to bin/. The same file is copied without any trouble in Bookstore.Api. In this post we read the project files of Bookstore, the online bookstore used as the shared example across this site's series, line by line, and look at where implicit behaviors like this come from, at the common properties and items, and at how to see the final shape of a project file.
The Sdk Attribute and the Invisible Imports
The Sdk attribute on the first line of an SDK-style project file adds one import at the top of the file and one at the bottom. In MSBuild's eyes the project above is the same as this:
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
</Project>
This explicit form also builds and runs. Sdk.props comes before your properties and sets the defaults; Sdk.targets comes after and defines the build steps using your values. There are different SDKs for different project types: Microsoft.NET.Sdk for class libraries and console apps, Microsoft.NET.Sdk.Web for ASP.NET Core, Microsoft.NET.Sdk.Worker for background services and Microsoft.NET.Sdk.Razor for Razor class libraries. The Web SDK adds Razor, static web asset and publish support on top of the base SDK.
-preprocess shows how much those two lines bring in. It expands every import into a single file:
dotnet msbuild tools/Bookstore.Admin -preprocess:admin.pp.xml
The 16-line Bookstore.Admin.csproj turns into a 17,416-line project built from 115 separate .props and .targets files on SDK 10.0.404; for Bookstore.Api, which uses the Web SDK, it is 169 files and 23,258 lines. You won't read this file end to end, but it is useful for finding where a property is defined. Search for Directory.Build.props, for example, and you'll see it imported on line 94, from inside Sdk.props, long before the contents of your csproj. The next post shows why that order matters.
Bookstore.Api.csproj Line by Line
Bookstore's API project is short:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>Bookstore</RootNamespace>
<UserSecretsId>bookstore-api</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
RootNamespace is the prefix of the namespace the IDE suggests for new files and of embedded resource names. It defaults to the project name, Bookstore.Api. The API code uses namespaces such as Bookstore.Catalog and Bookstore.Ordering, so the root is set to Bookstore. UserSecretsId tells dotnet user-secrets which folder holds the connection string; values that must not enter the repo live there. The package lines have no versions because Bookstore uses central package management. So where are TargetFramework, Nullable and ImplicitUsings? In the Directory.Build.props at the repo root:
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
MSBuild searches for this file up the folder tree and adds it to every project automatically. What the common properties do:
| Property | What it does | Default |
|---|---|---|
TargetFramework | The targeted .NET version; TargetFrameworks for several | None, required |
OutputType | Exe or Library | Library (Exe in the Web and Worker SDKs) |
Nullable | Nullable reference type analysis | Off; templates turn it on |
ImplicitUsings | Generates global usings based on the SDK | Off; templates turn it on |
LangVersion | C# language version | Based on the target framework; 14.0 for net10.0 |
AssemblyName, RootNamespace | Output file name, default namespace | Both the project name |
TreatWarningsAsErrors | Turns compiler warnings into errors | false |
GenerateDocumentationFile | Produces the XML documentation file | false |
InvariantGlobalization | Runs without ICU | false |
UserSecretsId | Identifies the user-secrets folder | None |
You rarely need to write LangVersion by hand. The TFM already determines which C# version is supported. Setting latest or preview can enable language features on an older TFM that its runtime doesn't support, so only do it on purpose.
Seeing the Final Project: -getProperty, -getItem, -preprocess
Instead of guessing a property's value you can ask for it. The -getProperty option of dotnet msbuild evaluates the project and returns the result as JSON without building:
dotnet msbuild src/Bookstore.Api -getProperty:TargetFramework -getProperty:OutputType -getProperty:LangVersion -getProperty:RootNamespace -getProperty:AssemblyName -getProperty:TreatWarningsAsErrors
{
"Properties": {
"TargetFramework": "net10.0",
"OutputType": "Exe",
"LangVersion": "14.0",
"RootNamespace": "Bookstore",
"AssemblyName": "Bookstore.Api",
"TreatWarningsAsErrors": "true"
}
}
None of them is written in the csproj, yet every value is known. OutputType comes from the Web SDK, LangVersion from the TFM, TargetFramework and TreatWarningsAsErrors from Directory.Build.props. For items you use -getItem. Let's see how the appsettings files are marked in the API project (the relevant part of the output):
dotnet msbuild src/Bookstore.Api -getItem:Content
{
"Items": {
"Content": [
{
"Identity": "appsettings.Development.json",
"ExcludeFromSingleFile": "true",
"CopyToOutputDirectory": "PreserveNewest",
"CopyToPublishDirectory": "PreserveNewest",
...
"DefiningProjectName": "Sdk.StaticWebAssets.StaticAssets.ProjectSystem",
"DefiningProjectExtension": ".props"
},
{
"Identity": "appsettings.json",
...
Here is the answer: the Web SDK turns the .json files in the project folder into Content items and sets their CopyToOutputDirectory metadata itself. DefiningProjectName even tells you which SDK file the rule comes from. The same command on bookstore-admin, which uses Microsoft.NET.Sdk, returns no Content item; there appsettings.json is a None item without metadata. We'll use this technique often in the coming posts; it is the shortest answer to "what is this property's actual value?". To see which line of which file set a property you need a binary log, which post 8 covers.
Cleaning Up What the Templates Left Behind
dotnet new console and dotnet new classlib don't check whether the repo has a Directory.Build.props. Each of the three projects we created in the previous post carried this:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
It looks harmless today because the values are the same. But when you move Directory.Build.props to net11.0 tomorrow, these three projects stay on net10.0 and nobody notices; the value in the csproj overrides the one in Directory.Build.props. We deleted the repeated lines and gave bookstore-admin its output name. We ask the same question before and after the change:
dotnet msbuild tools/Bookstore.Admin -getProperty:TargetFramework -getProperty:Nullable -getProperty:LangVersion -getProperty:AssemblyName -getProperty:RootNamespace
{
"Properties": {
"TargetFramework": "net10.0",
"Nullable": "enable",
"LangVersion": "14.0",
"AssemblyName": "bookstore-admin",
"RootNamespace": "Bookstore.Admin"
}
}
The values stayed the same; only AssemblyName changed. Note that RootNamespace is not derived from bookstore-admin: its default comes from the project name, so the code stays in the Bookstore.Admin namespace while the output files become bookstore-admin.exe and bookstore-admin.dll on Windows. For a command-line tool that is the name users type, so it is the right choice.
Properties That Change Behavior
Some properties change not just the output but whether the build succeeds or how the app runs. Bookstore.Contracts will be published as a package in post 4, and we want it to produce an XML documentation file so that developers using the package see method descriptions in their IDE:
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
C:\src\Bookstore\src\Bookstore.Contracts\Isbn.cs(3,31): error CS1591: Missing XML comment for publicly visible type or member 'Isbn' [C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj]
C:\src\Bookstore\src\Bookstore.Contracts\Isbn.cs(7,19): error CS1591: Missing XML comment for publicly visible type or member 'Isbn.Value' [C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj]
C:\src\Bookstore\src\Bookstore.Contracts\BookSummary.cs(3,22): error CS1591: Missing XML comment for publicly visible type or member 'BookSummary' [C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj]
...
11 Error(s)
With GenerateDocumentationFile on, the compiler raises warning CS1591 for every public member without documentation. TreatWarningsAsErrors from Directory.Build.props turns those warnings into errors and the build breaks. There are two ways out: write the documentation, or silence the warning in this project with <NoWarn>$(NoWarn);CS1591</NoWarn>. For a library that becomes a package the first is right, and with only a few public types we wrote the descriptions:
/// <summary>A validated 13-digit International Standard Book Number.</summary>
public readonly record struct Isbn
{
private Isbn(string value) => Value = value;
/// <summary>The 13 digits without hyphens or spaces.</summary>
public string Value { get; }
The build passed and Bookstore.Contracts.xml appeared under bin/Debug/net10.0/. When you write NoWarn, don't forget the $(NoWarn); prefix; without it you erase the values the SDK and Directory.Build.props added earlier.
The second example is InvariantGlobalization. With it on, the app runs without loading ICU, the output gets smaller and a Linux container image no longer needs the ICU package; we'll measure that for Native AOT in post 6. But Bookstore shows prices in Turkish lira, and the price command of bookstore-admin uses the tr-TR culture. Let's switch the property on temporarily from the command line:
dotnet run --project tools/Bookstore.Admin -- price 1249.5
dotnet run --project tools/Bookstore.Admin -p:InvariantGlobalization=true -- price 1249.5
₺1.249,50
Unhandled exception. System.Globalization.CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
tr-tr is an invalid culture identifier.
at System.Globalization.CultureInfo.GetCultureInfo(String name)
Invariant mode only has the invariant culture, and GetCultureInfo("tr-TR") throws. That is why we did not enable the property in bookstore-admin. The -p:Name=Value syntax works with every dotnet command and is the quickest way to try a property without editing the file.
Items: Compile, None, Content and Globs
In an SDK-style project you don't list files one by one. The SDK adds the **/*.cs files in the project folder as Compile items and the remaining files as None items, excluding bin/ and obj/. The Web SDK adds Content items for wwwroot/** and .json files. If an old .NET Framework habit makes you list a file explicitly:
<ItemGroup>
<Compile Include="Isbn.cs" />
</ItemGroup>
error 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' if you want to explicitly include them in your project file. For more information, see https://aka.ms/sdkimplicititems. The duplicate items were: 'Isbn.cs' [C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj]
To exclude a file from compilation use <Compile Remove="Legacy/**" />; to change the metadata of an existing item use Update. Include adds a new item, Update modifies an item the glob has already added. That is also the fix for the appsettings.json problem from the start:
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Runtime: .NET 10.0.15
Environment: (not set)
API: http://localhost:5080
PreserveNewest copies the file only when it changes; Always copies it on every build and slows incremental builds for no reason. When a None item has CopyToOutputDirectory, the file also goes to the publish output. The output of dotnet publish tools/Bookstore.Admin now has appsettings.json, bookstore-admin.exe, bookstore-admin.dll and Bookstore.Contracts.dll side by side.
PackageReference, ProjectReference and InternalsVisibleTo
The metadata on a PackageReference item decides which parts of the package flow into your project and into projects that reference yours. Microsoft.EntityFrameworkCore.Design in Bookstore.Api is the typical example: a package the dotnet ef tool needs when generating migrations but the app never uses at run time. Because PrivateAssets is all, the package does not flow into the test project that references the API:
dotnet package list --project tests/Bookstore.Api.Tests --include-transitive
Transitive Package Resolved
...
> Microsoft.EntityFrameworkCore 10.0.15
> Microsoft.EntityFrameworkCore.Abstractions 10.0.15
> Microsoft.EntityFrameworkCore.Analyzers 10.0.15
> Microsoft.EntityFrameworkCore.Relational 10.0.15
> Microsoft.EntityFrameworkCore.SqlServer 10.0.15
...
The SqlServer package arrived transitively, Design did not. The IncludeAssets line has no compile in its list, which keeps the API code from using this package's types by accident. You'll see the same pair on analyzer, source generator and build tool packages.
ProjectReference adds another project to the build and to the references; unlike packages it has no version and is compiled from source on every build. A common need in tests is access to internal types. We moved the code that computes the ISBN check digit in Bookstore.Contracts into an internal class called IsbnChecksum and wanted to test it directly from the test project:
C:\src\Bookstore\tests\Bookstore.Contracts.Tests\IsbnChecksumTests.cs(13,32): error CS0122: 'IsbnChecksum' is inaccessible due to its protection level [C:\src\Bookstore\tests\Bookstore.Contracts.Tests\Bookstore.Contracts.Tests.csproj]
This used to mean writing an attribute into AssemblyInfo.cs. The SDK now takes it as an item and generates the attribute during the build:
<ItemGroup>
<InternalsVisibleTo Include="Bookstore.Contracts.Tests" />
</ItemGroup>
The generated file (obj/Debug/net10.0/Bookstore.Contracts.AssemblyInfo.cs) shows the corresponding line, and the tests pass:
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bookstore.Contracts.Tests")]
Test run summary: Passed!
total: 7
failed: 0
succeeded: 7
skipped: 0
The test project got cleaned up too: nothing is left in it except OutputType, the PackageReference and the ProjectReference. For the Xunit namespace you can add a <Using Include="Xunit" /> item as in Bookstore.Api.Tests; this item adds your own namespaces to the global using list that ImplicitUsings generates.
In Production
Check a property's value with dotnet msbuild -getProperty before and after you change it. Especially in multi-project solutions, guessing whether a value comes from the csproj, from Directory.Build.props or from the SDK leads to mistakes. A small CI step that verifies critical properties (TFM, TreatWarningsAsErrors, Nullable) with this command catches a setting that was switched off by accident early.
The first time you turn on a property that changes run-time behavior, such as InvariantGlobalization or PublishTrimmed, actually run the app to test it. The build passes; the failure only shows up on the affected code path, in production.
Use NoWarn per project and with a reason instead of silencing warnings wholesale. Deciding as a team which analyzer rule becomes an error and when is the subject of the Static Analysis series.
Common Pitfalls
appsettings.json not reaching the output or publish folder. The Web SDK turns these files into Content automatically, while in console and class library projects they stay as None without metadata. Every project that moves from Worker to console, or reads configuration in a console app, hits this at least once; add the None Update line.
Carrying over old .NET Framework csproj habits. Listing files one by one gives NETSDK1022, and turning off EnableDefaultCompileItems means new files silently stop being compiled. Trust the globs and write exceptions with Remove.
Leaving the properties that came from the template. When the shared setting in Directory.Build.props changes, the old value in the csproj overrides it and projects silently stay on different TFMs.
Writing NoWarn without $(NoWarn);. You erase the values added by the SDK and the parent files, and warnings that were hidden before suddenly turn into errors.
Setting LangVersion to latest. On older TFMs it can enable features the runtime doesn't support, and the language version changes on its own when the SDK is updated. The default is chosen per TFM and is usually what you want.
A Small Experiment
★☆☆ Get the global using list the Web SDK generates with dotnet msbuild src/Bookstore.Api -getItem:Using and compare it with the output of the same command for tools/Bookstore.Admin. Which namespaces does the Web SDK add?
★★☆ Add a few sample JSON files under a data/ folder in bookstore-admin and copy them to the output with a single None Update="data/**/*.json" line. Then keep them out of the publish output while leaving them in the build output (hint: CopyToPublishDirectory).
★★★ In the file expanded with dotnet msbuild tools/Bookstore.Admin -preprocess:admin.pp.xml, find in which SDK file and under which condition the default values of AssemblyName and RootNamespace are set. Use those lines to explain why changing AssemblyName doesn't affect RootNamespace.
In this post we looked inside a single project file and saw that TargetFramework really comes from Directory.Build.props. In the next post we cover that file and its sibling Directory.Build.targets, central package management, and the order in which shared settings are evaluated in a multi-project solution.