Anatomy of a .NET app

What happens when you build a .NET app? What happens the instant you run it? The last time I studied this was when .NET Framework was in its infancy. That was nearly 20 years ago! Things have changed in those two decades: apps are now cross-platform; .NET has lost its "Framework" moniker; and I've lost my hair!

This post looks at:

We won't go into a massive amount of detail, but it'll help if you know roughly what MSBuild is and does, and know a little of the tooling around .NET.

The project used throughout is a C# console app targetting .NET 5. It is unmodified from what you get when you do File / New Project / Console from Visual Studio. It just writes Hello World to the console:

using System;
namespace new_console_with_dotnet_new
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}

A modern .NET project #

If you do File / New / C# Console (.NET 5) in Visual Studio 2019, you'll get this project file:

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>

</Project>

You can do the same with dotnet new console from a command prompt Windows Terminal.

If you're following along and you end up with a newer TargetFramework, then that just means you have a newer SDK installed and hence are running a newer dotnet. This doesn't matter - just be aware that when you see 5.x in this post, it'll be something different for you. In fact, you'll see in this post that there are references to .NET 6. This is because I have a preview of .NET 6 installed and some commands use the latest installed SDK (as explained later on).

The project file above is called an SDK Style project. These are a relatively new format and are much smaller compared with the old project files back in the .NET Framework days. Creating the same console app file for say, .NET Framework 4.7.2, results in a .csproj file weighing in at 53 lines, as can be seen below (it's intentionally small as you don't need to read it, just know there's a lot of it!)

There's a lot more stuff in the old style project than the new style project. Things like RootNamespace, AssemblyName, FileAlignment etc. MSBuild still needs these things, and we'll discover their whereabouts shortly.

The biggest space saver in new SDK Style projects is down to the fact that you no longer need to specify what's included, you just need to specify what should not be included.

The very first line of an SDK style project, has this:

<Project Sdk="Microsoft.NET.Sdk">

This means that the project references a Project SDK by the name of Microsoft.NET.Sdk . A project SDK is a set of MSBuild targets and tasks that compile/pack/publish the code. Microsoft.NET.Sdk is the base project SDK. There are other project SDKs, such as Microsoft.NET.Sdk.Web and Microsoft.NET.Sdk.Razor. They all reference the base project SDK.

One interesting point to note is that during the build, MSBuild adds implicit imports at the top and bottom of your .csproj file:

<Project>
<!-- Implicit top import -->
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
...
<!-- Implicit bottom import -->
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
</Project>

SDKs and related files can be found at %ProgramFiles%\dotnet\sdk\[version]\Sdks\Microsoft.NET.Sdk\Sdks:

If we look at the contents of the Microsoft.NET.Sdk folder, we see these subfolders:

In those subfolders, you'll find files used during the build.

So, going back to the .csproj file, we explicitly reference Microsoft.NET.Sdk, and MSBuild implicitly references Sdk.props and Sdk.targets

These files are here:

Sdk.Props references Microsoft.NET.Sdk.props, and if we take a peek at that file, we discover some of the things that are no longer present in the SDK-style project that were in the full-fat Framework project, for example:

There are also references to other props files:

If we look in the DefaultItems.props file, we discover how MSBuild gets to know what to compile in our project:

The screenshot of the full-fat Framework project earlier was deliberately small to show the size compared to new projects, but in that file there were references to Framework assemblies:

  <ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>

So, where are these references in the new world of SDK-style projects?

To find out, I built the app (using verbose output) and looked at what was produced. I didn't look directly at the build output as that hurts your eyes! Instead, I used the excellent open source MSBuild Structured Log Viewer. This is a much much more pleasant way of viewing MSBuild output.

Here it is showing one of the build tasks that were run as part of the build. It finds Framework references:

We can see the task is passed a set of KnownFrameworkReferences, including the one for .NET 5 (①). Notice that there's others, e.g. .NET Core 3.1 (②)

That task returns the following:

The term packs is used. So, what are 'packs'? From the documentation, a pack is:

a collection of files used by the build. A pack can either be deployed globally alongside dotnet or wrapped in a NuGet package

There are a few different types of packs:

🤓 Interesting fact
Packs are bundled with the SDK that you download. They are said to be 'globally installed'. You can build a framework-dependent app offline using the globally installed packs. However, you can also target packs that are not installed. When you do this, the SDK uses NuGet to download them. More info here.

Let's take a peek into the packs folder at C:\Program Files\dotnet\packs:

We can see it has reference dependencies (.Ref - for building), and real dependencies (runtime) for Windows x64 etc.

Going back to the build output above, MSBuild decided to use the packs directory at C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\5.0.0.

Let's take a look in that folder:

We can see that reference assemblies will be used from the ref folder for our chosen target of net5.0.

Our console app only does one thing: it writes Hello World to the console. It references System.Console. We can use ILSpy to look at System.Console in the ref folder:

💡 TIP:
Above, we performed a lot of manual steps and jumped around a fair few files. To speed things up, we can view an aggregated MSBuild project file containing all of the files necessary for a build by running with the preprocess parameter in MSBuild:

dotnet msbuild consoleapp1.csproj -preprocess:output.xml

The resulting file contains everything MSBuild uses to build the app and it weighs in at over 11,000 lines of XML! But it's nice to see it all in one place and be able to search through it for anything interesting.

So, what happens to the reference assemblies? Are they copied to the output folder? Is it the 'runtime' that decides which ones to use?

Next, we'll explore what happens when we run the app.

Running the app #

If you're following along, take a look in the bin\debug\net5.0 folder. You'll see that there's a ConsoleApp1.exe and a ConsoleApp1.dll.

You'll also see that the reference assemblies aren't copied to the output folder. Why would they, after all, they contain no useful functionality and exist just for the purpose of building. The main app (ConsoleApp1.exe) doesn't reference them. But... ConsoleApp1.dll does reference them, as can be seen in ILSpy:

So, why is there an .exe and a .dll? In the old days, when you built a .NET Framework exe, you just got the .exe, as can be seen here with a .NET Framework 4.6.1 console app:

But in the new world, this has changed. The exe is called an app host; it's a native framework-dependent executable, and is built by default in .NET Core 3.0 and later.

🤓 Interesting fact on how .NET Framework assemblies are loaded (from CLR via C#)
After Windows has examined the EXE file’s header to determine whether to create a 32-bit process, a 64-bit process, or a WoW64 process, Windows loads the x86, x64, or IA64 version of MSCorEE.dll into the process’s address space. … Then, the process’ primary thread calls a method defined inside MSCorEE.dll. This method initializes the CLR, loads the EXE assembly, and then calls its entry point method (Main). At this point, the managed application is up and running.

So we know the exe (the app host) is a native app and that the dll is a managed app (we can run it in Windows and Linux using dotnet consoleapp1.dll), but how does the app host know how to load our managed dll?

I found this great post from Matt Warren that describes the process in detail. I'll provide a quick summary below...

When you type dotnet run the app host is loaded. The app host will figure out what runtime and SDK to use, and will load the appropriate runtime binaries into memory.

We can view more information into what's going on here by turning on some host flags. This causes the host to write detailed output when it's run:

SET COREHOST_TRACE=1
COREHOST_TRACEFILE=out.txt
dotnet run

This will write the output to out.txt. Load that file in your editor and carefully study each of the 6,050 lines! Alternatively, here's an overview:

Reading fx resolver directory=[C:\Program Files\dotnet\host\fxr]
Considering fxr version=[2.2.8]...
...
Considering fxr version=[6.0.0-preview.5.21301.5]...
Detected latest fxr version=[C:\Program Files\dotnet\host\fxr\6.0.0-preview.5.21301.5]

To paraphrase from this page: the fx resolver contains the framework resolution logic used by the host (dotnet). It selects the appropriate runtime. The host will use the latest installed hostfxr

--- Resolving .NET SDK with working dir [C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\ConsoleApp1]
Probing path [C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\ConsoleApp1\global.json] for global.json
Probing path [C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\global.json] for global.json
Probing path [C:\git\scratch\dotnet-anatomy-post\global.json] for global.json
...
Probing path [C:\global.json] for global.json
Terminating global.json search at [C:\]
Resolving SDKs with version = 'latest', rollForward = 'latestMajor', allowPrerelease = true
Multilevel lookup is true
Searching for SDK versions in [C:\Program Files\dotnet\sdk]
Version [2.2.207] is a better match than [none]
...
Version [6.0.100-preview.5.21302.13] is a better match than [6.0.100-preview.5.21228.17]
Ignoring invalid version [NuGetFallbackFolder]
SDK path resolved to [C:\Program Files\dotnet\sdk\6.0.100-preview.5.21302.13]
Using .NET SDK dll=[C:\Program Files\dotnet\sdk\6.0.100-preview.5.21302.13\dotnet.dll]

I replaced some of the repetitive and similar lines with ... - but we can still see from the output that it searches for a global.json file and then resolves the latest runtime to use (or alternatively, the specific version you have in global.json file).

So far, we've built the app, have run it, and have seen how the runtime version is resolved.

Let's modify the app slightly so that we can see which SDK the dependencies are loaded from:

using System;
using System.Reflection;
using System.Runtime.InteropServices;

namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine($"Hello {System.Environment.GetEnvironmentVariable("USER")}");

if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Console.WriteLine("Linux - " + Environment.OSVersion.Version);
}

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Console.WriteLine("Windows - " + Environment.OSVersion.Version);
}

