SQLite Document DB
A lightweight SQLite-based document store for .NET that turns SQLite into a schema-free JSON document database with LINQ querying and full AOT/trimming support. Store entire object graphs — nested objects, child collections — as JSON documents. No CREATE TABLE, no ALTER TABLE, no JOINs, no migrations.
Features
Section titled “Features”- Zero schema, zero migrations — store objects as JSON documents
- LINQ expression queries —
o => o.ShippingAddress.City == "Portland"translates tojson_extractSQL automatically IAsyncEnumerable<T>streaming — yield results one-at-a-time withGetAllStreamandQueryStream- Expression-based JSON indexes — up to 30x faster queries on indexed properties
- SQL-level projections — project into DTOs with
json_objectat the database level - Surgical field updates —
SetPropertyupdates a single JSON field viajson_set()without deserialization.RemovePropertystrips a field viajson_remove(). Both support nested paths - Full AOT/trimming support — every API has a
JsonTypeInfo<T>overload for source-generated JSON. Configure aJsonSerializerContextonce and all overloads auto-resolve type info — no per-callJsonTypeInfo<T>needed. SetUseReflectionFallback = falseto catch missing registrations with clear exceptions - 10-30x faster nested inserts vs sqlite-net — one write per document vs multiple table inserts
- Transactions —
RunInTransactionwith automatic commit/rollback
Replacing EF Core on .NET MAUI
Section titled “Replacing EF Core on .NET MAUI”Entity Framework Core is a natural choice for server-side .NET, but it becomes a liability on .NET MAUI platforms (iOS, Android, Mac Catalyst).
Why EF Core is a poor fit for MAUI
Section titled “Why EF Core is a poor fit for MAUI”- No AOT support. EF Core relies on runtime reflection and dynamic code generation for change tracking, query compilation, and model building. Its public API carries
[RequiresDynamicCode]and[RequiresUnreferencedCode]attributes. On iOS, where Apple prohibits JIT compilation entirely, this is a non-starter for fully native AOT deployments. - Migrations add complexity without value. On a mobile device, the database is created on first launch or ships inside the app bundle. EF Core’s migration pipeline (
Add-Migration,Update-Database,__EFMigrationsHistory) solves a problem that doesn’t exist here. - Heavy dependency graph. EF Core pulls in
Microsoft.EntityFrameworkCore, its SQLite provider, design-time packages, and their transitive dependencies — increasing app bundle size on platforms where download size matters. - Relational overhead for document-shaped data. Mobile apps typically store user preferences, cached API responses, offline data queues, and local state. This data is naturally nested and variable. Normalizing it into tables with foreign keys and JOINs adds accidental complexity.
| Concern | EF Core | Shiny.SqliteDocumentDb |
|---|---|---|
| AOT / trimming | Reflection-heavy; no AOT support | Every API has a JsonTypeInfo<T> overload; zero reflection |
| Migrations | Required for every schema change | Not needed — schema-free JSON |
| Nested objects | Normalized tables, foreign keys, JOINs | Single document, single write, single read |
| App bundle size | Large dependency tree | Single dependency on Microsoft.Data.Sqlite |
| Startup time | DbContext model building, migration checks | Open connection and go |
Why AOT and trimming matter on mobile
Section titled “Why AOT and trimming matter on mobile”Ahead-of-Time compilation is not optional on Apple platforms — iOS, iPadOS, tvOS, and Mac Catalyst all prohibit JIT at the OS level. Android benefits from AOT (PublishAot or AndroidEnableProfiledAot) with faster startup and lower memory usage.
The .NET trimmer removes unreferenced code to shrink the app binary. Libraries that depend on reflection break under trimming because the trimmer cannot statically determine which types are accessed at runtime. This forces developers to either disable trimming (larger binaries) or maintain complex trimmer XML files.
This library avoids both problems:
- Source-generated JSON serialization. The
JsonSerializerContextpattern generates serialization code at compile time. The trimmer and AOT compiler can see every type and code path. - No runtime expression compilation. LINQ expressions are translated to SQL strings by a visitor — no
Expression.Compile(), noReflection.Emit, no dynamic delegates. - No model building. There is no equivalent of EF Core’s
OnModelCreatingthat discovers entities through reflection at startup.
-
Install the NuGet package
Terminal window dotnet add package Shiny.SqliteDocumentDb -
Register with dependency injection:
services.AddSqliteDocumentStore("Data Source=mydata.db");// or with full optionsservices.AddSqliteDocumentStore(opts =>{opts.ConnectionString = "Data Source=mydata.db";opts.TypeNameResolution = TypeNameResolution.FullName;opts.JsonSerializerOptions = new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase};});// AOT-safe — attach a JsonSerializerContext so all overloads auto-resolve type infovar ctx = new AppJsonContext(new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase});services.AddSqliteDocumentStore(opts =>{opts.ConnectionString = "Data Source=mydata.db";opts.JsonSerializerOptions = ctx.Options;opts.UseReflectionFallback = false; // throw instead of using reflection for unregistered types});Or instantiate directly:
var store = new SqliteDocumentStore(new DocumentStoreOptions{ConnectionString = "Data Source=mydata.db"}); -
Inject
IDocumentStoreand start using it:public class MyService(IDocumentStore store){public async Task SaveUser(User user){await store.Set("user-1", user, ctx.User);}public async Task<User?> GetUser(string id){return await store.Get<User>(id, ctx.User);}}
Configuration Options
Section titled “Configuration Options”| Property | Type | Default | Description |
|---|---|---|---|
ConnectionString | string (required) | — | SQLite connection string |
TypeNameResolution | TypeNameResolution | ShortName | How type names are stored (ShortName or FullName) |
JsonSerializerOptions | JsonSerializerOptions? | camelCase, no indent | JSON serialization settings. When a JsonSerializerContext is attached as the TypeInfoResolver, overloads without JsonTypeInfo<T> auto-resolve type info from the context |
UseReflectionFallback | bool | true | When false, throws InvalidOperationException if a type can’t be resolved from the configured TypeInfoResolver instead of falling back to reflection. Recommended for AOT deployments |