How to Organize Your .NET Repository

It’s all too common for .NET solutions to get out of hand - they can become very big, very fast.

Unfortunately, I learned this the hard way. As a junior engineer (and the only technical hire in the small startup I was working at) figuring this out was my responsibility. From this role through to my role as CTO at the same startup, I had a lot of opportunity to experiment - and over a decade later, I have a pretty solid grasp on what to do and what not to do.

At the beginning of my career at the aforementioned company, we didn’t know how to do things the right way. I had to develop processes that fit our small company by asking myself the most basic questions, like “what is scrum?,” and “how do we implement it?”

By the time I left, however, there were 3-4 teams, and while I wasn’t managing them directly, I did have a lot of influence on how they work. Thankfully, by then, I had found some tips and tricks to organize your .NET repository so that you’re not losing things left and right - in a bigger org, that could get messy very quickly.

Here are six tips and tricks on how to organize your solution repository (and maintain your sanity).

Trick number one - separate your code.

Solutions are essentially files. Maaaany files. And finding the one you need becomes more difficult the more files you have. Folders can help us organize and make our lives easier - this trick provides some guidance on how you might want to set up your folders structure.

.NET solutions usually have several parts:

  • Main app code - the heart of your solution
  • Tests- you have tests, right? :)
  • Technical files - files like .gitignore, nuget.config, readme.md and etc
  • Build scripts - anything that takes your source code and makes binary files from it
  • Build artifacts - the output of your solution that is usually shipped to end-user or deployed to server
  • Solution documentation - anything that describes how it works, designed and should be used
  • Samples - the guide on how to use your code

Here’s a suggestion on how to organize the above:

  • Put Main app source under /src folder
  • Put Tests under /tests folder
  • Keep .sln file or entry build script at repository root /
  • Put any other build scripts under /build folder
  • Put Build output in /artifacts folder (more about how to achieve this a bit later in this post)
  • Put Documentation in the /docs folder (except readme.md. This file is the root of documentation and should be accessible)
  • And lastly, code samples should obviously go under /samples folder

The final structure will look something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
artifacts/
   bin/
   nupkg/
   obj/
     <ProjectName>/
   tests/
     output/
 build/
 docs/
 samples/
 src/
   <ProjectName>/
 tests/
   <ProjectName.Tests>/
 .gitignore NuGet.Config
 README.md
 Solution.sln
 build.sh


Trick number two - centralize project properties.

.NET assemblies have properties like assembly version, author name, company, product name, copyright, license etc. Typically, these properties should be set for each project in the solution one by one. And setting this can quickly become a big mess if you’re not careful.

Luckily, Microsoft thought about us and provided a good solution for this: Meet Directory.Build.props _and _Directory.Build.target.

Basically, these two files allow us to gather all common properties in one place.

Create these files and put them in the repository’s root folder. From there, all projects in the solution will instantly inherit properties defined in these files.

Here is the typical content for Directory.Build.props:

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="15.0">
<PropertyGroup>
<NoLogo>true</NoLogo>
<Authors>ServiceTitan</Authors>
<Company>ServiceTitan</Company>
<Description>Pricing service package</Description>
</PropertyGroup>
</Project>

It’s worth adding the following line in this file:

