Namespaces, Assemblies and Projects

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.

Three different things organise C# code, and confusing them causes a surprising amount of grief. A namespace is a compile-time naming scope. An assembly is a deployment and versioning unit — a .dll or .exe with its own metadata and its own accessibility boundary. A project is the build description that produces one assembly. They are independent: one assembly may contain many namespaces, and one namespace may span many assemblies.

Namespaces

A namespace groups related types and prevents name collisions. C# 10 introduced the file-scoped form, which is now the default in new templates and removes a level of indentation from every file:

namespace Irurueta.Sample.Geometry;

public sealed class Point
{
    public double X { get; init; }
    public double Y { get; init; }
}

The block form still exists, and is the only way to put two namespaces in one file:

namespace Irurueta.Sample.Blocked
{
    public sealed class Inner
    {
    }
}

using Directives

using System.Text;                          // import a namespace
using Json = System.Text.Json;              // namespace alias
using Sb = System.Text.StringBuilder;       // type alias
using static System.Math;                   // import a type's static members
using Coordinates = (double X, double Y);   // C# 12: alias any type, including tuples

namespace Irurueta.Sample.Usings;

public static class Aliases
{
    public static string Show()
    {
        Sb builder = new();
        builder.Append(Sqrt(16));                       // `using static System.Math`
        builder.Append(Json.JsonSerializer.Serialize(new { Value = 1 }));

        Coordinates origin = (0.0, 0.0);
        builder.Append(origin.X);

        return builder.ToString();
    }
}

C# 12 generalised the alias directive: using X = …; now accepts any type, including tuples, arrays, pointers and nullable value types, not just named types.

Global Usings and Implicit Usings

A global using applies to every file in the assembly. Put them in one file — conventionally GlobalUsings.cs — rather than scattering them:

global using System.Collections.Generic;
global using System.Linq;
global using Result = System.Collections.Generic.IReadOnlyList<string>;

<ImplicitUsings>enable</ImplicitUsings> makes the SDK add a set of global using directives appropriate to the project type (System, System.Linq, System.Threading.Tasks and so on for a console project; more for a web project). They can be adjusted from the project file:

<ItemGroup>
  <Using Include="System.Text.Json" />
  <Using Include="System.Console" Static="true" />
  <Using Include="System.Net.Http" Alias="Http" />
  <Using Remove="System.Net.Http" />
</ItemGroup>

Resolution and Conventions

Name lookup walks outward from the innermost enclosing namespace, then through using directives in scope. An ambiguity between two imported namespaces is an error, resolved with an alias or a fully-qualified name. The global:: qualifier forces lookup to start at the root, which matters when a local namespace shadows a framework one:

namespace Irurueta.Sample.System2;

public sealed class Resolution
{
    // global:: escapes any local namespace that happens to be called System.
    public static string Now() => global::System.DateTime.UtcNow.ToString("O");
}

The convention is Company.Product.Feature, matching the folder structure, with the assembly’s root namespace as the prefix. The <RootNamespace> property controls the default the IDE uses when creating files.

Assemblies

An assembly is what the compiler produces and what the runtime loads: IL, metadata, a manifest listing dependencies and resources, and optionally a strong-name signature. It is the unit of versioning, of deployment, and — crucially — of internal accessibility.

internal and InternalsVisibleTo

internal members are visible everywhere inside their own assembly and nowhere else. It is the right default for anything that is not part of a library’s public contract:

namespace Irurueta.Sample.Internals;

internal sealed class Parser
{
    internal int Parse(string text) => int.Parse(text);
}

public sealed class Facade
{
    private readonly Parser _parser = new();

    public int Parse(string text) => _parser.Parse(text);
}

Tests usually need access to internals. [InternalsVisibleTo] grants it to a named assembly:

<ItemGroup>
  <InternalsVisibleTo Include="Irurueta.Sample.Tests" />
</ItemGroup>

The SDK turns that item into the assembly-level attribute. If the assembly is strong-named, the grant must include the full public key, and the friend assembly must be signed with the matching key.

private protected (accessible to derived types within this assembly) and protected internal (accessible to derived types anywhere, plus everything in this assembly) are the two accessibility levels that combine the assembly boundary with inheritance — see Classes and Objects.

extern alias

When two referenced assemblies define the same fully-qualified type name — typically two versions of one library — an extern alias disambiguates them. The reference is given an alias in the project file:

<ItemGroup>
  <PackageReference Include="Legacy.Client" Version="1.0.0" Aliases="LegacyV1" />
  <PackageReference Include="Modern.Client" Version="3.0.0" />
</ItemGroup>

and the source declares it:

extern alias LegacyV1;

using LegacyClient = LegacyV1::Acme.Client;
using ModernClient = Acme.Client;

public static class Bridge
{
    public static void Copy(LegacyClient from, ModernClient to) => _ = (from, to);
}

