Rewrote all the docs - more noob friendly now.
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,23 +1,29 @@
|
||||
# Data Models and AOT Safety
|
||||
# Data Models and AOT
|
||||
|
||||
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.
|
||||
Think of your data model as a **passport**. When a document leaves MongoDB and enters your C# code (or vice versa), it needs to be checked against an explicit, pre-declared format. In a regular .NET app, the runtime reads the passport on the fly using reflection. Under AOT, that border crossing has to be pre-approved at build time — every field declared up front, no surprises allowed.
|
||||
|
||||
## Why AOT matters
|
||||
This guide covers how to define models, register them safely for AOT, and avoid the patterns that quietly break in production.
|
||||
|
||||
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
|
||||
## What you want to achieve
|
||||
|
||||
The two main risks in this project are MongoDB BSON serialization and System.Text.Json serialization. Both require explicit registration rather than auto-discovery.
|
||||
By the end of this guide you will know how to:
|
||||
|
||||
- Define a MongoDB document class
|
||||
- Register it so it survives AOT compilation
|
||||
- Add a new collection to the app
|
||||
- Create an index on startup
|
||||
- Spot and fix the most common AOT mistakes
|
||||
|
||||
---
|
||||
|
||||
## Defining a document model
|
||||
|
||||
A document class is a plain C# class annotated with BSON attribute hints. Keep it simple:
|
||||
A document class is a plain C# class. Keep it simple — just properties, no logic.
|
||||
|
||||
```csharp
|
||||
// Data/AppUser.cs
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
@@ -45,19 +51,20 @@ public sealed class AppUser
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
Two rules:
|
||||
- `[BsonId]` marks the primary key — always required
|
||||
- `[BsonElement("fieldName")]` names the MongoDB field explicitly — always use this, otherwise renaming a C# property breaks existing documents
|
||||
|
||||
---
|
||||
|
||||
## Registering the class map (AOT-safe)
|
||||
## Registering the class map
|
||||
|
||||
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`:
|
||||
MongoDB's default behaviour is to scan your class at runtime using reflection and figure out the fields automatically. That does not work under AOT.
|
||||
|
||||
Instead, you declare the mapping explicitly in `Program.cs` before the app builder is created:
|
||||
|
||||
```csharp
|
||||
// Program.cs — must appear before builder construction
|
||||
// Program.cs — at the very top, before WebApplication.CreateSlimBuilder
|
||||
|
||||
BsonClassMap.RegisterClassMap<AppUser>(cm =>
|
||||
{
|
||||
@@ -67,97 +74,133 @@ BsonClassMap.RegisterClassMap<AppUser>(cm =>
|
||||
cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash");
|
||||
cm.MapProperty(u => u.DisplayName).SetElementName("displayName");
|
||||
cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc");
|
||||
cm.SetIgnoreExtraElements(true); // old documents with extra fields won't crash
|
||||
});
|
||||
```
|
||||
|
||||
Every property you want stored must be listed here. If it is not in the class map, it will not be read or written.
|
||||
|
||||
`SetIgnoreExtraElements(true)` is important: as your model evolves, old documents in the database may have fields that no longer exist in your class. Without this, deserializing them throws an exception.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new model — step by step
|
||||
|
||||
### 1. Create the class in `Data/`
|
||||
|
||||
```csharp
|
||||
// Data/Post.cs
|
||||
public sealed class Post
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
|
||||
|
||||
[BsonElement("title")]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[BsonElement("authorId")]
|
||||
public ObjectId AuthorId { get; set; }
|
||||
|
||||
[BsonElement("createdAtUtc")]
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register the class map in `Program.cs`
|
||||
|
||||
```csharp
|
||||
BsonClassMap.RegisterClassMap<Post>(cm =>
|
||||
{
|
||||
cm.MapIdProperty(p => p.Id).SetSerializer(new ObjectIdSerializer());
|
||||
cm.MapProperty(p => p.Title).SetElementName("title");
|
||||
cm.MapProperty(p => p.AuthorId).SetElementName("authorId");
|
||||
cm.MapProperty(p => p.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:
|
||||
### 3. Add the collection to `MongoDbService`
|
||||
|
||||
```csharp
|
||||
// Data/MongoDbService.cs
|
||||
public sealed class MongoDbService
|
||||
{
|
||||
private readonly IMongoCollection<AppUser> _users;
|
||||
// add more collections here
|
||||
private readonly IMongoCollection<Post> _posts; // ← add this
|
||||
|
||||
public MongoDbService(IMongoClient client, IConfiguration configuration)
|
||||
{
|
||||
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
|
||||
_users = db.GetCollection<AppUser>("users");
|
||||
// _posts = db.GetCollection<Post>("posts");
|
||||
_posts = db.GetCollection<Post>("posts"); // ← add this
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All queries use the strongly-typed `Builders<T>` API:
|
||||
---
|
||||
|
||||
## Querying data
|
||||
|
||||
Use the `Builders<T>` API — not LINQ, not EF-style expressions. The `Builders<T>` API generates BSON queries at compile time using source generators built into the MongoDB driver, so no reflection is needed at runtime.
|
||||
|
||||
```csharp
|
||||
// Exact-match lookup — no LINQ translation, no reflection at runtime
|
||||
// Good — AOT-safe
|
||||
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
|
||||
return await _users.Find(filter).FirstOrDefaultAsync(ct);
|
||||
```
|
||||
var user = 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.
|
||||
// Bad — uses LINQ translation that requires runtime reflection
|
||||
var user = await _users.AsQueryable().FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Index management
|
||||
|
||||
Indexes are created via `EnsureIndexesAsync`, called once at startup from `Program.cs`:
|
||||
Indexes are declared in `EnsureIndexesAsync` inside `MongoDbService` and 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`:
|
||||
Add new indexes here:
|
||||
|
||||
```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);
|
||||
// Unique index on email for fast login lookups
|
||||
var emailIndex = new CreateIndexModel<AppUser>(
|
||||
Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" });
|
||||
|
||||
// add more index creation calls here — CreateOneAsync is idempotent
|
||||
await _users.Indexes.CreateOneAsync(emailIndex, cancellationToken: ct);
|
||||
|
||||
// Add more indexes here — CreateOneAsync is idempotent (safe to call every startup)
|
||||
}
|
||||
```
|
||||
|
||||
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 |
|
||||
These patterns compile and run fine in `dotnet run` (Debug mode with the JIT), but fail silently or throw at runtime in a published AOT binary.
|
||||
|
||||
| Pattern | Why it breaks | What to do instead |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `BsonClassMap.RegisterClassMap<T>()` without explicit property mapping | Uses AutoMap reflection internally | List every property with `cm.MapProperty(...)` |
|
||||
| `collection.AsQueryable().Where(...)` | LINQ translation requires runtime reflection | Use `Builders<T>.Filter.Eq(...)` etc. |
|
||||
| `JsonSerializer.Deserialize<T>(json)` without registering T | Reflects on T at runtime | Add T to `AppJsonSerializerContext` (see guide 05) |
|
||||
| `Activator.CreateInstance(type)` | Requires reflection metadata stripped by AOT trimmer | Use `new T()` directly |
|
||||
| Packages that internally use AutoMapper, EF Core, or convention-scanning DI | Reflection-heavy at runtime | Find AOT-compatible alternatives |
|
||||
|
||||
---
|
||||
|
||||
## Checking for AOT warnings
|
||||
## Checking your work
|
||||
|
||||
Run a Release publish and watch the output:
|
||||
Run a Release publish regularly — do not wait until you are done with a feature:
|
||||
|
||||
```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.
|
||||
AOT warnings in the build output are potential runtime failures. Each warning names the exact type or method causing the issue. Fix them as they appear rather than letting them accumulate.
|
||||
|
||||
Reference in New Issue
Block a user