Note: An updated version of this post is available.
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
anddotnet 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:
- .NET
- PowerShell (Recommended)
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]