Skip to content
Introducing AI Conversations: Natural Language Interaction for Your Apps! Learn More

Document DB

A lightweight, database-agnostic document store for .NET that turns your database into a schema-free JSON document database with LINQ querying, spatial/geo queries, vector / ANN search, 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. One API, multiple database providers.

Frameworks
.NET
.NET MAUI
Blazor
ASP.NET
Operating Systems
Android
iOS
Windows
GitHubGitHub stars for shinyorg/DocumentDb
DownloadsNuGet downloads for Shiny.DocumentDb
SQLiteNuGet downloads for Shiny.DocumentDb.Sqlite
SQLCipherNuGet downloads for Shiny.DocumentDb.Sqlite.SqlCipher
SQL ServerNuGet downloads for Shiny.DocumentDb.SqlServer
MySQLNuGet downloads for Shiny.DocumentDb.MySql
PostgreSQLNuGet downloads for Shiny.DocumentDb.PostgreSql
LiteDBNuGet downloads for Shiny.DocumentDb.LiteDb
CosmosDBNuGet downloads for Shiny.DocumentDb.CosmosDb
MongoDBNuGet downloads for Shiny.DocumentDb.MongoDb
DuckDBNuGet downloads for Shiny.DocumentDb.DuckDb
DI ExtensionsNuGet downloads for Shiny.DocumentDb.Extensions.DependencyInjection
IndexedDBNuGet downloads for Shiny.DocumentDb.IndexedDb
AI ToolsNuGet downloads for Shiny.DocumentDb.Extensions.AI
  • Multi-provider — SQLite, SQLCipher (encrypted SQLite), LiteDB, CosmosDB, MongoDB, DuckDB, IndexedDB (Blazor WASM), SQL Server, MySQL, and PostgreSQL with a single API
  • Zero schema, zero migrations — store objects as JSON documents
  • Fluent query builderstore.Query<User>().Where(u => u.Age > 30).OrderBy(u => u.Name).Paginate(0, 20).ToList() with full LINQ expression support for nested properties, Any(), Count(), string methods, null checks, and captured variables
  • IAsyncEnumerable<T> streaming — yield results one-at-a-time with .ToAsyncEnumerable()
  • Expression-based JSON indexes — up to 30x faster queries on indexed properties
  • SQL-level projections — project into DTOs via .Select() at the database level
  • Aggregates — scalar .Max(), .Min(), .Sum(), .Average() as terminal methods; aggregate projections with automatic GROUP BY via Sql.* markers; collection-level Sum, Min, Max, Average on child collections
  • Ordering.OrderBy(u => u.Age) and .OrderByDescending(u => u.Name) on the fluent query builder
  • Pagination.Paginate(offset, take) translates to SQL LIMIT/OFFSET
  • Table-per-type mappingMapTypeToTable<T>() gives a document type its own dedicated table. Unmapped types share a configurable default table
  • Custom Id propertiesMapTypeToTable<T>("table", x => x.MyProp) to combine with a dedicated table, or MapIdProperty<T>(x => x.MyProp) to override the Id while keeping the type in the default shared table
  • Document diffingGetDiff compares a modified object against the stored document and returns an RFC 6902 JsonPatchDocument<T> with deep nested-object diffing
  • Surgical field updatesSetProperty updates a single JSON field without deserialization. RemoveProperty strips a field. Both support nested paths
  • JSON Merge Patch (Upsert)Upsert uses RFC 7396 json_patch to deep-merge a partial object into an existing document, preserving unset nullable fields. Inserts if the document doesn’t exist
  • Bulk operationsQuery<T>().Where(...).ExecuteUpdate(x => x.Prop, value) and .ExecuteDelete() issue a single SQL statement against all matching documents — no deserialization, no client-side loop
  • Typed Id lookupsGet, Remove, SetProperty, and RemoveProperty accept the Id as object so you can pass a Guid, int, long, or string directly. Unsupported types throw ArgumentException
  • Full AOT/trimming support — all JsonTypeInfo<T> parameters are optional and auto-resolve from a configured JsonSerializerContext. Set UseReflectionFallback = false to catch missing registrations with clear exceptions
  • Optimistic concurrencyMapVersionProperty<T>(x => x.RowVersion) enables automatic version checking on update/upsert. Version is set to 1 on insert, checked and incremented on update. Throws ConcurrencyException on conflict. Works across all providers — stored in the JSON blob with zero schema changes
  • TransactionsRunInTransaction with automatic commit/rollback
  • Batch insertBatchInsert inserts a collection in a single transaction with prepared command reuse, auto-generates IDs, and rolls back atomically on failure
  • Spatial / geo queriesWithinRadius, WithinBoundingBox, and NearestNeighbors with GeoPoint support. SQLite uses R*Tree; CosmosDB uses native ST_DISTANCE/ST_WITHIN. Learn more
  • Vector / ANN search — register an embedding property with MapVectorProperty<T>(d => d.Embedding, dimensions: 1536, metric: VectorDistance.Cosine, indexKind: VectorIndexKind.Hnsw) and query with Query<T>().Where(...).NearestVectors(query, k). Provider-native indexes: pgvector (PostgreSQL), VECTOR + DiskANN (SQL Server 2025), embedding policy (CosmosDB), $vectorSearch (MongoDB Atlas), vss extension (DuckDB), sqlite-vec (SQLite). Plus AutoEmbedOnInsert<T> to plug in Microsoft.Extensions.AI.IEmbeddingGenerator and embed text automatically on every write. Learn more
  • Composite JSON indexesCreateIndexAsync(ctx.User, u => u.Country, u => u.Age) builds a single B-tree across multiple JSON paths on SQLite, SQLCipher, PostgreSQL, MySQL, DuckDB, and SQL Server. Learn more
  • Hot backupBackup copies the database to a file. Available on SqliteDocumentStore, SqlCipherDocumentStore, and LiteDbDocumentStore
  • Clear allSqliteDocumentStore.ClearAllAsync() deletes all documents across all tables including spatial sidecar data
  • SQLCipher encryption — separate Shiny.DocumentDb.Sqlite.SqlCipher package with AES-256 encryption, password-aware backup, and RekeyAsync to change the encryption key
  • Multi-tenancy — two isolation strategies: shared-table (single database with automatic TenantId column filtering) and tenant-per-database (separate database per tenant via lazy factory). Both resolve the current tenant via a user-implemented ITenantResolver. Consumer code is unchanged — tenant isolation is applied transparently
  • Change monitoring — consume an IAsyncEnumerable<DocumentChange<T>> of insert/update/remove/clear events with await foreach (var c in store.NotifyOnChange<User>(ct)). Filter to a single document with WhenDocumentChanged<T>(id) or to the result set of a fluent query with query.NotifyOnChange(). Buffered inside RunInTransaction and emitted on commit. Learn more
  • Native change feedsIChangeFeedDocumentStore.SubscribeChanges<T> observes all writers via the database’s own mechanism: PostgreSQL LISTEN/NOTIFY triggers, SQL Server Change Tracking (optionally with SqlDependency query notifications), and Cosmos DB native Change Feed. Provisioning is automatic and idempotent
  • Global query filters — register an AddQueryFilter<T>(u => !u.IsDeleted) predicate that’s automatically AND-applied to every query of T, plus Get/Update/Remove/SetProperty/RemoveProperty/Clear/ExecuteUpdate/ExecuteDelete and per-query change monitoring. Mirrors Entity Framework Core’s HasQueryFilter, including named filters, IgnoreQueryFilters()/IgnoreQueryFilters("name"), and captured-variable semantics. Learn more
  • AI tool integrationShiny.DocumentDb.Extensions.AI exposes document types as Microsoft.Extensions.AI tool functions for LLM agents. Per-type capability flags (ReadOnly, All), structured filter expressions, field visibility control, and page size caps. Learn more
  1. Install the NuGet packages

    Install the core package plus your provider:

    Terminal window
    dotnet add package Shiny.DocumentDb.Sqlite

    Each provider package includes the core Shiny.DocumentDb package automatically.

    For dependency injection, also install the DI extensions package:

    Terminal window
    dotnet add package Shiny.DocumentDb.Extensions.DependencyInjection
  2. Register with dependency injection:

    using Shiny.DocumentDb;
    services.AddDocumentStore(opts =>
    {
    opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db");
    });

    Just swap the provider for your database:

    opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db");

    MongoDB uses its own options class — register the store directly with the DI container:

    builder.Services.AddSingleton(new MongoDbDocumentStoreOptions
    {
    ConnectionString = "mongodb://localhost:27017",
    DatabaseName = "mydb"
    });
    builder.Services.AddSingleton<IDocumentStore, MongoDbDocumentStore>();

    For multiple databases, register named stores using .NET keyed services:

    services.AddDocumentStore("users", opts =>
    {
    opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=users.db");
    });
    services.AddDocumentStore("analytics", opts =>
    {
    opts.DatabaseProvider = new PostgreSqlDatabaseProvider("Host=...");
    });

    Inject via [FromKeyedServices("name")] or resolve dynamically with IDocumentStoreProvider:

    public class MyService(
    [FromKeyedServices("users")] IDocumentStore userStore,
    [FromKeyedServices("analytics")] IDocumentStore analyticsStore) { }
    // Or dynamically:
    public class MyService(IDocumentStoreProvider stores)
    {
    void DoWork() => stores.GetStore("users").Insert(...);
    }

    For multi-tenant applications, two isolation strategies are available:

    // Shared-table: single database, automatic TenantId column filtering
    services.AddSingleton<ITenantResolver, MyTenantResolver>();
    services.AddDocumentStore(opts =>
    {
    opts.DatabaseProvider = new PostgreSqlDatabaseProvider("Host=...");
    }, multiTenant: true);
    // Tenant-per-database: separate database per tenant (scoped IDocumentStore)
    services.AddSingleton<ITenantResolver, MyTenantResolver>();
    services.AddMultiTenantDocumentStore(tenantId => new DocumentStoreOptions
    {
    DatabaseProvider = new SqliteDatabaseProvider($"Data Source={tenantId}.db")
    });

    Both require an ITenantResolver implementation:

    public class MyTenantResolver(IHttpContextAccessor http) : ITenantResolver
    {
    public string GetCurrentTenant()
    => http.HttpContext?.User.FindFirst("tenant_id")?.Value
    ?? throw new InvalidOperationException("No tenant context");
    }

    Or instantiate directly (no DI needed):

    // Quick setup (SQLite convenience class)
    var store = new SqliteDocumentStore("Data Source=mydata.db");
    // Full options
    var store = new SqliteDocumentStore(new DocumentStoreOptions
    {
    DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
    });
  3. Inject IDocumentStore and start using it:

    public class MyService(IDocumentStore store)
    {
    public async Task SaveUser(User user)
    {
    await store.Insert(user); // Id auto-generated for Guid/int/long; string Ids must be set
    }
    public async Task<User?> GetUser(string id)
    {
    return await store.Get<User>(id);
    }
    public async Task<IReadOnlyList<User>> GetActiveUsers()
    {
    return await store.Query<User>()
    .Where(u => u.IsActive)
    .OrderBy(u => u.Name)
    .ToList();
    }
    }
