The tools for .NET have advanced significantly from the .NET framework days. Unfortunately, when consulting documentation it can be difficult to pull out what is currently best practice and what is outdated.

This post represents a snapshot in the year 2024, targeting .NET 8.0/C#12. The structure of this template is borrowed from my time at Microsoft, and heavily influenced by my own personal opinion. I feel that it strikes a balance between the most common practices found in Microsoft, and developer productivity.

Not much has changed since I wrote a similar article in 2021. The updates in this post are mostly my own. I have been working in the typescript ecosystem for the past year, and am now able to take some learnings back to dotnet.

The repository is available on GitHub here and as a nuget template:

dotnet new install Pensono.DotNetStarterProject
mkdir MyProject && cd MyProject
dotnet new starterproject

Goals

  • One command to build, one command to test. In this case, those commands are dotnet build and dotnet test. This makes integration with CI easy, and allows developers and CI to share the same pipeline.
  • Use defaults when possible. Only special cases should be configured explicitly.
  • Minimal and easy to install tooling dependencies.
  • Use official tools as much as possible.

With this setup, dependencies are so limited that Visual Studio is not required to be productive.

Prerequisites

The following dependencies should be installed:

If it’s likely that team members have old .NET versions installed, you can enforce a minimum through a global.json file in the root. There’s also some versioning information here which will become relevant later.

global.json 
{
  "sdk": {
    "version": "8.0.303",
    "rollForward": "latestMajor"
  },
  "msbuild-sdks": {
    "Microsoft.Build.Traversal": "4.1.0"
  }
}

Overview

Below is a directory listing of the project. Each item will be explained in its section. Items marked with an asterisk are considered optional or project-dependent.

StarterProject
│   .gitignore
│   Directory.Build.props
│   Directory.Packages.props
│   dirs.proj
│   global.json
│   README.md
├───deployment*
├───docs*
├───shell
│       Init.ps1
│       MyTool.psm1
│       VisualStudio.psm1
├───src
│   ├───MyComponent
│   │   │   StarterProject.MyComponent.csproj
│   │   │   Source.cs
│   │   └───Folder
│   │           MoreSource.cs
│   └───AnotherComponent
│           StarterProject.AnotherComponent.csproj
│           AnotherSource.cs
├───test
│   └───MyComponent
│           SourceTest.cs
│           StarterProject.Test.MyComponent.csproj
└───tools*

Top-level configuration

There are some properties not set by default which should be used on new .NET projects. These can be configured in Directory.Build.props, which is applied to all projects within the directory. Other global configurations can be made here as well. I have included some packaging-related ones for sake of example.

Directory.Build.props 
<Project>
    <!-- General -->
    <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <LangVersion>12.0</LangVersion>
        <Nullable>enable</Nullable>
        <Features>strict</Features>
    </PropertyGroup>

    <!-- Build -->
    <PropertyGroup>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
        <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <!-- Enable linter -->
        <UseArtifactsOutput>true</UseArtifactsOutput>
        <RepositoryRoot>$(MSBuildThisFileDirectory)</RepositoryRoot>
    </PropertyGroup>
    
    <!-- Packaging -->
    <PropertyGroup>
        <IsPackable>false</IsPackable>
        <IsPublishable>false</IsPublishable>

        <!-- These properties will be used if packaging is enabled for a project -->
        <IncludeSymbols>true</IncludeSymbols>
        <SymbolPackageFormat>snupkg</SymbolPackageFormat>
        <EmbedUntrackedSources>true</EmbedUntrackedSources>
        <Authors>Author One; Author Two</Authors>
        <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
        <Description>Example project description.</Description>
        <PackageTags>dotnet</PackageTags>
    </PropertyGroup>
</Project>

Here’s the .gitignore being used. Note that .sln files are being ignored because they will be generated as needed and not checked in. More on that later.

.gitignore 
artifacts/*

**/TestResults/

*.sln
.vs/

Source Organization

The key to the source organization is the use of the Microsoft.Build.Traversal SDK. It allows projects to be organized anywhere in the repository, and referenced through a top-level dirs.proj. As your project grows, it may make sense to create more dirs.proj files referencing subsets of the codebase for different services or teams. In the previous iteration of this post, I recommended creating a structure of intermediate dirs.proj files at each level, however I now realize that life is too short to spend time tediously managing this directory structure.

The version of the Microsoft.Build.Traversal package is specified in global.json.

