In recent years, there have been many advances in .NET tooling. 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 2021. The guidelines here are not official guidance from the .NET team and are not endorsed by Microsoft, but represent a combination of what my team at Microsoft uses as well as my own personal preference. The project in this post will target .NET 5, C#9.0 and use the .NET 5 SDK.

The repository is available on GitHub here.

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": "5.0.103",
    "rollForward": "latestMajor"
  },
  "msbuild-sdks": {
    "Microsoft.Build.Traversal": "3.0.3"
  }
}

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.Packages.props
│   Directory.Build.props
│   dirs.proj
│   global.json
│   README.md
├───deployment*
├───docs*
├───shell
│       Init.ps1
│       MyTool.psm1
│       VisualStudio.psm1
├───src
│   │   dirs.proj
│   ├───MyComponent
│   │   │   StarterProject.MyComponent.csproj
│   │   │   Source.cs
│   │   └───Folder
│   │           MoreSource.cs
│   └───AnotherComponent
│           StarterProject.AnotherComponent.csproj
│           AnotherSource.cs
├───test
│   │   dirs.proj
│   └───MyComponent
│           StarterProject.Test.MyComponent.csproj
│           ExampleTest.cs
└───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>net5.0</TargetFramework>
        <LangVersion>9.0</LangVersion>
        <Nullable>enable</Nullable>
        <Features>strict</Features>
    </PropertyGroup>

    <!-- Build -->
    <PropertyGroup>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
        <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <!-- Enable linter -->
    </PropertyGroup>
    
    <!-- Packaging -->
    <PropertyGroup>
        <!-- Enable packaging on a per-project basis. -->
        <IsPackable>false</IsPackable>
        <IsPublishable>false</IsPublishable>

        <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 
**/bin
**/obj

**/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 hierarchically structured within the repository. Each folder has a file called dirs.proj or a .csproj for the project. The dirs.proj references where the child project files are located. The version of this package is specified in global.json.

dirs.proj 
<Project Sdk="Microsoft.Build.Traversal">
  <ItemGroup>
    <ProjectReference Include="src/dirs.proj" />
    <ProjectReference Include="test/dirs.proj" />
  </ItemGroup>
</Project>
src/dirs.proj 
<Project Sdk="Microsoft.Build.Traversal">
  <ItemGroup>
    <ProjectReference Include="MyComponent/StarterProject.MyComponent.csproj" />
    <ProjectReference Include="AnotherComponent/StarterProject.AnotherComponent.csproj" />
  </ItemGroup>
</Project>

It’s also possible to define one dirs.proj which automatically references any projects under src and test.

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 implies that project boundaries are drawn around 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="../AnotherComponent/StarterProject.AnotherComponent.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" />
    <PackageReference Include="Serilog" />
  </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.

Note: The functionality described is currently in preview, but represents the direction of the .NET SDK. A stable alternative is the CentralPackageVersions SDK, which does the same thing with slightly more boilerplate.

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="Newtonsoft.Json" Version="12.0.3" />
    <PackageVersion Include="Serilog" Version="2.10.0" />
  </ItemGroup>

  <!-- Test -->
  <ItemGroup>
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
    <PackageVersion Include="Moq" Version="4.13.1" />
    <PackageVersion Include="xunit" Version="2.4.1" />
    <PackageVersion Include="xunit.runner.visualstudio" Version="2.4.1" />
  </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
    Get-ChildItem -Recurse *.csproj | ForEach { dotnet sln add $_.FullName }
    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 have been added to .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]