A single .NET Core project to rule them all

-

Abstract

The code is here.

Thanks to Mike for re­view­ing this.

I have al­ways been mildly ir­ri­tated by how many .net pro­jects I need to cre­ate in my stan­dard work­flow.

Usually I start with an idea for a li­brary; I then want to test it with a sim­ple ex­e­cutable; write some XUnit tests for it and fi­nally bench­mark some key sce­nar­ios. So I end up with at least four pro­jects to man­age.

Sure, I can find ways to au­to­mat­i­cally gen­er­ate those pro­jects, but I have al­ways been weary of code­gen to solve com­plex­ity is­sues. It al­ways ends up com­ing back to bite you. For those of you as old as I am, think MFC

So what is my ideal world then? Well, let’s try this:

  1. One single project for the library and related artifacts (i.e. test, benchmarks, etc…).
  2. Distinguish the library code from the test code from the benchmark code by some convention (i.e. name scheme).
  3. Generate each artifact (i.e. library, tests, benchmarks, executable) by passing different options to dotnet build and dotnet run.
  4. Create a new project by using the standard dotnet new syntax.
  5. Have intellisense working normally in each file for my chosen editor (VSCode).
  6. Work with dotnet watch so that one can automatically run tests when anything changes.

Disclaimer

What fol­lows, de­spite work­ing fine, is not the stan­dard way .net tools are used. It is not in the golden path’. That is prob­lem­atic for pro­duc­tion us­age as:

  1. It might not work in your particular configuration.
  2. It might not work with other tools that rely on the presence of multiple projects (i.e. code coverage? …).
  3. It might work now in all scenarios, but get broken in the future as you update to a new framework, sdk, editor.
  4. It might expose bugs in the tools, now or later, which aren’t going to be fixed, as you are not using the tools as intended.
  5. It might upset your coworkers that are used to a more standard setup.

I need to write a blog post about the con­cept of the golden path’ and the per­ils, mostly hid­den, of get­ting away from it. The sum­mary, it is a bad idea.

Having said all of that, for the dar­ing souls, here is one way to achieve most of the above. It also works out as a tu­to­r­ial on how the dif­fer­ent com­po­nents of the .NET Core build sys­tem in­ter­acts.

How to use it

Here are the steps:

  1. Type dotnet new -i Lucabol.SingleSourceProject.
  2. Create a directory for your project and cd to it.
  3. Type dotnet new lsingleproject and optionally --standardVersion <netstandardXX> --appVersion <netcoreappXX>.
  4. Either modify the Library.cs, Main.cs, Test.cs, Bench.cs files or create your own with this convention:
    • Code for the executable goes in potentially multiple files named XXXMain.cs (i.e. MyLibrary.Main.cs).
    • Code for the tests goes into files named XXXTest.cs (i.e. MyLibrary.Test.cs).
    • Code for the benchmarks goes into files named XXXBench.cs (i.e. MyLibrary.Bench.cs).
    • Any .cs file not following the above conventions is compiled into the dll.
  5. Type:
    • dotnet build or dotnet build -c release to build debug or release version of your dll. This doesn’t include any of the main, test or bench code.
    • dotnet build -c main or dotnet build -c main_release and the corresponding dotnet run -c .. build and run the exe.
    • dotnet build -c test, dotnet build -c test_release and dotnet test -c test build and run the tests.
    • dotnet build -c bench, dotnet run -c bench build and run the benchmark.

How it all works

The var­i­ous steps above are im­ple­mented as fol­lows:

dotnet new -i ... in­stall a cus­tom tem­plate that I have cre­ated and pushed on NuGet.

The cus­tom tem­plate is com­posed of the fol­low­ing files:

Code files

There is one file for each kind of ar­ti­fact that the pro­ject can gen­er­ate: li­brary, pro­gram, tests and bench­mark. The files fol­low the name ter­mi­nat­ing con­ven­tions, as de­scribed above.

Project file

The pro­ject file is iden­ti­cal to any other pro­ject file gen­er­ated by dotnet new ex­cept that there is one ad­di­tional line ap­pended at the end:

<Import Project="Base.targets" />

This in­struct msbuild to in­clude the Base.targets file. That file has most of the mag­ick. I have sep­a­rated it out so that you can use it un­changed in your own pro­jects.

Base.targets

We start by re­mov­ing all the file from com­pi­la­tion ex­cept the ones that are used to build the li­brary.

  <ItemGroup>
    <Compile Remove="**/*Bench.cs;**/*Test.cs;**/*Main.cs" />
  </ItemGroup>

