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_KEY secret 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 the dotnet 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).

Information and Diagnostics

dotnet --info                            # SDKs, runtimes, RID, environment
dotnet --list-sdks
dotnet --list-runtimes
dotnet sdk check                         # which installed SDKs/runtimes are out of support
dotnet nuget locals all --clear          # clear the package caches

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 catch has 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();
        }
    }
}

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.json and put shared settings in Directory.Build.props.

  • Prefer local tools with a manifest over global installs, so versions are reproducible.

  • Use dotnet watch while developing and -bl when 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 format settle every style argument.

See Also