Files
Htmx/docs/04-data-models-and-aot.md
T
2026-05-04 19:57:48 +05:00

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 / InvalidOperationException at 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

  1. Create the model class in Data/ with [BsonId] and [BsonElement] attributes
  2. Add a BsonClassMap.RegisterClassMap<YourModel>(...) block in Program.cs before the builder
  3. 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.