We then con­di­tion­ally in­clude the cor­rect ones de­pend­ing on which con­fig­u­ra­tion is cho­sen. Please no­tice the last line, which in­struct dotnet watch to watch all the .cs files. By de­fault it just watches the ones in the debug con­fig­u­ra­tion.

  <ItemGroup>
    <Compile Include="**/*Test.cs"  Condition="'$(Configuration)'=='Test'"/>
    <Compile Include="**/*Test.cs"  Condition="'$(Configuration)'=='Test_Release'"/>
    <Compile Include="**/*Bench.cs" Condition="'$(Configuration)'=='Bench'"/>
    <Compile Include="**/*Main.cs"  Condition="'$(Configuration)'=='Main'"/>
    <Compile Include="**/*Main.cs"  Condition="'$(Configuration)'=='Main_Release'"/>
    <Watch Include="**\*.cs" />
  </ItemGroup>

Then we need to de­fine the ref­er­ences. Depending on what you are build­ing you need to in­clude ref­er­ences to the cor­rect NuGet pack­ages (i.e. if you are build­ing test you need the xunit pack­ages). This is done be­low:

  <ItemGroup  Condition="'$(Configuration)'=='Bench' OR '$(Configuration)'=='Debug'">
    <PackageReference Include="BenchmarkDotNet" Version="0.11.3" />
  </ItemGroup>

  <ItemGroup Condition="'$(Configuration)'=='Test' OR '$(Configuration)'=='Test_Release' OR '$(Configuration)'=='Debug'">
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.9.0" />
    <PackageReference Include="xunit" Version="2.4.0" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
  </ItemGroup>

One thing to no­tice is that most ref­er­ences are also in­cluded in the debug con­fig­u­ra­tion. This is not a good thing, but it is the only way to get VSCode Intellisense to work for all the files in the so­lu­tion. Apparently, IntelliSense uses what­ever ref­er­ence are de­fined for the debug build in VsCode. So debug is spe­cial, if you wish …

But that’s not enough. When you cre­ate your own MsBuild con­fig­u­ra­tions, you also have to repli­cate the prop­er­ties and con­stants that are set in the debug and release con­fig­u­ra­tions. You would like a way to in­herit them, but I don’t think it is pos­si­ble.

It is par­tic­u­larly im­por­tant to set the TargetFramework prop­erty, as it needs to be set to netcoreappXXX for the main, test and bench­mark con­fig­u­ra­tions. I give an ex­am­ple of the Test and Test_release con­fig­u­ra­tions be­low. The rest is sim­i­lar:

  <PropertyGroup Condition="'$(Configuration)'=='Test'">
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <DefineConstants>$(DefineConstants);DEBUG;TRACE;TEST</DefineConstants>
    <DebugSymbols>true</DebugSymbols>
    <DebugType>portable</DebugType>
    <Optimize>false</Optimize>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)'=='Test_Release'">
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <DefineConstants>$(DefineConstants);RELEASE;TRACE;TEST</DefineConstants>
    <DebugSymbols>false</DebugSymbols>
    <DebugType>portable</DebugType>
    <Optimize>true</Optimize>
  </PropertyGroup>

The .template.config/template.json file

This is nec­es­sary to cre­ate a dotnet new cus­tom tem­plate. The only thing to no­tice is the two pa­ra­me­ters standardVersion and appVersion that gives the user a way to in­di­cate which ver­sion of the .NET Standard to use for the li­brary and which ver­sion of the ap­pli­ca­tion frame­work to use for Main, Test and Bench.

{
  "$schema": "http://json.schemastore.org/template",
  "author": "Luca Bolognese",
  "classifications": [ "Classlib", "Console", "XUnit" ],
  "identity": "Lucabol.SingleSourceProject",
  "name": "One single Project",
  "description": "One single Project for DLL, XUnit, Benchmark & Main, using configurations to decide what to compile",
  "shortName": "oneproject",
  "tags": {
    "language": "C#",
    "type": "project"
  },
  "preferNameDirectory": true,
  "sourceName": "SingleSourceProject",
  "symbols":{
    "standardVersion": {
      "type": "parameter",
      "defaultValue": "netstandard2.0",
      "replaces":"netstandard2.0"
    },
    "appVersion": {
      "type": "parameter",
      "defaultValue": "netcoreapp2.1",
      "replaces":"netcoreapp2.1"
    }
  }
}

Conclusion

Now that you know how it all works, you can make an in­formed de­ci­sion if to use it or not. As for me …

Tags