This is a rare and deliberately awkward feature; needing it usually signals a dependency problem worth solving instead.

The SDK-Style Project

A modern .csproj is short, because the SDK supplies the defaults — every .cs file in the directory tree is compiled without being listed:

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

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>Irurueta.Sample</RootNamespace>
    <AssemblyName>Irurueta.Sample</AssemblyName>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <LangVersion>14.0</LangVersion>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="System.Text.Json" Version="10.0.0" />
    <ProjectReference Include="../Irurueta.Sample.Core/Irurueta.Sample.Core.csproj" />
  </ItemGroup>

  <ItemGroup>
    <EmbeddedResource Include="Resources/schema.json" />
    <None Include="README.md" Pack="true" PackagePath="/" />
    <Compile Remove="Generated/**" />
  </ItemGroup>

</Project>

The SDK to use is the first decision: Microsoft.NET.Sdk for libraries and console apps, Microsoft.NET.Sdk.Web for ASP.NET Core (see ASP.NET Reference), Microsoft.NET.Sdk.Razor, Microsoft.NET.Sdk.Worker and so on.

Target Frameworks and Multi-Targeting

<TargetFramework> names the API surface the code compiles against — net10.0, or a platform-specific variant such as net10.0-windows or net10.0-android. netstandard2.0 remains relevant only for libraries that must also load into .NET Framework.

A library can produce one binary per framework by using the plural property:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net10.0;net8.0;netstandard2.0</TargetFrameworks>
  </PropertyGroup>

  <!-- Conditional dependencies per target. -->
  <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
    <PackageReference Include="System.Memory" Version="4.6.0" />
  </ItemGroup>
</Project>

Each target is compiled separately, with its own preprocessor symbols (NET10_0_OR_GREATER and friends) — see Preprocessor Directives and Compilation.

Sharing Configuration

Directory.Build.props (imported before every project in the directory tree) and Directory.Build.targets (imported after) are how a repository states a policy once:

<!-- Directory.Build.props at the repository root -->
<Project>
  <PropertyGroup>
    <LangVersion>14.0</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <EnableNETAnalyzers>true</EnableNETAnalyzers>
    <AnalysisLevel>latest</AnalysisLevel>
    <Deterministic>true</Deterministic>
    <Authors>Irurueta</Authors>
    <PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
  </PropertyGroup>
</Project>

Directory.Packages.props with <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> adds central package management: versions are declared once for the repository, and each project references a package without a version.

Solutions and global.json

A solution groups projects for the IDE and for dotnet build. The classic .sln format is a bespoke text format; .slnx (supported from .NET 9 tooling) is a much simpler XML equivalent:

<Solution>
  <Project Path="src/Irurueta.Sample/Irurueta.Sample.csproj" />
  <Project Path="src/Irurueta.Sample.Core/Irurueta.Sample.Core.csproj" />
  <Project Path="tests/Irurueta.Sample.Tests/Irurueta.Sample.Tests.csproj" />
</Solution>

global.json pins the SDK version for everything under its directory, which is what makes a build reproducible across machines and CI:

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

rollForward decides how much newer an installed SDK may be: patch, feature, latestFeature, major, latestMajor, or disable for an exact match.

NuGet Packages

Consuming

<PackageReference> declares a dependency; dotnet restore (run implicitly by build and run) resolves the graph and writes obj/project.assets.json:

<ItemGroup>
  <PackageReference Include="Serilog" Version="4.2.0" />
  <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="[10.0.0,11.0.0)" />
  <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
    <PrivateAssets>all</PrivateAssets>          <!-- do not flow to consumers -->
  </PackageReference>
</ItemGroup>

Two behaviours to know. First, versions are minimums, not exact: 4.2.0 means "4.2.0 or later", and NuGet resolves the whole graph to the lowest version satisfying every constraint. A bracketed range pins it explicitly. Second, dependencies are transitive — a package’s own dependencies become yours — and <PrivateAssets>all</PrivateAssets> is how a build-only dependency such as an analyzer is stopped from propagating.

packages.lock.json (enabled with <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>) freezes the resolved graph so CI restores exactly what was tested.

Authoring

Any library project becomes a package by adding metadata and running dotnet pack:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>

    <PackageId>Irurueta.Sample</PackageId>
    <Version>2.1.0</Version>
    <Authors>Irurueta</Authors>
    <Description>Sample geometry helpers.</Description>
    <PackageTags>geometry;math</PackageTags>
    <PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
    <PackageProjectUrl>https://github.com/albertoirurueta/sample</PackageProjectUrl>
    <RepositoryUrl>https://github.com/albertoirurueta/sample</RepositoryUrl>
    <PackageReadmeFile>README.md</PackageReadmeFile>

    <!-- Ship symbols and enable source-link debugging from the package. -->
    <IncludeSymbols>true</IncludeSymbols>
    <SymbolPackageFormat>snupkg</SymbolPackageFormat>
    <PublishRepositoryUrl>true</PublishRepositoryUrl>
    <EmbedUntrackedSources>true</EmbedUntrackedSources>
  </PropertyGroup>
