From a single file to a project

Since .NET 10 a single .cs file runs with dotnet run app.cs, without any project file (see The .NET SDK, the dotnet CLI and Your First Program). That is ideal for scripts and for learning. Behind the scenes the SDK still builds a project; it just makes one up for you, with sensible defaults.

A real application soon outgrows one file: several source files, tests in a separate project, a shared library, settings everyone on the team must use. Then you want an explicit project. You can create one from scratch with dotnet new, or convert a file-based app:

dotnet new console -o MeterTool      # new project in the folder MeterTool
dotnet project convert app.cs         # turn a file-based app into a project

dotnet project convert creates a folder with a .csproj and moves the #: directives of the file into it. Those directives are a preview of what a project file holds: #:property sets a project property and #:package adds a package reference. The examples on this page use #:property to show the effect of project settings in a single runnable file.

The project file

A project file is an XML file, with the extension .csproj for C#, that describes one unit of compilation: which source files to compile, for which framework, with which settings, and which packages and other projects it depends on. Building one project produces one assembly: a .dll file with the compiled Intermediate Language, plus an executable launcher for console apps. The build engine that reads project files is called MSBuild.

This is the complete project file that dotnet new console creates on the .NET 10 SDK:

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

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

</Project>

It is this short because it is an SDK-style project: a project file whose Sdk attribute imports a set of defaults and build steps from the .NET SDK, so the file itself only lists what differs from those defaults. Above all, every .cs file in the folder and its subfolders is compiled automatically; you do not list source files. Old .NET Framework projects, from before the SDK style, listed every file and ran to hundreds of lines; you recognize them by a long header and <Compile Include=…> lines. Other SDKs exist for other kinds of projects, such as Microsoft.NET.Sdk.Web for ASP.NET Core web apps.

Property Meaning
OutputType Exe for a program; leave it out for a class library (a .dll other projects use)
TargetFramework which .NET version to build for (next section)
ImplicitUsings import the common namespaces such as System.IO and System.Linq automatically
Nullable turn on nullable reference type checks
TreatWarningsAsErrors fail the build on any warning (see Testing and Code Quality)
Version the version number stamped into the assembly
AssemblyName, RootNamespace name of the output file and default namespace (both default to the project name)

Properties end up inside the compiled program. Here the Version property, set with #:property, is read back at run time through reflection:

#:property Version=2.3.1
using System.Reflection;

Assembly app = Assembly.GetEntryAssembly()!;
Console.WriteLine($"Assembly version: {app.GetName().Version}");
Console.WriteLine($"Product version:  {app.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion}");
Assembly version: 2.3.1.0
Product version:  2.3.1

Besides PropertyGroup, a project file has ItemGroup elements for lists: package references, references to other projects, and files that need special treatment (for example a settings file that must be copied next to the program):

<ItemGroup>
  <PackageReference Include="Humanizer.Core" Version="2.14.1"></PackageReference>
  <ProjectReference Include="..\MeterTools.Core\MeterTools.Core.csproj"></ProjectReference>
  <None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest"></None>
</ItemGroup>

An element without content may also be written in XML's short form, <PackageReference Include="Humanizer.Core" Version="2.14.1" />; that is how the dotnet commands write it, and both forms mean the same.

Shared settings: Directory.Build.props

When a repository holds several projects, put the settings they share in a file named Directory.Build.props in a parent folder. MSBuild imports it automatically into every project below it, so the whole team gets the same rules without repeating them in each .csproj:

<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <Company>Kestrel Energy</Company>
  </PropertyGroup>
</Project>

Target frameworks

The target framework is the .NET version (and API surface) a project is built for, written as a short code called a target framework moniker (TFM), such as net10.0. It decides which library APIs you may call and which runtime is needed to run the result. Common values:

TFM Meaning
net10.0, net8.0 modern .NET; runs on Windows, Linux and macOS
net10.0-windows modern .NET plus Windows-only APIs, for WinForms and WPF apps
netstandard2.0 a shared API set for libraries that must also work on .NET Framework
net48, net481 the Windows-only .NET Framework 4.8 and 4.8.1

A library that must support several platforms can list more than one, with the plural property: <TargetFrameworks>net10.0;net8.0</TargetFrameworks>. The program can also find out what it was built for:

#if DEBUG
Console.WriteLine("Debug build: extra checks on");
#else
Console.WriteLine("Release build");
#endif
Console.WriteLine($"Target: {AppContext.TargetFrameworkName}");
Console.WriteLine($"Runtime major version: {Environment.Version.Major}");
Console.WriteLine($"Assembly: {typeof(Program).Assembly.GetName().Name}");
Debug build: extra checks on
Target: .NETCoreApp,Version=v10.0
Runtime major version: 10
Assembly: example

.NETCoreApp is the internal name of modern .NET. The assembly is named after the file (this example ran as example.cs), just as a project's assembly is named after the project. The #if DEBUG block is explained under building.

Language version follows the framework

Each target framework comes with a default C# version: C# 14 for net10.0, C# 12 for net8.0. That is why a project that targets .NET 8 cannot use the newest language features, even when you build it with the .NET 10 SDK. Forcing an older version shows the effect:

#:property LangVersion=12
var meter = new Meter();
meter.Id = "  KE-1042 ";
Console.WriteLine(meter.Id);

class Meter
{
    public string Id { get; set => field = value.Trim(); } = "";
}
example.cs(8,19): error CS9202: Feature 'field keyword' is not available in C# 12.0. Please use language version 14.0 or greater.

Setting LangVersion higher than the framework's default is possible but not supported: some features need runtime support that an older framework lacks. The clean way to get new language features is to move the project to a newer target framework.

Pinning the SDK with global.json

The target framework says what you build for; the SDK is what you build with. By default dotnet uses the newest SDK installed. A global.json file in the repository pins a version, so that every developer and the build server use the same compiler:

{
  "sdk": {
    "version": "10.0.401",
    "rollForward": "latestFeature"
  }
}

rollForward allows newer patch and feature releases of the same major and minor version (10.0.x), so security fixes do not require editing the file.

Solutions

A solution file lists the projects that belong together, so that one command or one IDE window can build, test and open all of them. It contains no build settings of its own. Since the .NET 10 SDK, dotnet new sln creates the XML-based .slnx format; the older .sln format is still supported, and dotnet solution migrate converts it.

dotnet new sln -n MeterTools
dotnet new classlib -o src/MeterTools.Core
dotnet new console -o src/MeterTools.Cli
dotnet solution add src/MeterTools.Core src/MeterTools.Cli
dotnet reference add src/MeterTools.Core --project src/MeterTools.Cli

The last command adds a ProjectReference: the console app may now use the public types of the class library. This layout, with the logic in a library and a thin program around it, also makes testing easier (see Testing and Code Quality). The result:

MeterTools/
├── MeterTools.slnx
└── src/
    ├── MeterTools.Core/
    │   ├── MeterTools.Core.csproj
    │   └── Billing.cs
    └── MeterTools.Cli/
        ├── MeterTools.Cli.csproj
        └── Program.cs
<Solution>
  <Folder Name="/src/">
    <Project Path="src/MeterTools.Cli/MeterTools.Cli.csproj"></Project>
    <Project Path="src/MeterTools.Core/MeterTools.Core.csproj"></Project>
  </Folder>
</Solution>

NuGet packages

NuGet is the package manager for .NET: a package format (a .nupkg file, a zip archive with compiled assemblies and metadata), a public repository at nuget.org, and the tooling built into the dotnet CLI. As of October 2026, nuget.org lists more than 500,000 packages. It plays the same role as pip and PyPI for Python, or npm for JavaScript.

dotnet package add Humanizer.Core --version 2.14.1 --project src/MeterTools.Cli

This adds a PackageReference to the project file and runs a restore: NuGet resolves the package and its own dependencies, downloads them into a shared cache on your machine (the global packages folder, ~/.nuget/packages), and writes the result to obj/project.assets.json. Packages are not copied into your repository; dotnet build restores automatically, so a fresh clone only needs the project files. In a file-based app the same reference is a directive: #:package Humanizer.Core@2.14.1.

Versions

Package versions follow semantic versioning: MAJOR.MINOR.PATCH, where a new major version may break your code. In a PackageReference, Version="2.14.1" means "2.14.1 or higher", and NuGet picks the lowest version that satisfies all constraints, which keeps restores predictable. Exact ranges use interval notation: [2.14.1] is exactly that version, and [2.0,3.0) is at least 2.0 and below 3.0.

