ee8797c142
Co-authored-by: Copilot <copilot@github.com>
164 lines
6.1 KiB
Markdown
164 lines
6.1 KiB
Markdown
# 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.
|