Typed Context
DocumentContext is an optional, EF-Core-style typed front-end over IDocumentStore. Instead of
re-typing <T> and remembering the JsonTypeInfo<T> at every call site, you declare your aggregates once on
a partial context and work against discoverable DocumentSet<T> properties. A source generator
bundled in the core Shiny.DocumentDb package emits the sets, the configuration lowering, and a DI
extension — all compile-time and AOT-clean.
// store-firstvar adults = await store.Query<User>().Where(x => x.Age > 18).ToList();await store.Insert(user, AppJsonContext.Default.User);
// context (this feature) — model-first, JsonTypeInfo threaded for youvar adults = await db.Users.Where(x => x.Age > 18).ToList();await db.Users.Insert(user);Install
Section titled “Install”dotnet add package Shiny.DocumentDbThat’s all you need. DocumentContext and DocumentSet<T> are runtime types in the core
Shiny.DocumentDb package, and the source generator that emits your sets ships inside that same
package (bundled under analyzers/dotnet/cs) — there is no separate generator package to install.
Declare a context
Section titled “Declare a context”Derive a partial class from DocumentContext and decorate it with [Document(typeof(T))] per aggregate.
[Document(typeof(User), Id = nameof(User.Email), JsonContext = typeof(AppJsonContext))][Document(typeof(Order), Table = "orders", JsonContext = typeof(AppJsonContext))]public partial class AppContext : DocumentContext;[Document] lowers into the same DocumentStoreOptions.Map* calls you’d write by hand:
| Property | Effect | Default |
|---|---|---|
Table | Backing table/collection name | TypeNameResolution |
Id | Id property name (e.g. nameof(User.Email)) | convention (Id) |
Set | Generated set property name | pluralized type name (User → Users) |
Serialization | How the type’s JsonTypeInfo resolves | Auto |
JsonContext | Your JsonSerializerContext for AOT-safe metadata | none |
The generator emits the other half of the partial — a DocumentSet<T> per type, a ConfigureModel lowering,
and a DI extension:
// <auto-generated/>partial class AppContext{ public AppContext(IDocumentStore store) : base(store) { } // emitted if you didn't write one
public DocumentSet<User> Users => /* ... */; public DocumentSet<Order> Orders => /* ... */;
internal static void ConfigureModel(DocumentStoreOptions options) { /* Map* + resolver wiring */ }}
public static class AppContextRegistration{ // Scoped context (ASP.NET Core request scopes) public static IServiceCollection AddAppContext(this IServiceCollection services, Action<DocumentStoreOptions> configure);
// Singleton IDocumentContextFactory<AppContext> (MAUI / Blazor / desktop — no ambient scope) public static IServiceCollection AddAppContextFactory(this IServiceCollection services, Action<DocumentStoreOptions> configure);}Register & use
Section titled “Register & use”builder.Services.AddAppContext(o =>{ o.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db"); o.UseReflectionFallback = false; // strict AOT — JsonContext mode carries everything});
// inject AppContext anywhere (registered scoped, like a DbContext)public class UserService(AppContext db){ public Task<IReadOnlyList<User>> Adults() => db.Users.Where(u => u.Age >= 18).ToList();}// MAUI / Blazor / desktop have no per-request scope. Register a singleton factory and// create a short-lived context per unit of work — the parallel of EF's IDbContextFactory<T>.builder.Services.AddAppContextFactory(o =>{ o.DatabaseProvider = new SqliteDatabaseProvider($"Data Source={dbPath}"); o.UseReflectionFallback = false;});
// inject the factory anywhere — even into a singleton page/view-modelpublic class UserViewModel(IDocumentContextFactory<AppContext> factory){ public async Task<IReadOnlyList<User>> Adults() { await using var db = factory.Create(); // owns a child scope + session — await using it return await db.Users.Where(u => u.Age >= 18).ToList(); }}var opts = new DocumentStoreOptions { DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db") };AppContext.ConfigureModel(opts);// the generated constructor takes an IDocumentSession (the context is a unit of work)await using var db = new AppContext(new DocumentStore(opts).OpenSession());DocumentContext only needs an IDocumentStore, so it works over any provider — including the ones with
their own options type (LiteDB, MongoDB, Cosmos): construct that store yourself and pass it to the context.
The generated ConfigureModel / AddAppContext target the relational DocumentStoreOptions.
DocumentSet<T>
Section titled “DocumentSet<T>”Each set forwards to the store with its JsonTypeInfo pre-applied:
// queries — return IDocumentQuery<T> as-is, so the full surface is availableIDocumentQuery<User> q = db.Users.Query();var adults = await db.Users.Where(u => u.Age >= 18).OrderBy(u => u.Name).ToList();var alice = await db.Users.Get("alice@x.com");var count = await db.Users.Count();
// immediate writesawait db.Users.Insert(user);await db.Users.Update(user);await db.Users.Upsert(patch);await db.Users.Remove("alice@x.com");
// batch writes (atomic where the provider supports it)await db.Users.BatchInsert(users);
// the context IS the unit of work — buffer with Add/Update/Upsert/Remove, commit atomicallydb.Add(order).Remove<User>("old@x.com");await db.SaveChanges();
// explicit transaction (relational) — locking reads via the raw session, then commitawait using var tx = await db.BeginTransaction();var u = await db.Session.Get<User>("u1", LockMode.Update);db.Update(u);await db.SaveChanges(); // flushes into the transactionawait tx.Commit();The typed sets (db.Users.Insert(…)) are immediate; the context’s own Add/Update/Upsert/Remove +
SaveChanges are the buffered unit-of-work path. Reach the raw IDocumentSession via db.Session and the
root IDocumentStore via db.Store.
Because the set returns the store’s IDocumentQuery<T> unchanged, Select, Paginate, aggregates, and the
spatial/vector/full-text terminators all come for free — see Querying.
Worked example — a small order service
Section titled “Worked example — a small order service”End-to-end: two aggregates, a context, both registration styles, and the three ways to write.
// ── models ──────────────────────────────────────────────────────────────public class User { public string Id { get; set; } = ""; public string Name { get; set; } = ""; public int Age { get; set; } }public class Order { public string Id { get; set; } = ""; public string UserId { get; set; } = ""; public decimal Total { get; set; } }
// ── context: one [Document] per aggregate; the generator emits db.Users / db.Orders ──[Document(typeof(User))][Document(typeof(Order))]public partial class AppDb : DocumentContext;// Program.cs — registered scoped, one context per requestbuilder.Services.AddAppDb(o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(connectionString));
// inject the context directly — the container disposes it at end of requestapp.MapPost("/users", async (User user, AppDb db) =>{ await db.Users.Insert(user); // immediate return Results.Created($"/users/{user.Id}", user);});
app.MapGet("/users/{id}", async (string id, AppDb db) => await db.Users.Get(id) is { } u ? Results.Ok(u) : Results.NotFound());// MauiProgram.cs — no request scope, so use the factorybuilder.Services.AddAppDbFactory(o => o.DatabaseProvider = new SqliteDatabaseProvider($"Data Source={dbPath}"));
public class OrdersViewModel(IDocumentContextFactory<AppDb> dbf){ public async Task<IReadOnlyList<Order>> Recent() { await using var db = dbf.Create(); // one context per unit of work — await using it return await db.Orders.Where(o => o.Total >= 100) .OrderByDescending(o => o.Total) .Paginate(0, 20) .ToList(); }}// ── the three ways to write ─────────────────────────────────────────────await using var db = dbf.Create();
// (a) immediate — typed sets commit each callawait db.Users.Insert(new User { Id = "u1", Name = "Allan", Age = 41 });
// (b) unit of work — buffer on the context, commit atomically (all-or-nothing)db.Add(new Order { Id = "o1", UserId = "u1", Total = 50 }) .Add(new Order { Id = "o2", UserId = "u1", Total = 75 });await db.SaveChanges();
// (c) explicit transaction — locking read + grouped writes (relational providers)await using var tx = await db.BeginTransaction();var user = await db.Session.Get<User>("u1", LockMode.Update);user.Age++;db.Update(user);await db.SaveChanges(); // flushes into the transaction (no commit yet)await tx.Commit();| You want | Use |
|---|---|
| Read | db.Users.Get(id), db.Users.Where(…).ToList(), db.Users.Count() |
| Immediate write | db.Users.Insert / Update / Upsert / Remove(…) |
| Atomic multi-write | db.Add(x).Add(y); await db.SaveChanges(); |
| Transaction / locking / isolation | await using var tx = await db.BeginTransaction(IsolationLevel.Snapshot); … await tx.Commit(); |
| Escape hatch | db.Session (raw IDocumentSession), db.Store (root IDocumentStore) |
No change tracking or identity map — a document store embeds, it does not join.
Serialization modes
Section titled “Serialization modes”Serialization chooses how each type’s JsonTypeInfo<T> resolves. AOT is the goal; reflection is the
explicit opt-out. See AOT Setup for
the full comparison.
| Mode | AOT-safe? | Notes |
|---|---|---|
Auto (default) | Yes, if a context is registered | Inherits the store’s resolver, else reflection fallback. |
JsonContext | Yes | Point JsonContext = typeof(MyJsonCtx) at your JsonSerializerContext. Recommended for AOT. |
Reflection | No | Explicit opt-out for non-AOT apps that won’t maintain a context. |
Generated | Yes | The generator emits the metadata-mode JsonTypeInfo for you — AOT-safe with no JsonSerializerContext. Supported subset below. |
Generated mode
Section titled “Generated mode”[Document(typeof(T), Serialization = DocumentSerialization.Generated)] makes the generator emit an
IJsonTypeInfoResolver that builds T’s metadata (and its whole reachable type closure) via
JsonMetadataServices — the same AOT-safe shape System.Text.Json’s own generator produces — so you get AOT
serialization from the single [Document] list, no hand-written JsonSerializerContext.
Supported per-type: a POCO with a public parameterless constructor and settable public properties
whose types are JSON primitives, Guid/date-time types, enums, nullable value types, nested supported
objects, List<T>, or T[]. [JsonPropertyName] and [JsonIgnore] are honored. Anything outside that
subset (records / parameterized constructors, init-only or get-only members, dictionaries, interfaces) raises
DDB005 — use JsonContext for those types.
Diagnostics
Section titled “Diagnostics”| Id | Meaning |
|---|---|
DDB001 | A [Document] type is not declared partial. |
DDB002 | A [Document] type does not derive from DocumentContext. |
DDB003 | Two [Document] declarations resolve to the same set name — set Set = on one. |
DDB005 | A Generated type (or something in its closure) is outside the supported subset — use JsonContext. |