Versions must be compared as numbers, part by part, not as text. System.Version does that for numeric versions:

string[] versions = ["10.0.9", "10.0.401", "10.0.12", "9.0.20"];

Console.WriteLine("As text:    " + string.Join(", ", versions.Order(StringComparer.Ordinal)));
Console.WriteLine("As version: " + string.Join(", ", versions.Select(Version.Parse).Order()));
As text:    10.0.12, 10.0.401, 10.0.9, 9.0.20
As version: 9.0.20, 10.0.9, 10.0.12, 10.0.401

Lock files and central versions

Restores are already repeatable for your direct references. To also record the exact version of every indirect dependency, with a content hash, turn on a lock file with RestorePackagesWithLockFile. NuGet then writes packages.lock.json next to the project; commit it, and restore in CI with --locked-mode so that the build fails instead of silently resolving something new.

In a solution with many projects, central package management keeps every version in one place. The projects reference packages without a version, and a Directory.Packages.props file in the root lists them:

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Humanizer.Core" Version="2.14.1"></PackageVersion>
  </ItemGroup>
</Project>

In each project the reference then shrinks to <PackageReference Include="Humanizer.Core" />, and an upgrade is a one-line change for the whole solution.

Keeping dependencies safe and up to date

Every package is code written by someone else that runs with your program's permissions. Old versions collect known vulnerabilities, and a malicious or hijacked package is a supply chain attack. NuGet helps in three ways.

Audit during restore. On the current SDK, restore checks every package, including indirect ones, against a database of published security advisories, and reports a warning: NU1901 to NU1904 for low to critical severity. Adding an old version of a popular JSON library, for example:

warn : NU1903: Package 'Newtonsoft.Json' 12.0.1 has a known high severity vulnerability, https://github.com/advisories/GHSA-5crp-9r3c-p9vr

List what is vulnerable or outdated. Two commands give an overview per project:

dotnet package list --vulnerable
dotnet package list --outdated
Project `MeterTools.Cli` has the following vulnerable packages
   [net10.0]:
   Top-level Package      Requested   Resolved   Severity   Advisory URL
   > Newtonsoft.Json      12.0.1      12.0.1     High       https://github.com/advisories/GHSA-5crp-9r3c-p9vr

Project `MeterTools.Cli` has the following updates to its packages
   [net10.0]:
   Top-level Package      Requested   Resolved   Latest
   > Humanizer.Core       2.14.1      2.14.1     3.0.10
   > Newtonsoft.Json      12.0.1      12.0.1     13.0.4

Automate the updates. Tools such as Dependabot open pull requests when a new version or a security fix appears. Combine that with tests, so that you can accept updates with confidence. With TreatWarningsAsErrors, the audit warnings even stop the build until the package is updated.

Building

dotnet build restores packages, compiles the project and everything it references, and writes the output to bin/<Configuration>/<TargetFramework>/. Intermediate files go to obj/. Both folders are generated, so they belong in .gitignore (dotnet new gitignore creates a suitable one). Building again is incremental: unchanged projects are skipped.

Humanizer.dll                      a package dependency
MeterTools.Cli                     the launcher (MeterTools.Cli.exe on Windows)
MeterTools.Cli.dll                 your compiled code (IL)
MeterTools.Cli.pdb                 debug symbols: file names and line numbers
MeterTools.Cli.deps.json           which dependencies to load
MeterTools.Cli.runtimeconfig.json  which .NET runtime to start
MeterTools.Core.dll                the referenced class library

A build has a configuration. Debug, the default for dotnet build and dotnet run, compiles with little optimization so that the debugger can follow every line; Release (-c Release) optimizes for speed. The Debug configuration also defines the symbol DEBUG, which #if DEBUG tests at compile time: code in a #if DEBUG block is not even compiled into a Release build. The example under target frameworks printed "Debug build" because the examples on this site are built in Debug.

You can define your own symbols with the DefineConstants property. Writing $(DefineConstants) first keeps the symbols that are already defined, such as DEBUG:

#:property DefineConstants=$(DefineConstants);TEST_METERS
#if TEST_METERS
string[] meters = ["KE-TEST-1", "KE-TEST-2"];
#else
string[] meters = LoadMetersFromDatabase();
#endif
Console.WriteLine($"Meters: {string.Join(", ", meters)}");
#if DEBUG
Console.WriteLine("DEBUG is still defined");
#endif

