MOFAKH.COM
← Back to profile
.NET Web API

The .csproj file: how our code becomes a DLL

Aug 1, 20268 min readWritten

Our project file is fifteen lines long because the SDK writes the build logic for we. Read what that one line imports and we understand why we never reference ASP.NET Core by hand.

The whole file, and what is missing from it

A working web project file is almost empty:

xml
<Project Sdk="Microsoft.NET.Sdk.Web">
 
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
</Project>

Notice what is not here. No reference to Microsoft.AspNetCore.*. No list of .cs files. No compiler flags. No build steps. we use WebApplication, controllers, middleware, DI, logging, and Kestrel — and none of them appear in the file. All of it arrives through the first line.

Sdk="Microsoft.NET.Sdk.Web" is an import, not a runtime

An MSBuild SDK is not a runtime and not a NuGet package. It is a bundle of pre-written build logic — MSBuild targets, tasks, and default properties — that tells .NET how to compile, publish, and configure a project.

The Sdk="..." attribute is shorthand. MSBuild expands it into two hidden imports wrapped around our file: Sdk.props at the very top and Sdk.targets at the very bottom. our PropertyGroup sits in the middle of someone else's build script.

The plain Microsoft.NET.Sdk means one thing: compile every .cs file in this folder, output a DLL. The .Web variant adds the web-specific defaults on top of that:

  • a framework reference to the entire Microsoft.AspNetCore.App shared framework,
  • wwwroot handling,
  • appsettings.json copied to output,
  • launchSettings.json support.

That framework reference is the answer to "where is ASP.NET Core?" A package reference is downloaded from NuGet into our build. A framework reference is resolved from the runtime already installed on the machine. ASP.NET Core is the second kind — which is why it is nowhere in our file and never in our bin folder.

What we get for free

ImplicitUsings is a generated file, not compiler magic

Turn it on and the SDK writes a real file into our build output:

Diagram
obj/Debug/net8.0/MyApi.GlobalUsings.g.cs

Open it. It is ordinary code:

csharp
global using System;
global using Microsoft.AspNetCore.Builder;
global using Microsoft.Extensions.DependencyInjection;
// ...

There is no compiler trick here. The build generates a file full of global using directives and compiles it alongside ours. That is the whole feature.

Nullable is a compile-time contract

Nullable turns on null-state static analysis. string now means "should not be null"; string? means "might be." The compiler tracks the flow and warns we when the two disagree.

It is compile-time only. Nothing changes at runtime — a null still fits in a string field once the program is running. we are not enabling a runtime check; we are enabling a warning we can act on before shipping.

What "build" actually does

MSBuild is the orchestrator, not the compiler. The .NET CLI hands the build command to MSBuild. MSBuild reads the .csproj, loads the SDK's targets, and — among many steps — invokes Roslyn, the C# compiler, to turn our source into IL.

Roslyn compiles only our .cs files. It does not recompile ASP.NET Core or the base class library, because those are already compiled and installed on the machine. And it doesn't even open the real framework DLLs to do its work: at compile time it references reference assemblies — versions that carry the API surface (types and method signatures) but no method bodies. our build compiles against the shape of the framework, not its implementation.

The output is a handful of files:

Diagram
dotnet build
  |
  v
MSBuild reads MyApi.csproj
  |
  v
loads Microsoft.NET.Sdk.Web
  (build rules, defaults, framework ref)
  |
  v
Roslyn compiles ONLY your .cs
  C# -> IL
  |
  v
produces:
  MyApi.dll                 your IL
  MyApi.pdb                 debug symbols
  MyApi.deps.json           dependency graph
  MyApi.runtimeconfig.json  what runtime to load

None of these run yet. MyApi.dll is IL, not machine code, and runtimeconfig.json is just a note to whoever starts the process later. Turning this pile of files into a listening web server is the runtime's job — the next file.

Note to self

Sdk.props is imported before our PropertyGroup, Sdk.targets after it. So a property we set can override an SDK default, but a target the SDK defines can override ours. When a build setting "won't take," this ordering is almost always why. dotnet build -bl writes a binlog — open it in the MSBuild Structured Log Viewer and we can see exactly which import won.

Previous
Start of this topic