Aspire Integration
These packages make “which database backs a DocumentDb store” and “how it gets seeded” AppHost (deployment) decisions instead of code, and give the consuming service a provider-agnostic one-liner that wires the store with health checks and OpenTelemetry already attached.
Shiny.DocumentDb.Aspire.Hosting— AppHost: model a DocumentDb store as a resource, pick its backend, and gate seeding.Shiny.DocumentDb.Aspire.Client— consuming service:AddDocumentStore("name")resolves the injected provider + connection string and registers the store.Shiny.DocumentDb.Aspire.Orleans— silo: back Orleans grain storage / reminders / clustering / grain directory with the Aspire-provisioned store in one call (see Orleans on DocumentDb).
AppHost — pick the backend + seed
Section titled “AppHost — pick the backend + seed”// AppHost Program.csvar store = builder .AddPostgresDocumentStore("orders") // provisions Postgres and models the store .WithSeeder(async (ctx, ct) => { // Runs on the FIRST setup of this database only. Restarting the AppHost does nothing. // ctx => (StoreName, Provider, ConnectionString, Recreate). });
builder.AddProject<Projects.Api>("api") .WithReference(store); // injects connection string + provider discriminatorSwap the backend by changing one call — the consuming service is untouched:
builder.AddSqliteDocumentStore("orders", "orders.db"); // local dev — no containerbuilder.AddSqlServerDocumentStore("orders");builder.AddMySqlDocumentStore("orders");When the seeder runs
Section titled “When the seeder runs”A seeder fires on exactly two triggers — the first time the database is set up, and a destructive recreation. Ordinary AppHost restarts never re-seed.
The “has this store been seeded” marker (__shiny_documentdb_aspire_seed) is written into the backing
database itself, not into AppHost state. That is what makes the two triggers one mechanism: destroy the
data and the marker goes with it, so the next start is a first-time setup again.
builder.AddPostgresDocumentStore("orders") .WithSeeder(Seed); // DocumentStoreSeedMode.FirstTimeOnly (default)
// AppHost start #1 -> marker absent -> seeds, writes marker// AppHost start #2 -> marker present -> skips// `docker volume rm` the Postgres volume, start #3 -> marker gone -> seeds againAsk for a rebuild on every start with DocumentStoreSeedMode.Recreate. The AppHost never issues DDL of
its own — it tells you which trigger fired via ctx.Recreate, and your callback (which holds a real
store) does the wipe:
builder.AddPostgresDocumentStore("orders") .WithSeeder( async (ctx, ct) => { var store = BuildStore(ctx.Provider, ctx.ConnectionString);
if (ctx.Recreate && store is IDocumentMaintenance m) // false on a first-time setup await m.ClearAll(ct);
await DocumentSeedRunner.RunAsync(store, MySeeders, cancellationToken: ct); }, DocumentStoreSeedMode.Recreate );The marker is written only after the callback succeeds, so a seeder that throws is retried on the next start rather than being silently skipped. Markers are keyed by store name, so several stores can share one database and each still gets its own first-time setup.
Layer onto a DB resource you already declared
Section titled “Layer onto a DB resource you already declared”If you already model the database, wrap it with AsDocumentStore (the provider is auto-detected from the
resource, or pass it explicitly):
var pg = builder.AddPostgres("orders-server").AddDatabase("orders-db");var store = pg.AsDocumentStore("orders"); // name MUST differ from the DB resource nameCockroachDB and MariaDB have no dedicated AddXDocumentStore helper (Aspire has no first-party hosting
resource for them), and they can’t be auto-detected — model the container/resource yourself and pass the kind
explicitly:
var crdb = builder.AddContainer("orders-server", "cockroachdb/cockroach") /* … expose the SQL port, model the connection string … */;var store = crdb.AsDocumentStore("orders", DocumentProviderKind.CockroachDb);// MariaDB is DocumentProviderKind.MariaDb — note a plain AddPostgres/AddMySql resource auto-detects as// Postgres/MySql, so pass CockroachDb/MariaDb explicitly to select the wire-compatible variant.Consuming service — provider-agnostic
Section titled “Consuming service — provider-agnostic”// Api Program.csbuilder.AddDocumentStore("orders", configureOptions: o => o.ConfigureDocument<Order>(cfg => cfg.Table = cfg.TypeName));The store is registered keyed by name, so resolve it with [FromKeyedServices]:
public class OrdersService([FromKeyedServices("orders")] IDocumentStore store){ public Task<Order?> Get(string id) => store.Get<Order>(id);}AddDocumentStore reads the connection string (ConnectionStrings:orders) and the provider discriminator
(Shiny:DocumentDb:orders:Provider) the AppHost injects, selects the matching IDatabaseProvider, and —
unless disabled — registers a health check and wires the Shiny.DocumentDb meter + ActivitySource into
OpenTelemetry so query metrics and trace spans land in the Aspire dashboard.
Settings
Section titled “Settings”builder.AddDocumentStore("orders", settings =>{ settings.DisableHealthChecks = true; settings.DisableTracing = false; settings.DisableMetrics = false; // settings.Provider / settings.ConnectionString override what the AppHost injected});| Setting | Effect |
|---|---|
ConnectionString |
Overrides the injected connection string |
Provider |
Overrides the injected DocumentProviderKind |
DisableHealthChecks |
Skips the SELECT 1 health probe registration |
DisableTracing |
Skips wiring the Shiny.DocumentDb ActivitySource |
DisableMetrics |
Skips wiring the Shiny.DocumentDb meter |
MultiTenant |
Registers a shared-table multi-tenant store — adds a TenantId column, filters every query by the current tenant, and resolves it from a registered ITenantResolver |
PortableSpatial |
Forces the dependency-free spatial envelope tier — no PostGIS on PostgreSQL, no native geometry column or spatial index. Set it when you can’t install the backend’s spatial extension |
Container-aware configuration
Section titled “Container-aware configuration”configureOptions handles anything that’s a plain option (JSON contexts, type/table maps, query filters,
interceptor instances). When the configuration depends on other registered services, use
configureServiceOptions — it runs with the resolved IServiceProvider when the keyed store is first
created:
builder.AddDocumentStore( "orders", configureServiceOptions: (sp, o) => o.AddInterceptor(sp.GetRequiredService<AuditInterceptor>()));For the common shared-table multi-tenancy case, just flip the MultiTenant setting — it wires
TenantIdAccessor from a registered ITenantResolver for you:
builder.Services.AddSingleton<ITenantResolver, MyTenantResolver>();builder.AddDocumentStore("orders", settings => settings.MultiTenant = true);Typed DocumentContext on an Aspire resource
Section titled “Typed DocumentContext on an Aspire resource”If you use the source-generated typed DocumentContext (EF-style
DocumentContext + DocumentSet<T>), point its store at an Aspire-provisioned resource with
AddDocumentContextProvider. It resolves the same injected connection string + provider discriminator,
wires the health check + OpenTelemetry (honoring the same settings), and returns the
Action<DocumentStoreOptions> you hand to the generated Add{Context} / Add{Context}Factory method:
// scoped (ASP.NET Core):builder.Services.AddOrdersContext(builder.AddDocumentContextProvider("orders"));
// factory (MAUI / Blazor / desktop / background):builder.Services.AddOrdersContextFactory(builder.AddDocumentContextProvider("orders"));The returned action runs inside the generated method’s configure delegate — after the context’s
ConfigureModel — so it just supplies the Aspire-resolved provider on top of your model mappings. It
takes the same optional configureSettings / configureOptions as AddDocumentStore:
builder.Services.AddOrdersContext(builder.AddDocumentContextProvider( "orders", configureSettings: s => s.DisableHealthChecks = true, configureOptions: o => o.UseReflectionFallback = false));Each context registers its store keyed by the context type, so calling AddDocumentContextProvider
once per context (each with its own Aspire name) is the multi-store story — multiple contexts, each
backed by a different Aspire resource, coexist without shadowing:
builder.Services.AddOrdersContext(builder.AddDocumentContextProvider("orders"));builder.Services.AddInvoicesContext(builder.AddDocumentContextProvider("invoices"));Admin UI as a resource
Section titled “Admin UI as a resource”Shiny.DocumentDb.Aspire.Hosting also models the Admin UI
(ghcr.io/shinyorg/shiny-docdb-myadmin) as a resource, so it comes up with the rest of your app and every
store you reference is already connected — no connection strings to paste in by hand:
var store = builder.AddPostgresDocumentStore("orders");
builder.AddDocumentDbAdmin(port: 8085) .WithReference(store) .WaitFor(store);WithReference is the same call a consuming service makes: the tool reads the ConnectionStrings:{name}
Shiny:DocumentDb:{name}:Providerpair described below. A connection string with no matching provider key is ignored, so a Redis or blob reference in the same AppHost doesn’t turn into a junk connection. Referenced stores show up under a from host badge and can’t be edited or deleted from the UI — they’re declared in the AppHost, so that’s where they change.
Add WithDataVolume() to keep saved connections and queries across runs, and
WithHostPath(hostPath, containerPath) to make a file-backed store reachable at all. The image tag
defaults to the hosting package’s own version, so an integration upgrade brings the matching UI with it.
The full builder surface is covered in Admin UI → Aspire AppHost.
Or the terminal one, without a container
Section titled “Or the terminal one, without a container”On Aspire 13.5+ the terminal front end can be modelled the same way — as a process rather than a container, attached to from the CLI:
#pragma warning disable ASPIRETERMINAL001builder.AddDocumentDbAdminTerminal() .WithReference(store) .WithStartupProfile(store);aspire config set features.terminalCommandsEnabled trueaspire terminal attach documentdb-terminalLocally that replaces the container outright, and it is the only one of the two that can open a file
on your machine without a bind mount. It does not deploy, though: terminal sessions are a dev-loop
feature, so the resource stays out of the manifest and anything you publish still wants
AddDocumentDbAdmin. Full surface in
Terminal UI → Aspire AppHost.
Configure the assistant from the AppHost
Section titled “Configure the assistant from the AppHost”.WithAi(...) points the admin tool’s assistant at a chat backend, so a
developer who opens the tool gets a working assistant without pasting their own key into a database
browser. It works the same on the container and the terminal resource — both are the same tool over the
same configuration.
builder.AddDocumentDbAdmin() .WithReference(store) .WithAi(AdminAiProvider.Anthropic, "claude-sonnet-4-5-20250929", builder.AddParameter("anthropic-key", secret: true)) .WithAiWrites(insert: true) // off unless you ask; delete stays off here .WithAiFor(otherStore, AdminAiProvider.OpenAI, "gpt-4o"); // one store, bigger modelConfiguring it here makes it read-only there: the tool treats a host-supplied assistant exactly like a
host-supplied connection, so the settings page shows the values and refuses to change them. Azure and
OpenAI-compatible need endpoint:, and the AppHost throws if you leave it out rather than letting the tool
come up assistant-less.
How the pieces connect
Section titled “How the pieces connect”- The hosting resource implements
IResourceWithConnectionStringover its backing DB and publishes a provider discriminator to consumers viaWithReference: envShiny__DocumentDb__<name>__Provider/ configShiny:DocumentDb:<name>:Provider, value = theDocumentProviderKindname. - The client reads both, maps the kind to a provider (
new PostgreSqlDatabaseProvider(conn)etc.), and registers the keyed store with the standard (core) instrumentation decorator. WithSeedergates a callback on the backing resource’s ready event — the same pattern the Shiny Aspire Orleans integration uses for its database setup — and decides whether to run it by reading a__shiny_documentdb_aspire_seedmarker row kept inside the backing database.
This means the provider choice and seed strategy live in the AppHost, and the consuming code is a single
provider-agnostic AddDocumentStore("name") that works regardless of which backend the AppHost picked.
Orleans on DocumentDb
Section titled “Orleans on DocumentDb”If you run Orleans persistence on DocumentDb, Shiny.DocumentDb.Aspire.Orleans
bridges the two: a silo backs all its Orleans system stores with the same Aspire-provisioned, keyed
store in one call. Register the store on the host builder, then point Orleans at it by name:
// Silo Program.csbuilder.AddDocumentStore("orleans"); // Shiny.DocumentDb.Aspire.Client — keyed store + health + telemetry
builder.UseOrleans(silo => silo .UseAspireDocumentDb("orleans")); // grain storage + reminders + clustering + grain directoryUseAspireDocumentDb wires each provider’s StoreFactory to resolve the keyed IDocumentStore, so
Orleans persistence shares the one Aspire-managed store (and its connection, health check, and telemetry).
Select a subset of features and override the provider/directory names if needed:
silo.UseAspireDocumentDb( "orleans", DocumentDbOrleansFeatures.GrainStorage | DocumentDbOrleansFeatures.Reminders, grainStorageName: "Default");On the AppHost, the silo project just references the store like any other consumer:
var store = builder.AddPostgresDocumentStore("orleans");builder.AddProject<Projects.Silo>("silo").WithReference(store);Because DocumentDb is schema-free, there are no setup scripts — the membership/storage tables are created on demand. (Clustering needs a backend with multi-document transactions — relational or MongoDB on a replica set.)
Streams
Section titled “Streams”Orleans persistent streams run on the same Aspire-provisioned store, but
they are opt-in: DocumentDbOrleansFeatures.Streams is deliberately not part of All.
silo.UseAspireDocumentDb( "orleans", DocumentDbOrleansFeatures.All | DocumentDbOrleansFeatures.Streams, streamProviderName: "Default", configureStreams: o => { o.TotalQueueCount = 8; o.Retention = TimeSpan.FromHours(1); });The reason it is not in All: streams need a backend with row-level pessimistic locking (PostgreSQL,
SQL Server, MySQL, MariaDB, Oracle, CockroachDB) and the silo refuses to start without it. Folding that
into All would turn a working SQLite or DuckDB Aspire app into one that will not boot, purely because it
took a package update.
configureStreams is for tuning only — the store always comes from Aspire, so setting DatabaseProvider
or StoreFactory in that callback is overwritten rather than honoured.


