Projects, NuGet and Building
From a single file to a real application: the project file, solutions, NuGet packages and versions, building, publishing self-contained and native AOT apps, and keeping dependencies up to date.
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.
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 |