PropertyTypeDefaultDescription
DatabaseProviderIDatabaseProvider (required)The database provider to use (e.g. SqliteDatabaseProvider, SqlCipherDatabaseProvider, SqlServerDatabaseProvider, MySqlDatabaseProvider, PostgreSqlDatabaseProvider, DuckDbDatabaseProvider). LiteDB, CosmosDB, MongoDB, and IndexedDB use their own options classes.
TableNamestring"documents"Default table name for all document types not mapped via MapTypeToTable
TypeNameResolutionTypeNameResolutionShortNameHow type names are stored (ShortName or FullName)
JsonSerializerOptionsJsonSerializerOptions?nullJSON serialization settings. When a JsonSerializerContext is attached as the TypeInfoResolver, all methods auto-resolve type info from the context
UseReflectionFallbackbooltrueWhen false, throws InvalidOperationException if a type can’t be resolved from the configured TypeInfoResolver instead of falling back to reflection. Recommended for AOT deployments
LoggingAction<string>?nullCallback invoked with every SQL statement executed
TenantIdAccessorFunc<string>?nullWhen set, enables shared-table multi-tenancy. All queries are filtered by TenantId and all inserts include the TenantId value. A dedicated TenantId column and index are created automatically

By default all document types share a single table. Use MapTypeToTable to give a type its own dedicated table. Tables are lazily created on first use. Two types cannot map to the same custom table.