static string[] LoadMetersFromDatabase() => throw new NotImplementedException();
Meters: KE-TEST-1, KE-TEST-2
DEBUG is still defined

Use such symbols sparingly: every #if creates a variant of the program that has to be built and tested separately. For behavior that differs per environment, configuration read at run time is usually the better tool.

Errors and warnings

The build prints every compiler message as file(line,column): severity code: message, for example Program.cs(12,9): error CS0029: …. The code, such as CS0029, is the quickest way to find documentation: the compiler reference on Microsoft Learn has a page per code. Errors stop the build; warnings do not, which is why it pays to keep the warning count at zero (see Testing and Code Quality).

Publishing

dotnet publish prepares an application for deployment: it builds in Release (the default for publish since .NET 8) and collects everything the program needs in a publish/ folder. You copy that folder to a server or package it into a container image. There are three main forms.

From project to deployment: the project file and NuGet packages go into dotnet build, which produces IL assemblies. dotnet publish then produces one of three forms: framework-dependent (your DLLs, needs an installed .NET runtime), self-contained (your DLLs plus the runtime, for one platform) or Native AOT (a single native executable, no JIT). .csproj + your .cs files NuGet packages restored from nuget.org dotnet build IL assemblies (.dll) dotnet publish Framework-dependent your DLLs; needs .NET installed on the machine Self-contained your DLLs + the runtime, for one platform Native AOT one native executable, no JIT, no IL
One build, three ways to publish it. The further down, the less the target machine needs, and the more you give up.

Framework-dependent

The default. The publish folder contains your assemblies and their packages, but not .NET itself: the target machine needs a matching .NET runtime installed. The output is small and runs on any operating system that has that runtime, and the runtime gets security updates through the machine's normal updates. This is the usual choice for servers you manage and for container images based on Microsoft's .NET runtime images.

dotnet publish src/MeterTools.Cli

Self-contained

A self-contained deployment includes the .NET runtime itself in the published output, so the program runs on a machine without .NET installed. The price: the output is built for one platform, named by a runtime identifier (RID) such as win-x64, linux-x64 or linux-arm64; it is much larger; and runtime security fixes only arrive when you republish.

dotnet publish src/MeterTools.Cli -r linux-x64 --self-contained -p:PublishSingleFile=true

PublishSingleFile bundles everything into one executable, convenient for a tool you hand to colleagues. On this site's test machine, a self-contained single-file build of a small console app for linux-x64 came to about 70 MB. Trimming (-p:PublishTrimmed=true) removes unused parts of the libraries and shrinks that considerably, but code that discovers types at run time through reflection may then miss what was removed.

Native AOT

Native AOT (ahead-of-time compilation) publishes an application as a self-contained native executable: the compiler translates all the Intermediate Language into machine code during publishing, so there is no just-in-time compiler at run time. The program starts in milliseconds, uses less memory, and is small: a small console app came to about 1.3 MB (built for linux-arm64, so not a like-for-like comparison with the 70 MB above). That suits command-line tools, serverless functions and small containers.

dotnet publish src/MeterTools.Cli -r linux-arm64 -p:PublishAot=true

The restrictions follow from "everything is known in advance":

  • No generating or loading code at run time, and only limited reflection. Libraries that inspect types at run time, such as reflection-based JSON serialization, need an alternative such as source generation. The build reports problems as warnings such as IL2026 and IL3050; take them seriously.
  • You compile on the platform you target (Windows for Windows, Linux for Linux), and a native toolchain must be installed.
  • Not every library supports it yet.
#:property InvariantGlobalization=true
using System.Globalization;

Console.WriteLine(1234.5m.ToString("N2"));
var nl = new CultureInfo("nl-NL");
Console.WriteLine(1234.5m.ToString("C", nl));
1,234.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')

InvariantGlobalization leaves out the culture data (number and date formats per language), which makes a published app smaller. The price is that only the invariant culture exists, so a Dutch euro format fails at run time. Project settings change behavior, not only size: test the published form of your app, not just dotnet run.

Form Needs .NET on target Platform-specific Size Startup
Framework-dependent yes no small normal (JIT)
Self-contained no yes large normal (JIT)
Native AOT no yes small to medium fastest