<SolutionDir Condition="$(SolutionDir) == ''">$(MSBuildThisFileDirectory.TrimEnd('\').TrimEnd('/'))\</SolutionDir>

This line ensures that the SolutionDir build variable is available even if we are building a single project from the command line rather than a whole solution.

We’ll use these files more in the following tricks. To apply this behaviour to nested folders we also put in similar files in /src and /tests folders with following content:

<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
</Project>

Need more info? Check out Microsoft’s documentation on customizing your build.

Trick number three - separate build artifacts.

As was mentioned above, putting build output in a single place is very useful. Single folder tasks like making clean build or publishing build output become super easy with the power of Directory.Build.props.

Adding following lines will do the trick:

/Directory.Build.props

<PropertyGroup>
<ArtifactsDir>$(SolutionDir)artifacts</ArtifactsDir>
   <OutputPath>$(ArtifactsDir)</OutputPath>
<BaseIntermediateOutputPath>$(SolutionDir)artifacts\obj\$(MSBuildProjectName)</BaseIntermediateOutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>

/src/Directory.Build.props

<PropertyGroup>  
 <OutputPath>$(ArtifactsDir)\bin</OutputPath>
</PropertyGroup>

/tests/Directory.Build.props

<PropertyGroup>
<OutputPath>$(ArtifactsDir)\tests</OutputPath>
</PropertyGroup>


Trick number four - consolidate package versions.

In my .NET developer life, I periodically see solutions where multiple projects reference the same nuget package, but with different versions. These usually lead to annoying build warnings, and in some cases even runtime errors. Updating the nuget package version for the whole project is a pain.

And again, Microsoft thought about us - meet Microsoft.Build.CentralPackageVersions.

This package allows managing nuget packages from a single file. So let’s create it!

/Packages.props

<Project>
<PropertyGroup>
<SwashbuckleVersion>5.3.1</SwashbuckleVersion>
<XUnitVersion>2.4.1</XUnitVersion>
</PropertyGroup>
<ItemGroup Label="Nupkg Versions">
<PackageReference Update="Swashbuckle.AspNetCore" Version="$(SwashbuckleVersion)" />
<PackageReference Update="Swashbuckle.AspNetCore.ReDoc" Version="$(SwashbuckleVersion)" />
<PackageReference Update="xunit" Version="$(XUnitVersion)" />
<PackageReference Update="xunit.analyzers" Version="0.10.0" />
<PackageReference Update="xunit.runner.visualstudio" Version="$(XUnitVersion)" />
</ItemGroup>
</Project>

Note that we have to define SwashbuckleVersion and XUnitVersion variables. Since Swashbuckle packages are released together, we want to make sure they all are the same version.

To enable Packages.props file magic, we need to edit Directory.Build.target file content:

/Directory.Build.target

<Project>
<Sdk Name="Microsoft.Build.CentralPackageVersions" Version="2.0.79" />
</Project>

**Note: **When you build your project, you probably get a bunch of errors saying that “The package reference ‘xxxx’ should not specify a version.” This is happening because CentralPackageVersions will enforce you to store versions in a single file. To fix this, we need to edit the .csproj files and remove PackageReference’s version attributes.

Trick number five - enforce coding styles.

I love to prefix class private fields with an underscore. But not all devs that I work with follow this rule…. Let’s enforce them, HAHAAHAAA!

Just add .editorconfig in your solution root to enforce this coding style:

/.editorconfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[*]
end_of_line = crlf

[*.xml]
indent_style = space

# Allow underscore for private fields
dotnet_naming_symbols.private_fields.applicable_kinds           = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private

dotnet_naming_style.prefix_underscore.capitalization            = camel_case
dotnet_naming_style.prefix_underscore.required_prefix           = p

dotnet_naming_rule.private_fields_with_underscore.symbols       = private_fields
dotnet_naming_rule.private_fields_with_underscore.style         = prefix_underscore
dotnet_naming_rule.private_fields_with_underscore.severity      = error

For more details, check out Microsoft’s documentation on EditorConfig settings.

Trick number six - apply assembly versions.

In real life, it’s essential to mark your release assemblies with versions. Of course, we could do it manually, but that’s not what we are here for. Introducing… Nerdbank.GitVersioning.

This library allows automatic mark assemblies with versions depending on Git commit. To make it work, we simply need to reference Nerdbank.GitVersioning library in each project.

Luckily with Directory.Build.props, we can do it in one step.

/Directory.Build.props

<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" />
</ItemGroup>

And we should specify the package version as well:

/Packages.props

<PackageReference Update="Nerdbank.GitVersioning" Version="3.1.91" PrivateAssets="All" IncludeAssets="runtime; build; native; contentfiles; analyzers" />

Nerbank.GitVersioning can generate build numbers, but specifying major and minor versions is up to us. To do this we need to create one file in the repository root folder:

/version.json

{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
"version": "2.0-pre",
"assemblyVersion": {
"precision": "revision"
},
"nuGetPackageVersion": {
"semVer": 2
}
}


Conclusion

If you have followed the above steps, your .NET solution should now be more organized, and you can take a deep breath. The tips and tricks are, in this order: separate your code, centralize project properties, separate build artifacts, consolidate package versions, enforce coding styles, and apply assembly versions. Good luck!

All tips and tricks samples can be found in this GitHub repository.

BIO

Gor Rustamyan is one of our beloved Staff Software Engineers at ServiceTitan Armenia. He’s loved computers since he was a child - in his words, he always wanted to know how this “plastic-metal thing” works. Before ServiceTitan, he worked at a startup that designed custom software solutions, bringing with him over a decade of experience in engineering. In his spare time, you might find him outside at night with a telescope watching the stars, or reading a good sci-fi novel.