Co-authored-by: Copilot <copilot@github.com>
6.1 KiB
Data Models and AOT Safety
This guide explains how to define MongoDB document models, register them for AOT-safe serialization, and avoid the common patterns that break Native AOT compilation.
Why AOT matters
The project is compiled with <PublishAot>true</PublishAot>. AOT (Ahead-of-Time) compilation strips out the JIT and eliminates reflection-based code paths at runtime. Any code that relies on Type.GetProperties(), Activator.CreateInstance(), Expression.Compile(), or similar reflection primitives will either:
- Produce a build warning during
dotnet publish, or - Throw a
MissingMethodException/InvalidOperationExceptionat runtime
The two main risks in this project are MongoDB BSON serialization and System.Text.Json serialization. Both require explicit registration rather than auto-discovery.
Defining a document model
A document class is a plain C# class annotated with BSON attribute hints. Keep it simple:
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Htmx.ApiDemo.Data;
public sealed class AppUser
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("email")]
public string Email { get; set; } = "";
[BsonElement("normalizedEmail")]
public string NormalizedEmail { get; set; } = "";
[BsonElement("passwordHash")]
public string PasswordHash { get; set; } = "";
[BsonElement("displayName")]
public string? DisplayName { get; set; }
[BsonElement("createdAtUtc")]
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
Rules:
- Always annotate the primary key with
[BsonId] - Always annotate every persisted property with
[BsonElement("fieldName")]— this makes the MongoDB field name explicit and independent of C# naming conventions - Use
SetIgnoreExtraElements(true)in the class map (see below) so old documents with extra fields do not crash deserialization
Registering the class map (AOT-safe)
MongoDB's default AutoMap() uses reflection to discover properties at runtime. This is not AOT-safe. Instead, register an explicit BsonClassMap in Program.cs before WebApplication.CreateSlimBuilder:
// Program.cs — must appear before builder construction
BsonClassMap.RegisterClassMap<AppUser>(cm =>
{
cm.MapIdProperty(u => u.Id).SetSerializer(new ObjectIdSerializer());
cm.MapProperty(u => u.Email).SetElementName("email");
cm.MapProperty(u => u.NormalizedEmail).SetElementName("normalizedEmail");
cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash");
cm.MapProperty(u => u.DisplayName).SetElementName("displayName");
cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc");
cm.SetIgnoreExtraElements(true);
});
This replaces AutoMap entirely. Every property you want persisted must be listed here.
Adding a new model
- Create the model class in
Data/with[BsonId]and[BsonElement]attributes - Add a
BsonClassMap.RegisterClassMap<YourModel>(...)block inProgram.csbefore the builder - Wire the collection into
MongoDbService
MongoDbService pattern
MongoDbService is the single place that owns typed MongoDB collections. Add new collections here:
// Data/MongoDbService.cs
public sealed class MongoDbService
{
private readonly IMongoCollection<AppUser> _users;
// add more collections here
public MongoDbService(IMongoClient client, IConfiguration configuration)
{
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
_users = db.GetCollection<AppUser>("users");
// _posts = db.GetCollection<Post>("posts");
}
}
All queries use the strongly-typed Builders<T> API:
// Exact-match lookup — no LINQ translation, no reflection at runtime
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
return await _users.Find(filter).FirstOrDefaultAsync(ct);
The Builders<T> API compiles query expressions to BSON at build time via source generators in the MongoDB driver — it does not require runtime reflection.
Index management
Indexes are created via EnsureIndexesAsync, called once at startup from Program.cs:
using (var scope = app.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync();
Add new indexes to EnsureIndexesAsync:
public async Task EnsureIndexesAsync(CancellationToken ct = default)
{
var indexKeys = Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail);
var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" };
var model = new CreateIndexModel<AppUser>(indexKeys, indexOptions);
await _users.Indexes.CreateOneAsync(model, cancellationToken: ct);
// add more index creation calls here — CreateOneAsync is idempotent
}
The call is idempotent: if the index already exists with the same definition, MongoDB silently succeeds.
AOT anti-patterns to avoid
| Pattern | Why it breaks AOT | Safe alternative |
|---|---|---|
BsonClassMap.RegisterClassMap<T>() without explicit mapping |
Uses AutoMap reflection | Explicit cm.MapProperty(...) for every field |
collection.AsQueryable().Where(...) |
EF-style LINQ translation uses reflection | Builders<T>.Filter.Eq(...) |
JsonSerializer.Deserialize<T>(json) without a type resolver |
Reflects on T at runtime | Register T in AppJsonSerializerContext |
Activator.CreateInstance(type) |
Requires reflection metadata | Use new T() directly |
typeof(T).GetProperties() |
Stripped by trimmer | Not needed with explicit class maps |
Checking for AOT warnings
Run a Release publish and watch the output:
dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release
Any trim/AOT warning in the output is a potential runtime failure. Fix each warning before it reaches production. The most common suppressable case is third-party libraries that are not fully AOT-annotated — suppress only after manually verifying the code path is not exercised at runtime.