dirs.proj 
<Project Sdk="Microsoft.Build.Traversal">
  <ItemGroup>
    <ProjectReference Include="src\**\*.*proj"/>
    <ProjectReference Include="test\**\*.*proj" />
  </ItemGroup>
</Project>

Source files are split into two folders, src and test. Within each folder are a tree of projects.

StarterProject
│   dirs.proj
├───src
│   │   dirs.proj
│   ├───MyComponent
│   │   └───Folder
│   │       StarterProject.MyComponent.csproj
│   └───AnotherComponent
│           StarterProject.AnotherComponent.csproj
└───test
    │   dirs.proj
    └───MyComponent
            StarterProject.Test.MyComponent.csproj

Dependencies are made between projects using project references. This enables project boundaries to signify self-contained components. .NET will prohibit circular dependencies. Within a project, folders can be used to group files if more than one namespace is needed.

src/MyComponent/StarterProject.MyComponent.proj 
<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <ProjectReference Include="$(RepositoryRoot)/src/AnotherComponent/StarterProject.AnotherComponent.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Serilog" />
  </ItemGroup>
</Project>

Note that project references begin with $(RepositoryRoot) which was defined earlier in Directory.Build.props. In some repositories, all project references are relative to the current project and contain many ../s at the front. By making all references relative to the repository root, managing project files becomes less tedious. Find and replace can be used to update any paths and any new project files created by copy-paste will always have the correct configuration.

Optional: Organizing Test Code with Source Code

Having worked in the typescript world for the last year, I have realized that organizing test files next to source files is much easier to manage. It’s still reasonable to organize integration tests as totally separate programs in their own directory.

The file tree looks something like this:

StarterProject
│   dirs.proj
├───src
│   ├───MyComponent
│   │   └───Folder
│   │       Source.cs
│   │       Source.test.cs
│   │       StarterProject.MyComponent.csproj
│   │       StarterProject.MyComponent.Test.csproj
│   └───AnotherComponent
│           StarterProject.AnotherComponent.csproj
└───test
    └───IntegrationTest
        StarterProject.IntegrationTest.csproj

If you wish to do this, exclude test files from each of your source projects, and replicate the project references from the source project to the test project since the test project now builds the source code into the test binary.

src/MyComponent/StarterProject.MyComponent.proj 
<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <ProjectReference Include="$(RepositoryRoot)/src/AnotherComponent/StarterProject.AnotherComponent.csproj" />
  </ItemGroup>

  <ItemGroup>
    <Compile Remove="**\*.test.cs" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Serilog" />
  </ItemGroup>
</Project>
src/MyComponent/StarterProject.MyComponent.Test.proj 
<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <ProjectReference Include="$(RepositoryRoot)/src/AnotherComponent/StarterProject.AnotherComponent.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" />
    <PackageReference Include="Moq" />
    <PackageReference Include="xunit" />
    <PackageReference Include="xunit.runner.visualstudio" />
  </ItemGroup>
</Project>

Dependency Management

NuGet is the package manager of choice for .NET applications. It can be configured in two parts, Directory.Packages.props which gives the version number for each package, and in each project file are references to those packages.

Here’s what Directory.Packages.props may look like. Dependencies are sorted by usage, then alphabetically by package name.

Directory.Packages.props 
<Project>
  <!-- Runtime -->
  <ItemGroup>
    <PackageVersion Include="Serilog" Version="4.0.1" />
  </ItemGroup>

  <!-- Test -->
  <ItemGroup>
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
    <PackageVersion Include="Moq" Version="4.20.70" />
    <PackageVersion Include="xunit" Version="2.9.0" />
    <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
  </ItemGroup>
</Project>

A project can then reference one of these packages.

src/AnotherComponent/StarterProject.AnotherComponent.csproj 
<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" />
    <PackageReference Include="Serilog" />
  </ItemGroup>
</Project>

Since this functionality is currently in preview, each project much have ManagePackageVersionsCentrally set to true. This can be done globally in Directory.Build.props. The default value of this property will be true in future versions of the .NET SDK.

Directory.Build.props 
    <PropertyGroup>
        <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    </PropertyGroup>

Internal Tooling

It can be useful to have a collection of scripts related to the project checked in. PowerShell is my automation language of choice, not only for it’s integration with .NET, but also because scripts tend to be easier to write and more maintainable than other scripting alternatives. PowerShell can be used on both Linux and Windows.