Console.WriteLine("List of assemblies loaded in current appdomain:");

foreach (Assembly assem in AppDomain.CurrentDomain.GetAssemblies())
Console.WriteLine(assem.ToString() + " " + assem.Location);
}
}
}

Running it on Windows (with dotnet run), we see:

Hello
We're on Windows!
Version 10.0.22000.0
List of assemblies loaded in current appdomain:
System.Private.CoreLib, Version=5.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Private.CoreLib.dll
ConsoleApp1, Version=1.0.0.0, Culture=neutral, C:\git\scratch\dotnet-anatomy-post\new-console-with-vs\ConsoleApp1\bin\Debug\net5.0\ConsoleApp1.dll
System.Runtime, Version=5.0.0.0, Culture=neutral,  C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Runtime.dll
System.Console, Version=5.0.0.0, Culture=neutral,  C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Console.dll
System.Runtime.InteropServices.RuntimeInformation, Version=5.0.0.0, Culture=neutral,  C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Runtime.InteropServices.RuntimeInformation.dll
System.Threading, Version=5.0.0.0, Culture=neutral,  C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Threading.dll
System.Text.Encoding.Extensions, Version=5.0.0.0, Culture=neutral,  C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\System.Text.Encoding.Extensions.dll

Fire up WSL and and run it again on Linux:

