f6ae86617c
Co-authored-by: Copilot <copilot@github.com>
207 lines
7.1 KiB
Markdown
207 lines
7.1 KiB
Markdown
# Data Models and AOT
|
|
|
|
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.
|
|
|
|
This guide covers how to define models, register them safely for AOT, and avoid the patterns that quietly break in production.
|
|
|
|
---
|
|
|
|
## What you want to achieve
|
|
|
|
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. Keep it simple — just properties, no logic.
|
|
|
|
```csharp
|
|
// Data/AppUser.cs
|
|
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;
|
|
}
|
|
```
|
|
|
|
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
|
|
|
|
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 — at the very top, before WebApplication.CreateSlimBuilder
|
|
|
|
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); // 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);
|
|
});
|
|
```
|
|
|
|
### 3. Add the collection to `MongoDbService`
|
|
|
|
```csharp
|
|
// Data/MongoDbService.cs
|
|
public sealed class MongoDbService
|
|
{
|
|
private readonly IMongoCollection<AppUser> _users;
|
|
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"); // ← add this
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
// Good — AOT-safe
|
|
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
|
|
var user = await _users.Find(filter).FirstOrDefaultAsync(ct);
|
|
|
|
// Bad — uses LINQ translation that requires runtime reflection
|
|
var user = await _users.AsQueryable().FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
|
```
|
|
|
|
---
|
|
|
|
## Index management
|
|
|
|
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 here:
|
|
|
|
```csharp
|
|
public async Task EnsureIndexesAsync(CancellationToken ct = default)
|
|
{
|
|
// 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" });
|
|
|
|
await _users.Indexes.CreateOneAsync(emailIndex, cancellationToken: ct);
|
|
|
|
// Add more indexes here — CreateOneAsync is idempotent (safe to call every startup)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## AOT anti-patterns to avoid
|
|
|
|
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 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 your work
|
|
|
|
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
|
|
```
|
|
|
|
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.
|