</Project>
dotnet pack -c Release                 # produces bin/Release/Irurueta.Sample.2.1.0.nupkg
dotnet nuget push bin/Release/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "$KEY"

The three-part version follows semantic versioning: a breaking change bumps the major, a backward-compatible addition the minor, a fix the patch. A pre-release suffix (2.1.0-rc.1) is ordered before the release.

Assembly Versioning and Strong Names

An assembly carries several versions, and they are not the same thing:

Property Attribute Used for

AssemblyVersion

[AssemblyVersion]

Binding identity. Part of the assembly’s name; changing it is a breaking change to any binary referencing it.

FileVersion

[AssemblyFileVersion]

The Win32 file version, shown in file properties. Informational.

InformationalVersion

[AssemblyInformationalVersion]

A free-form display version — typically the full semantic version plus the commit hash.

PackageVersion/Version

n/a

The NuGet package version.

The usual policy for a library is to keep AssemblyVersion at major.0.0.0 and let the other three move with every release, so that a patch does not require consumers to rebuild.

A strong name signs the assembly with a key pair, making its identity include a public key token. In modern .NET it is not a security feature — anyone can re-sign a modified copy — and it exists mainly so that an assembly can be referenced by strong-named callers and so that [InternalsVisibleTo] can be granted to a signed friend. Sign only if you must:

<PropertyGroup>
  <SignAssembly>true</SignAssembly>
  <AssemblyOriginatorKeyFile>irurueta.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

From Source to a Published Application

flowchart TD A["Source files
*.cs, *.resx, generated sources"] --> B["csc / Roslyn
one compilation per target framework"] R["PackageReference
+ ProjectReference"] --> N["NuGet restore
obj/project.assets.json"] N --> B B --> C["Assembly
MyLib.dll -- IL + metadata + manifest"] C --> P["dotnet pack
MyLib.2.1.0.nupkg
lib/net10.0/, lib/netstandard2.0/"] C --> D["dotnet publish"] D --> E["Framework-dependent
app.dll + deps.json
needs a shared runtime"] D --> F["Self-contained
app + runtime
per runtime identifier"] F --> G["Single file
one executable"] F --> H["ReadyToRun
precompiled IL, faster start"] F --> I["Trimmed
unused IL removed"] I --> J["Native AOT
native binary, no JIT"] style C fill:#eaf3fb,stroke:#3d6fa5 style P fill:#f7eef3,stroke:#9c5a7d style J fill:#eef6f1,stroke:#4a8a68

Deployment Models

Framework-dependent is the default: the output contains only your code and a deps.json, and a matching shared runtime must already be installed. It is the smallest output and the one that benefits from runtime servicing.

dotnet publish -c Release

Self-contained bundles the runtime, so nothing needs to be installed — at the cost of size, and of you owning the servicing. It requires a runtime identifier naming the target platform:

dotnet publish -c Release -r linux-x64 --self-contained true
dotnet publish -c Release -r win-x64   --self-contained true
dotnet publish -c Release -r osx-arm64 --self-contained true

Single-file packs the output into one executable (-p:PublishSingleFile=true). ReadyToRun (-p:PublishReadyToRun=true) precompiles IL to native code ahead of time, which cuts start-up time while keeping the JIT available for anything not precompiled.

Trimming (-p:PublishTrimmed=true) removes IL that nothing appears to reference. Native AOT (-p:PublishAot=true) goes further and compiles the whole application to a native binary with no JIT at all: the fastest start-up and the smallest memory footprint, at the cost of no run-time code generation, no Reflection.Emit, and no dynamic assembly loading:

<PropertyGroup>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <IsTrimmable>true</IsTrimmable>
  <EnableTrimAnalyzer>true</EnableTrimAnalyzer>
</PropertyGroup>

Both trimming and AOT depend on the code being statically analysable, which is why the reflection annotations matter — see Attributes and Reflection. A library intended for trimmed consumers should set <IsTrimmable>true</IsTrimmable> and fix every trim warning.

Practical Guidance

  • Match namespaces to folders, and keep one public type per file.

  • Default to internal; make a type public only when it is part of the contract you intend to support.

  • State shared build policy once in Directory.Build.props, and package versions once in Directory.Packages.props.

  • Pin the SDK with global.json so local and CI builds agree.

  • Multi-target only when you genuinely must support an older framework; each target is a support obligation.

  • Keep AssemblyVersion stable within a major version; move InformationalVersion with every build.

  • Choose the simplest deployment model that works, and add trimming or AOT only when start-up or size actually matters — and measure both before and after.

See Also