Build and Tooling
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
The .NET SDK is a single download that brings the compiler, the build engine, the package manager, the test runner and the publishing pipeline, all behind one command-line tool. Everything an IDE does, the CLI can do too — which is why the same commands work on a laptop and in CI, and why this page starts there.
The dotnet CLI
Creating Projects
dotnet new list # every installed template
dotnet new console -o Sample # a console application
dotnet new classlib -o Sample.Core # a library
dotnet new xunit -o Sample.Tests # an xUnit test project
dotnet new webapi -o Sample.Api # an ASP.NET Core API
dotnet new gitignore # a .NET-aware .gitignore
dotnet new editorconfig # a starter .editorconfig
dotnet new globaljson --sdk-version 10.0.100
dotnet new sln -n Sample # a solution (add --format slnx for the XML format)
dotnet sln add Sample/Sample.csproj Sample.Core/Sample.Core.csproj
dotnet add Sample/Sample.csproj reference Sample.Core/Sample.Core.csproj
dotnet add Sample/Sample.csproj package Serilog --version 4.2.0
Templates are themselves NuGet packages, so a team’s house layout can be installed with dotnet new install
<package> and used like any built-in template.
Building and Running
dotnet restore # resolve packages (implicit in build/run/test)
dotnet build # Debug by default
dotnet build -c Release --no-restore
dotnet build -v detailed # q[uiet], m[inimal], n[ormal], d[etailed], diag[nostic]
dotnet build /p:TreatWarningsAsErrors=true
dotnet run # build, then run
dotnet run -c Release -- --input data.csv # everything after -- goes to the application
dotnet run --project Sample/Sample.csproj
dotnet clean
dotnet watch re-runs on every file change, and applies hot reload where the edit permits it — no restart,
and application state is preserved:
dotnet watch run # hot reload where possible, restart otherwise
dotnet watch test # re-run the tests on every save
dotnet watch --no-hot-reload run # always restart instead
Edits hot reload can apply include method bodies, adding members and adding types. Edits it cannot include changing a method signature, changing a type’s inheritance, or editing a generic method — those prompt a restart.
Testing, Publishing and Packing
dotnet test
dotnet test --filter "FullyQualifiedName~Geometry"
dotnet test --collect:"XPlat Code Coverage"
dotnet test --logger "trx;LogFileName=results.trx"
dotnet publish -c Release
dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true
dotnet publish -c Release -r win-x64 -p:PublishAot=true
dotnet pack -c Release
dotnet nuget push bin/Release/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "$KEY"
See Testing and Namespaces, Assemblies and Projects for what these produce.
A package needs metadata beyond dotnet pack’s defaults to be publishable — these go in the project file’s
`<PropertyGroup>:
<PropertyGroup>
<PackageId>MyLib.Core</PackageId>
<Version>1.4.0</Version>
<Authors>Jane Doe</Authors>
<Description>A short, one-line description shown on the NuGet.org package page.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageIcon>icon.png</PackageIcon>
<RepositoryUrl>https://github.com/example/mylib</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
PackageId is the identifier consumers dotnet add package by, defaulting to AssemblyName when omitted;
PackageLicenseExpression takes an SPDX identifier and is the modern replacement for the deprecated
PackageLicenseUrl; PackageReadmeFile/PackageIcon both need the matching <None Include>/Pack="true"
entry so the file actually ships inside the .nupkg, not just on disk.
Publishing to NuGet.org
Publishing requires an account at nuget.org (sign-in is via a Microsoft account), and,
for a package maintained by more than one person, an organization created from the account settings so
publish rights aren’t tied to a single individual. From Account Settings → API Keys, a scoped API key can be
generated for a glob pattern such as MyLib.*, limiting what that key can push rather than granting access to
every package the account owns:
dotnet nuget push bin/Release/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "$KEY"
- NuGet Trusted Publishing (OIDC)
-
NuGet.org also supports Trusted Publishing, a GitHub Actions OIDC-based flow that needs no stored
NUGET_API_KEYsecret at all — the registry instead trusts a token asserting the push came from one specific GitHub repository and workflow, configured on the package’s Trusted Publishing settings on nuget.org. It’s a drop-in replacement for thedotnet nuget push -k "${{ secrets.NUGET_API_KEY }}"step already shown below in== Continuous Integration; that snippet keeps the secret-based push as the documented fallback and notes the OIDC alternative alongside it, since a full second workflow isn’t needed to show the difference.
Global and Local Tools
A .NET tool is a NuGet package containing an executable. Installed globally it is on the PATH; installed
locally it is pinned in a manifest checked into the repository, so every contributor and CI agent gets the same
version:
# Global.
dotnet tool install -g dotnet-counters
dotnet tool list -g
dotnet tool update -g dotnet-counters
# Local, the better choice for a team.
dotnet new tool-manifest # creates .config/dotnet-tools.json
dotnet tool install dotnet-reportgenerator-globaltool
dotnet tool restore # on a fresh clone or CI agent
dotnet tool run reportgenerator -- -reports:coverage.xml -targetdir:report
The tools worth knowing: dotnet-counters, dotnet-trace, dotnet-dump, dotnet-gcdump, dotnet-stack,
dotnet-monitor (diagnostics); dotnet-ef (Entity Framework Core migrations); docfx (documentation);
dotnet-reportgenerator-globaltool (coverage reports).
MSBuild
The CLI’s build commands are front-ends to MSBuild, which evaluates the project file, its imports and the SDK’s targets into a dependency graph of targets and tasks.
The vocabulary is small: properties are single values (<TargetFramework>net10.0</TargetFramework>),
items are lists with metadata (<Compile Include="…"/>), targets are named units of work, and tasks
are the individual steps a target runs (Csc, Copy, Exec).
Evaluation order matters: Directory.Build.props is imported before the project body (so the project can
override it), and Directory.Build.targets after (so it can override the project). That is the entire basis
of repository-wide configuration:
<!-- Directory.Build.props: defaults for every project below this directory. -->
<Project>
<PropertyGroup>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="all" />
</ItemGroup>
</Project>
A custom target hooks into the standard sequence with BeforeTargets/AfterTargets:
<Project>
<Target Name="StampBuildTime" BeforeTargets="CoreCompile">
<PropertyGroup>
<BuiltAt>$([System.DateTime]::UtcNow.ToString('O'))</BuiltAt>
</PropertyGroup>
<Message Importance="high" Text="Building $(AssemblyName) at $(BuiltAt)" />
</Target>
<Target Name="CopyConfig" AfterTargets="Build">
<Copy SourceFiles="@(ConfigFiles)" DestinationFolder="$(OutDir)config" SkipUnchangedFiles="true" />
</Target>
</Project>
When a build does something unexpected, the binary log is the tool that answers it. dotnet build -bl writes
msbuild.binlog, which the MSBuild Structured Log Viewer opens to show every property value, every item and
every task invocation:
dotnet build -bl # produces msbuild.binlog
dotnet build -v diag > build.log # or the textual equivalent
dotnet msbuild -preprocess:full.xml # the fully-expanded project, imports inlined
IDEs and Editors
Visual Studio 2026 (Windows) is the most complete environment: the full debugger with data tips, IntelliTrace and the Parallel Stacks window, profiling, hot reload, database and Azure tooling, and the richest refactoring set.
VS Code with the C# Dev Kit is the cross-platform option. The base C# extension provides the language
service (Roslyn-powered completion, navigation, refactoring) and debugging; the Dev Kit adds solution
management, a test explorer and project templates. The common configuration lives in .vscode/launch.json and
tasks.json:
{
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Sample/bin/Debug/net10.0/Sample.dll",
"args": [],
"cwd": "${workspaceFolder}/Sample",
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}
JetBrains Rider is the cross-platform commercial IDE: its own analysis engine, refactorings and a well-regarded debugger and profiler (dotTrace/dotMemory integration).
All three share the same underlying compiler and analyzer configuration, so a .editorconfig produces the same
diagnostics in each — see
Coding Conventions and
Documentation.
Debugging
Breakpoints
Beyond the plain line breakpoint, the ones that save the most time:
-
Conditional breakpoints — break only when an expression is true (
order.Id == 4711). Far faster than stepping through a loop. -
Hit-count breakpoints — break on the n-th hit, or every n-th.
-
Tracepoints — print a message and continue, without editing the code to add a
Console.WriteLine. -
Function breakpoints — break on a method by name, even without navigating to it.
-
Exception breakpoints — break when an exception is thrown, before any
catchhas swallowed it. This is the single most useful setting when hunting a failure that is being caught and logged somewhere unhelpful. -
Data breakpoints — break when a field’s value changes.
In source, Debugger.Break() and Debugger.IsAttached let code participate, and the display attributes make
the debugger’s variable windows show what matters:
using System.Diagnostics;
[DebuggerDisplay("{Name,nq} ({Items.Count} items)")]
public sealed class Basket
{
public string Name { get; init; } = string.Empty;
[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
public List<string> Items { get; } = [];
[DebuggerStepThrough] // the debugger steps over this, not into it
public void Add(string item) => Items.Add(item);
public void BreakHere()
{
if (Debugger.IsAttached)
{
Debugger.Break();
}
}
}
Symbols and Source Link
A .pdb maps IL back to source. Portable PDBs are the modern cross-platform format, and Source Link
embeds the repository URL and commit so a debugger can download the exact source for a NuGet package from
GitHub — which is what makes stepping into a framework or third-party library work:
<PropertyGroup>
<DebugType>portable</DebugType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
Diagnostic Tools
These attach to a running process, including one in a container, and are the right first response to "it is slow" or "it is using too much memory" in an environment where a profiler cannot be installed:
dotnet-counters ps # list .NET processes
dotnet-counters monitor -p 1234 --counters System.Runtime,Microsoft.AspNetCore.Hosting
# live: CPU, heap size, gen 0/1/2 counts, exception rate, thread-pool queue length, requests/sec
dotnet-trace collect -p 1234 --profile cpu-sampling # a .nettrace for PerfView / Visual Studio
dotnet-trace collect -p 1234 --providers Microsoft-DotNETCore-SampleProfiler
dotnet-dump collect -p 1234 # full process dump
dotnet-dump analyze core_20260911 # then: clrstack, dumpheap -stat, gcroot <addr>
dotnet-gcdump collect -p 1234 # heap snapshot, openable in Visual Studio
dotnet-stack report -p 1234 # managed stacks of every thread -- deadlock hunting
dotnet-monitor wraps these behind an HTTP endpoint with rule-based triggers, which is how they are typically
run in Kubernetes.
The typical readings: rising gen-2 heap with no plateau means a leak; a high allocation rate with a flat heap means churn; a growing thread-pool queue with a rising thread count means blocked pool threads. See Memory Management and Disposal and Threads and Synchronization.
Analyzers and Source Generators
Analyzers are Roslyn components that inspect code as it compiles and report diagnostics, optionally with a code
fix the IDE can apply. The SDK’s CA rules are on by default; more arrive as NuGet packages, always with
PrivateAssets="all" so they do not flow to consumers:
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.0" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<AnalysisMode>Recommended</AnalysisMode>
<AnalysisLevel>latest</AnalysisLevel>
<ReportAnalyzer>true</ReportAnalyzer> <!-- per-analyzer timings, for build-time regressions -->
</PropertyGroup>
Source generators run in the same pass and add source to the compilation. The SDK already ships several
that are worth adopting — [GeneratedRegex], [LibraryImport], JsonSerializerContext, LoggerMessage — each replacing run-time reflection with compile-time code. To see what a generator actually produced:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
Scratchpads
Trying something out no longer requires a project:
File-based apps (C# 14 / .NET 10) run a single .cs file directly, with NuGet packages declared inline:
dotnet run app.cs
dotnet project convert app.cs # promote it to a real project when it outgrows one file
See Preprocessor Directives and
Compilation for the :package / :sdk / #:property directives these use.
LINQPad (Windows) is the long-standing C# scratchpad: expressions, statements or whole programs, with a
rich result dumper (.Dump()), NuGet integration and live database querying. Its Util.Dump output is often
the fastest way to understand an unfamiliar object graph.
C# Interactive (dotnet-script, or the Visual Studio interactive window) offers a REPL for the same
purpose.
Continuous Integration
The actions/setup-dotnet action installs the SDK; the rest is the same CLI used locally:
name: build
on:
push:
branches: [ main ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore
run: dotnet restore
- name: Verify formatting
run: dotnet format --verify-no-changes --no-restore
- name: Build
run: dotnet build -c Release --no-restore
- name: Test
run: dotnet test -c Release --no-build --collect:"XPlat Code Coverage" --logger trx
- name: Pack
if: startsWith(github.ref, 'refs/tags/v')
run: dotnet pack -c Release --no-build -o artifacts
- name: Publish to NuGet
if: startsWith(github.ref, 'refs/tags/v')
# Shown here with a stored API key, the documented fallback. Where NuGet.org Trusted Publishing (OIDC)
# is configured for this repository/workflow, this step needs no secret at all -- drop -k/${{ secrets.NUGET_API_KEY }}
# and add `permissions: id-token: write` to the job instead.
run: dotnet nuget push artifacts/*.nupkg -s https://api.nuget.org/v3/index.json -k "${{ secrets.NUGET_API_KEY }}"
Points worth copying: pin the SDK with global.json and dotnet-version so both agree; enable package
caching; run dotnet format --verify-no-changes so formatting never reaches review; use --no-restore and
--no-build on later steps so the work is not repeated; and set CI=true so ContinuousIntegrationBuild
normalises paths for source-link.
For a matrix build across operating systems or target frameworks, strategy.matrix over runs-on and a
-f net8.0/-f net10.0 argument covers it.
Practical Guidance
-
Learn the CLI first; every IDE is a front-end to it, and CI has nothing else.
-
Pin the SDK with
global.jsonand put shared settings inDirectory.Build.props. -
Prefer local tools with a manifest over global installs, so versions are reproducible.
-
Use
dotnet watchwhile developing and-blwhen a build misbehaves. -
Set exception breakpoints before you start guessing where a swallowed failure comes from.
-
Keep the diagnostic global tools installed; they answer production questions no log can.
-
Treat analyzer warnings as errors in CI and let
dotnet formatsettle every style argument.
See Also
-
Namespaces, Assemblies and Projects — the project file the CLI builds.
-
Testing —
dotnet testand the frameworks behind it. -
Coding Conventions and Documentation —
.editorconfig, analyzers anddotnet format. -
Preprocessor Directives and Compilation — compiler options and file-based apps.
-
Getting Started — installing the SDK in the first place.