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.
Types with their own [JsonConverter]
Section titled “Types with their own [JsonConverter]”A property whose type carries a type-level [JsonConverter] is emitted as a value using that converter
rather than being walked as an object — so converter-backed types work even when they’re immutable or
abstract. This is how GeoPoint (an immutable struct), GeoPoint?, and Geometry (an abstract polymorphic
base) serialize as GeoJSON under Generated.
The converter must be public, non-abstract, have a public parameterless constructor, and derive from
JsonConverter<T> where T is exactly the declared member type. The generated resolver lives in your
assembly and constructs the converter directly, so:
- An inaccessible converter raises
DDB005— make it public. - A
JsonConverterFactoryis not supported (raisesDDB005). - Declare the member as the type the converter converts. A
JsonConverter<Geometry>cannot produce metadata for a derivedGeoPolygonmember — type the property asGeometry. - Member-level
[JsonConverter](on the property rather than its type) is not supported and raisesDDB005; move the attribute onto the type, or use a non-Generatedmode. - A document type itself cannot carry a type-level
[JsonConverter]— the query translator needs the document to serialize as a JSON object.
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. |


