| name | server-dotnet-aot |
| description | Patterns and anti-patterns for the .NET 10 Minimal API + Native AOT server defined in spec 0008. Use when implementing or reviewing any file in servers/dotnet/. Trigger phrases: "dotnet server", "csharp server", "native aot", "minimal api", "/server-dotnet".
|
| applyTo | ["servers/dotnet/**"] |
Server: .NET 10 + Native AOT (spec 0008)
This server uses ASP.NET Core 10 Minimal APIs + Kestrel + Native AOT.
Native AOT eliminates the JIT at runtime, reducing startup time, peak RAM,
and binary size — all critical on the Raspberry Pi 5. It also introduces
compile-time constraints that are invisible during local dotnet run.
The fundamental rule
Always test with dotnet publish -c Release -r linux-arm64 --self-contained true -p:PublishAot=true, not just dotnet run.
dotnet run uses JIT and hides every AOT incompatibility.
Build command (from Makefile)
cd servers/dotnet && dotnet publish -c Release \
-r linux-arm64 --self-contained true \
-p:PublishAot=true \
-o publish
The output binary is servers/dotnet/publish/Server (gitignored).
Project file must-haves
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
</PropertyGroup>
InvariantGlobalization=true is mandatory: removes ICU dependency, dramatically reduces binary size, and avoids a missing library crash on Raspberry Pi OS.
JSON serialization — source generation is mandatory for AOT
AOT trims all code unreachable at compile time. JsonSerializer.Serialize<T>() with
no context uses runtime reflection — this compiles fine but crashes at runtime.
Required pattern
[JsonSerializable(typeof(Item))]
[JsonSerializable(typeof(List<Item>))]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(CreateItemRequest))]
internal partial class AppJsonContext : JsonSerializerContext { }
Register the context once on startup:
builder.Services.ConfigureHttpJsonOptions(opts =>
opts.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
Use it in handlers:
Results.Json(item, AppJsonContext.Default.Item)
JsonSerializer.Serialize(item)
JSON field naming
Source generators respect [JsonPropertyName] attributes — use them when the
C# property name differs from the JSON field name.
public record Item(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("created_at")] string CreatedAt
);
DateTime — always UTC with Z suffix
DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
DateTime.Now.ToString()
DateTime.UtcNow.ToString()
Database: Microsoft.Data.Sqlite
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.*" />
Microsoft.Data.Sqlite bundles a native SQLite library. For AOT + linux-arm64
the native lib must ship with the binary — the NuGet package handles this when
-r linux-arm64 --self-contained true is set.
using var conn = new SqliteConnection($"Data Source={dbPath}");
conn.Open();
DB_PATH resolution
var dbPath = Environment.GetEnvironmentVariable("DB_PATH")
?? Path.Combine(AppContext.BaseDirectory, "../../data/benchmark.db");
PORT binding
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
Endpoint responses — Minimal API
app.MapGet("/items", async () => {
var items = await GetAllItems();
return Results.Json(items, AppJsonContext.Default.ListItem);
});
app.MapPost("/items", async (CreateItemRequest req) => {
if (string.IsNullOrEmpty(req.Name))
return Results.Json(new ErrorResponse("name is required"),
AppJsonContext.Default.ErrorResponse, statusCode: 400);
var item = await InsertItem(req.Name);
return Results.Created($"/items/{item.Id}",
Results.Json(item, AppJsonContext.Default.Item));
});
app.MapDelete("/items/{id:int}", async (int id) => {
var deleted = await DeleteItem(id);
return deleted ? Results.NoContent() :
Results.Json(new ErrorResponse("not found"),
AppJsonContext.Default.ErrorResponse, statusCode: 404);
});
Single-process constraint (RF6)
var app = builder.Build();
AOT incompatible patterns — will compile but crash or warn
| Pattern | Problem | Fix |
|---|
JsonSerializer.Serialize(obj) | Reflection | Use source-generated context |
Activator.CreateInstance(type) | Dynamic | Use new T() |
dynamic keyword | Runtime reflection | Use explicit types |
Type.GetMethods() | Reflection | Design without reflection |
| Third-party libs that use reflection internally | Trimming removes needed code | Check AOT compatibility in NuGet metadata |
ILLink / trimmer warnings
During dotnet publish with AOT, warnings like IL2026 or IL3050 indicate
trimming hazards. Treat them as errors in this project — they signal runtime
crashes on the Pi.
Module layout (from spec 0008)
servers/dotnet/
├── Server.csproj
├── Program.cs # Minimal API app + Kestrel startup + all routes
└── publish/ # gitignored