var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db"),
TableName = "docs" // change the default table name (optional)
}
.MapTypeToTable<Order>("orders") // explicit table name
.MapTypeToTable<AuditLog>() // auto-derived table name "AuditLog"
// User stays in the default "docs" table
);

By default every document type must have a property named Id. Override that with a custom property using either MapTypeToTable<T>(...) (combined with a dedicated table) or MapIdProperty<T>(...) (the type stays in the default shared table). The two are independent — use either, both, or neither.

var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}
// Dedicated table + custom Id
.MapTypeToTable<Sensor>("sensors", s => s.DeviceKey) // Guid DeviceKey as Id
.MapTypeToTable<Tenant>("tenants", t => t.TenantCode) // string TenantCode as Id
// Default shared table + custom Id
.MapIdProperty<BlogPost>(p => p.Slug) // string Slug as Id
);

MapTypeToTable and MapIdProperty overloads

Section titled “MapTypeToTable and MapIdProperty overloads”
OverloadDescription
MapTypeToTable<T>()Auto-derive table name from type name
MapTypeToTable<T>(string tableName)Explicit table name
MapTypeToTable<T>(Expression<Func<T, object>> idProperty)Auto-derive table + custom Id
MapTypeToTable<T>(string tableName, Expression<Func<T, object>> idProperty)Explicit table + custom Id
MapIdProperty<T>(Expression<Func<T, object>> idProperty)Custom Id only — type stays in the default shared table
MapIdProperty<T>(string propertyName)AOT-safe string overload

All overloads return DocumentStoreOptions for fluent chaining. Duplicate table names throw InvalidOperationException.

services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db");
opts.MapTypeToTable<User>();
opts.MapTypeToTable<Order>("orders");
opts.MapTypeToTable<Sensor>("sensors", s => s.DeviceKey);
});
claude plugin marketplace add shinyorg/skills
claude plugin install shiny-data@shiny
copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny-data@shiny
View shiny-data Plugin