An entrypoint is defined as follows, which imports all other PowerShell scripts where commands are defined. In this case there are only two.

shell/Init.ps1 
Import-Module $PSScriptRoot/VisualStudio.psm1
Import-Module $PSScriptRoot/MyTool.psm1 # Optional

Write-Host -ForegroundColor Cyan "Welcome to StarterProject shell"

This can be invoked directly when starting the shell. Running this script will load any commands that the .psm1 files export.

PS StarterProject> .\shell\Init.ps1
Welcome to StarterProject shell

Solution Generation

While developers can use any editor, many will want to work from Visual Studio. Visual Studio requires a solution file in order to be run. Within Microsoft, it is quite common not to check in .sln files and instead generate them using one of many tools. Here is a short PowerShell script which can be used to do the same thing.

shell/VisualStudio.psm1 
function Start-VisualStudio() {
    $solutionName = (Get-Item .).Name

    dotnet new sln --force --name $solutionName
    dotnet sln add @(Get-ChildItem -Recurse *.csproj)
    start "$solutionName.sln" # This part only works on windows
}

Export-ModuleMember *-*

Running it will generate the solution file and launch Visual Studio if installed.

PS StarterProject> .\shell\Init.ps1
Welcome to StarterProject shell
PS StarterProject> Start-VisualStudio
The template "Solution File" was created successfully.
Project `src\AnotherComponent\StarterProject.AnotherComponent.csproj` added to the solution.
Project `src\MyComponent\StarterProject.MyComponent.csproj` added to the solution.
Project `test\MyComponent\StarterProject.Test.MyComponent.csproj` added to the solution.
Project `tools\MyTool\StarterProject.MyTool.csproj` added to the solution.

The command can also be run from a different location within the repo to generate a solution with a smaller scope.

PS StarterProject\src\MyComponent> Start-VisualStudio
The template "Solution File" was created successfully.
Project `StarterProject.MyComponent.csproj` added to the solution.

Note: The slngen tool is a more robust alternative to this script with better MSBuild integration. However, because it has dependencies on Visual Studio and MSBuild which require extra configuration it is not included in this guide.

Testing

There are several popular options for testing in .NET.

  • MSTest, Microsoft’s official framework
  • xUnit, the open source testing framework
  • NUnit, ported from Java’s JUnit.

This guide will choose xUnit out of personal preference.

Tests are organized with a hierarchy that parallels the code being tested. This gives something like the following structure.

├───src
│   │   dirs.proj
│   └───MyComponent
│           StarterProject.MyComponent.csproj
│           Source.cs
└───test
    │   dirs.proj
    └───MyComponent
            StarterProject.Test.MyComponent.csproj
            SourceTest.cs

Tests use relative project references to refer to the code they are testing.

test/MyComponent/StarterProject.Test.MyComponent.csproj 
<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <ProjectReference Include="../../src/MyComponent/StarterProject.MyComponent.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" />
    <PackageReference Include="Moq" />
    <PackageReference Include="xunit" />
    <PackageReference Include="xunit.runner.visualstudio" />
  </ItemGroup>
</Project>

Linting

Code style analyzers were added in .NET 5. In order to enable this, a .editorconfig file must be created and the EnforceCodeStyleInBuild property should be enabled. Using this property will cause IDExxxx rules to be emitted.

Directory.Build.props 
    <PropertyGroup>
        <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <!-- Enable linter -->
    </PropertyGroup>

The .editorconfig file is too large to reproduce here, but you can see an example in the SampleProject repo.

Code quality analyzers (CAxxxx) are enabled by default.

Optional: /docs

The /docs folder is a great place to store documentation alongside the code. A simple wiki can be created here as a collection of markdown files. By checking documentation into the repo through pull requests, it undergoes the same quality gates as the rest of the code.

Optional: /deployment

If the project will be run as a service, /deployment is a good place to put any configuration or automation related to making deployments.

Optional: /tools

Any ad-hoc tools can be placed here. If they are written in .NET, a simple wrapper in the shell folder can be written to invoke dotnet run. This will compile and run the program.

shell/MyTool.psm1 
function Invoke-MyTool() {
    dotnet run -p tools/MyTool/StarterProject.MyTool.csproj -- @args
}

Export-ModuleMember *-*

Running it:

PS StarterProject> .\shell\Init.ps1
Welcome to StarterProject shell
PS StarterProject> Invoke-MyTool arg1 arg2
Hello from MyTool! Arguments: [arg1,arg2]