cd /mnt/c/[the path where you compiled it to]
dotnet run

You'll see something similar to this:

steve@STEVEDESKTOP:/mnt/c/git/scratch/dotnet-anatomy-post/new-console-with-vs/ConsoleApp1$ dotnet run
Hello steve
We're on Linux!
Version 4.19.104.0
List of assemblies loaded in current appdomain:
System.Private.CoreLib, Version=5.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Private.CoreLib.dll
ConsoleApp1, Version=1.0.0.0, Culture=neutral, /mnt/c/git/scratch/dotnet-anatomy-post/new-console-with-vs/ConsoleApp1/bin/Debug/net5.0/ConsoleApp1.dll
System.Runtime, Version=5.0.0.0, Culture=neutral,  /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Runtime.dll
System.Console, Version=5.0.0.0, Culture=neutral,  /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Console.dll
System.Runtime.InteropServices.RuntimeInformation, Version=5.0.0.0, Culture=neutral,  /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Runtime.InteropServices.RuntimeInformation.dll
System.Threading, Version=5.0.0.0, Culture=neutral,  /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Threading.dll
System.Text.Encoding.Extensions, Version=5.0.0.0, Culture=neutral,  /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Text.Encoding.Extensions.dll
Microsoft.Win32.Primitives, Version=5.0.0.0, Culture=neutral,  /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/Microsoft.Win32.Primitives.dll
System.Collections, Version=5.0.0.0, Culture=neutral,  /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/System.Collections.dll

We can see the main difference is the locations where the files are loaded from. On Windows, they're loaded from C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.7\. On Ubuntu, they're loaded from /usr/share/dotnet/shared/Microsoft.NETCore.App/5.0.7/

💡 TIP: The /usr/ folder on WSL can be browsed (via Windows File Explorer) at "\\wsl.localhost\Ubuntu\usr\", e.g. \\wsl.localhost\Ubuntu\usr\share\dotnet\shared\Microsoft.NETCore.App\5.0.7

What have we seen? #

We've seen

Hopefully this post has been interesting and useful. There's still tons to explore though; things like stand-alone publishing etc.

Thank you for reading and please feel free to use the comments below and/or share via Twitter etc.

🙏🙏🙏

Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please 🦋 ping me on Bluesky! 🦋

Leave a comment

Comments are moderated, so there may be a short delays before you see it.

10 comments on this page

  • Ifeanyi Oraelosi

    commented

    In addition to the wealth of information in this article, I also discovered a new tool: MSBuild Structured Log Viewer.

    Thank you so much for this.

  • Dick Baker

    commented

    useful as background, but happily not something I need to stress over daily.
    Having lotsa SDKs & runtimes on my laptop, I would love to know how to purge all the old stuff safely (maybe creating equivalent forward pointers to appropriate latest version). Thanks!

  • Sean

    commented

    Awesome article -- thank you for sharing your research into this!

  • Saurabh

    commented

    It was a very informative article. Thank you for taking the effort to write this post for us. Cheers.

  • Andreas Gullberg Larsen

    commented

    Well written and interesting details, it definitely became less black magic for me now 😀

  • Tim

    commented

    Fantastic. Thanks for taking the time to walk through such a fundamental but often overlooked part of our life as developers.

  • Jan Vratislav

    commented

    Great article!

  • Pankaj

    commented

    Excellent post!!

  • Sharp Ninja

    commented

    Great job. You gave more insight into MSBuild than any article I've seen since its release in Visual Studio 2005.

  • Bartłomiej Rosa

    commented

    Wow, good article! It's good to know what's under the hood. Thanks :)

Published