@@ -0,0 +1,163 @@
|
||||
# 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:
|
||||
|
||||
```csharp
|
||||
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`:
|
||||
|
||||
```csharp
|
||||
// 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:
|
||||
|
||||
```csharp
|
||||
// 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:
|
||||
|
||||
```csharp
|
||||
// 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`:
|
||||
|
||||
```csharp
|
||||
using (var scope = app.Services.CreateScope())
|
||||
await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync();
|
||||
```
|
||||
|
||||
Add new indexes to `EnsureIndexesAsync`:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user