.NET Core has long supported the idea of a self-contained application, where the build process results in a single executable application. Until the release of .NET Core 3 preview 6 though, the resulting output would be a single executable and every supporting library or dll that it needed.

Let's examine the changes using a simple Hello World application...

mkdir SingleExe
dotnet new console

This will create a simple console application with a csproj like below

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

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

</Project>

If we add a RuntimeIdentifier for our environment we can then publish an application built for that environment that can be ran without requiring the .NET Core runtime to be installed. A breakdown of Runtime Identifiers can be found here.

Once we add the appropriate identifier, e.g. <RuntimeIdentifier>osx-x64</RuntimeIdentifier> we can then publish the application using

dotnet publish -c Release

This will result in a lot of output!

72MB across 191 items is not a small self-contained application!

.NET Core 3 introduces a number of new options for consolidating all this output.

If we update our csproj to the following, then our output will be merged into a single executable and uncalled code will be trimmed from it

<PublishTrimmed>true</PublishTrimmed>
<PublishSingleFile>true</PublishSingleFile>
A single 30MB executable is a lot more manageable

Now, 30MB for an application that in this case simply writes "Hello World" to a console screen may seem excessive but remember that this is including every part of .NET Core that the application needs to run, it has absolutely no external dependencies!

Be careful here though, anything that uses reflection may be incorrectly trimmed.

Test your applications after trimming them! You can tell the trimmer to include specific namespaces, classes or methods using the TrimmerRootAssembly element.

<ItemGroup>
  <TrimmerRootAssembly Include="Namespace.To.Keep" />
</ItemGroup>

This is all still just using IL (Intermediate Language) though, the bytecode that .NET code compiles to and is JIT (Just In Time) compiled to the target machine's native code every time the application is ran.

If we're going to build for a specific platform, or offer builds for multiple platforms then it would make sense to pre-compile as much of the code to native machine code as we can.

Enter the PublishReadyToRun option. We include it in our csproj file so that the whole file now looks like this...

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <PublishTrimmed>true</PublishTrimmed>
    <PublishSingleFile>true</PublishSingleFile>
    <PublishReadyToRun>true</PublishReadyToRun>
    <!-- Set the RuntimeIdentifier below to match your own system -->
    <RuntimeIdentifier>osx-x64</RuntimeIdentifier>
  </PropertyGroup>

</Project>

Now the compiler will pre-compile as much code as it can to (in this case) Mac specific machine code. This will not have to be JITted each time the application runs, leading to a noticeably faster application start.

The tradeoff here is that the resulting executables are now platform specific but if you can provide a pre-built executable for each of the major platforms then it could be well worth considering.