Skip to content

Project structure & SDK

Targets: net10.0, csharp-14, fsharp-10 · Last reviewed: 2026-08-21 · Sources: ms-learn, dotnet-blog, gerald-versluis, steve-gordon, aaron-stannard, house

One solution format, central package management, versions pinned at the root. The templates/ directory encodes these opinions as copy-paste-ready files: copy them verbatim and trim, rather than authoring from scratch.

Solution & SDK

  • Use .slnx for new solutions: dotnet new sln defaults to it in the .NET 10 SDK; migrate .sln files opportunistically. Use .slnf filters for large solutions. Start from templates/example.slnx and templates/example.slnf. (Microsoft Learn: Breaking changes in .NET 10)
  • Pin the SDK with global.json (roll-forward latestFeature, per templates/global.json). (Microsoft Learn: What's new in .NET 10)
  • The pinned version is a floor, not a selection, so raise it only when you need a newer feature band. Under rollForward: latestFeature, 10.0.400 means that version or any later band and patch installed on the machine, so a new band reaches the build without the file changing. Pinning a patch (10.0.412) or chasing each band as it ships narrows who can build and fails contributors one servicing release behind, buying nothing. Move the floor when a band ships something the repository actually uses, and record which feature in the commit. (Microsoft Learn: global.json overview)
  • Trial a preview SDK with sdk.paths, and ignore the folder it installs into in the same commit. ./dotnet-install.sh --install-dir .dotnet (-InstallDir on the PowerShell script) puts the preview SDK inside the repository rather than on the machine, and paths makes the host prefer it while still falling back to the machine install via $host$. Paths resolve relative to global.json, not the working directory, so this holds from any subdirectory; errorMessage is what a contributor who has neither SDK sees instead of a bare version error. Two limits: paths applies only to commands that engage the SDK (dotnet build, dotnet run) and is ignored by the apphost and dotnet app.dll; and the folder is a full SDK, hundreds of megabytes that must never be committed. (Versluis: Test .NET MAUI Preview SDKs Locally with global.json sdk.paths, Microsoft Learn: test prerelease SDKs locally)
{
  "sdk": {
    "version": "11.0.100-preview.1.25000.1",
    "paths": [".dotnet", "$host$"],
    "errorMessage": "Run ./dotnet-install.sh --install-dir .dotnet --version 11.0.100-preview.1.25000.1"
  }
}

dotnet new gitignore does not cover .dotnet/, because the template ignores build output and a locally installed SDK is a build prerequisite. Every .NET repository that installs one this way adds the rule by hand (dotnet/runtime, dotnet/aspnetcore, dotnet/sdk, dotnet/maui all carry it). Adopting sdk.paths therefore means one line in the repo-specific block of .gitignore below, and it is not optional.

Repository layout

  • src/ for shipping code, tests/ for test projects, build configuration at the root. One project per directory, directory named after the assembly, and the solution mirrors the disk layout with /src/ and /tests/ solution folders. templates/example.slnx encodes exactly this shape. (Microsoft Learn: Organize projects for .NET Framework and .NET, dotnet/samples: migrate-library-csproj)
  • House: the solution also carries a /build/ solution folder listing the root build files (Directory.Build.props, Directory.Packages.props, global.json, .editorconfig). They hold every project's settings, and listing them in the solution puts them in Solution Explorer beside the projects they configure. templates/example.slnx includes it; Stannard: project-structure uses the same shape.
  • Enable the artifacts output layout: <UseArtifactsOutput>true</UseArtifactsOutput> in the root Directory.Build.props. All build output from all projects lands under one artifacts/<type>/<project>/<pivot> root instead of a bin/+obj/ pair per project directory. The types are bin, obj, publish and package, and the package type omits the project segment, so nupkgs land at artifacts/package/<configuration>. Tools and CI steps can rely on that layout, where the per-project one "can change drastically via relatively simple MSBuild changes". GA since .NET 8 but still opt-in on the .NET 10 SDK, so adopting it is a deliberate choice; templates/Directory.Build.props makes it. Two things to know when adopting: MSBuild reads the property before project evaluation, so it works only from Directory.Build.props or the command line, and setting it in a project file fails the build with NETSDK1199; and the pivot is the lowercase configuration alone for a single-TFM project (artifacts/bin/App/release, no TFM segment), so every hardcoded bin/<Config>/<tfm> path in Dockerfiles, CI copy steps and scripts must change in the same commit. dotnet new gitignore already ignores artifacts/. (Microsoft Learn: Artifacts output layout, .NET Blog: Announcing .NET 8 Preview 3)
  • Root files are the contract. global.json, Directory.Build.props, Directory.Packages.props, and .editorconfig live at the repository root so every project inherits them with no per-project setup: MSBuild walks up from each project to the nearest Directory.Build.props, and Central Package Management reads Directory.Packages.props the same way. (Microsoft Learn: Customize the build by folder, Microsoft Learn: Central Package Management)
  • Test projects sit beside, never inside, the code under test: tests/Example.Library.Tests mirrors src/Example.Library and is named <Project>.Tests, as in the same sample; opinions/testing.md covers what goes in them.
  • Inside a project, group by feature, not by pattern: see architecture.md for how modules and vertical slices sit under this layout.
  • Don't add layout you don't need yet. A single-project tool is fine as src/Tool plus tests/Tool.Tests; add docs/ and solution filters when the repository needs them, not on day one. (Microsoft Learn: Organizing and testing projects with the .NET CLI)
  • Generate .gitignore with dotnet new gitignore, and don't hand-maintain one. The SDK template is the canonical .NET ignore set and evolves with the toolchain; a hand-rolled copy (or a copy-paste from another repo) drifts, which is why this repository deliberately ships no .gitignore template. Regenerate after major SDK upgrades; keep any repo-specific additions in a clearly marked block at the bottom so regeneration is a safe overwrite-above-the-line. .dotnet/ from an sdk.paths install is exactly such an addition. (Microsoft Learn: dotnet new gitignore)

.editorconfig

  • Every repository carries a root .editorconfig, and templates/.editorconfig is the canonical one. Copy it verbatim and trim rules you disagree with, but disagree deliberately. It encodes the house style: file-scoped namespaces, expression-bodied members where they fit on a line, collection expressions, auto-implemented properties (IDE0032), and standard .NET naming, with analyzer diagnostics defaulted to warning.
  • Style is enforced by the build, not by reviewers. .editorconfig severities only fail the build because templates/Directory.Build.props sets EnforceCodeStyleInBuild and TreatWarningsAsErrors. Adopt the pair together, or the style file is documentation, not enforcement.
  • One .editorconfig at the root, not one per project. Nested files are for real exceptions (e.g. relaxing doc-comment rules under tests/), and each nested file should contain only the delta: for a key set in both, the file deeper in the tree wins. (Microsoft Learn: Configuration files for code analysis rules)

Containers

  • Build images with the SDK (dotnet publish /t:PublishContainer), not a hand-written Dockerfile. The SDK produces the image directly, with no Dockerfile to drift out of sync with the project, and pushes to the local Docker/Podman daemon by default, a registry via ContainerRegistry, or a tarball via ContainerArchiveOutputPath. In .NET 10 this covers console apps natively too: <EnableSdkContainerSupport> is no longer required, aligning them with ASP.NET Core and Worker apps. (Microsoft Learn: Containerize a .NET app with dotnet publish, Microsoft Learn: What's new in the .NET 10 SDK)
<!-- All container config is MSBuild properties; illustrative values -->
<PropertyGroup Label="Container image">
  <ContainerRepository>contoso/example-worker</ContainerRepository>
  <ContainerImageTags>1.4.0;latest</ContainerImageTags>
  <ContainerFamily>noble-chiseled</ContainerFamily>
</PropertyGroup>
  • Know that .NET 10 base images are Ubuntu, not Debian. The version-only tags (mcr.microsoft.com/dotnet/aspnet:10.0) now resolve to Ubuntu 24.04 "Noble", and Microsoft ships no Debian images for .NET 10, so there is no tag to opt back into. If image size matters, prefer the chiseled variants via ContainerFamily (e.g. noble-chiseled) or Alpine (alpine) over anything hand-rolled. (Microsoft Learn: Default .NET container tags now use Ubuntu)
  • Take the -extra tag if the app is not invariant. The chiseled and Alpine variants recommended above ship neither ICU nor tzdata, and they set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true in the image. An app that names a culture therefore throws CultureNotFoundException at startup and one that only reads CurrentCulture formats and compares as invariant without complaint. A project-file <InvariantGlobalization>false</InvariantGlobalization> will not override an environment variable. noble-chiseled-extra and alpine-extra restore both. (globalization.md)
  • Keep the rootless default. Linux images run as the non-root app user (since .NET 8) and ContainerPort is inferred from ASPNETCORE_URLS, ASPNETCORE_HTTP_PORTS or ASPNETCORE_HTTPS_PORTS; don't set ContainerUser to root or re-expose privileged ports to make a broken volume mount work: fix the mount. (Microsoft Learn: Containerize a .NET app reference)