Architecture Decision Record
ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)
Status
Accepted. The framework plumbing is complete, covered by unit and integration tests
(DataSourceResolverTests, CrossDataSourceDegradeConventionTests, EntityTypeConfigurationTests,
CosmosConfigurationPortabilityTests, MultiSourceSqliteIntegrationTests, and others), and shipped to
production (the engine-agnostic plumbing released in Common v1.79.0; see FACTS.md for the current
framework version). No production entity routes to a non-SQL-Server engine today. An end-to-end
trial (ADC's Conference Session to Cosmos DB and Room to SQLite, with its child entities) was built
and tested locally, then deliberately reverted to all-SQL-Server while every framework extension point was kept.
Moving an aggregate to another engine later is a config-base-class change plus connection strings (and
one AppHost helper line), not a rewrite. This ADR records the decision and the extension point, because the
machinery is load-bearing and already in production. Revised 2026-08-29 (v1.170.0): a request for an
engine the host configures nowhere is served from the engine it does configure, so the
"engines never collapse into each other" rule in Decision item 4 now has one bounded exception. See
the Revision at the end.
Context
ADR-006 (database-per-service) splits storage along the Name axis: several physically separate databases, all on the same engine (SQL Server), one per service. A second, orthogonal axis is the storage Engine itself. Not every aggregate fits a relational store equally well:
- High-volume public read models (for example the conference's published-session surface) suit a document store with cheap horizontal reads.
- Transactional modules (Identity, Sales) want relational integrity and JOINs.
- Small, self-contained, or edge/offline data suits an embedded file database.
Putting every aggregate on SQL Server is a default, not a decision. We wanted the engine to be a per-aggregate choice driven by access pattern, without that choice leaking into domain or application code, and without rewriting an entity to move it between engines.
The DataSourceKey(Engine, Name) pair already present for ADR-006 carries an Engine component, so
routing by engine was a natural extension of the same resolver, registry, and context factory rather
than a separate subsystem.
Decision
Support three storage engines behind one entity model and one set of repository abstractions, selected per entity configuration.
DataSourceengine enum:SQLServer(full relational JOINs),CosmosDB(document store, no cross-container JOINs),Sqlite(JOINs within one file).DataSourceKey(Engine, Name)identifies a physical source: the Name axis is ADR-006, the Engine axis is this ADR.- Engine is a one-line declaration on the entity's configuration. A configuration derives from an
engine shim base (
EntityTypeConfigurationSQLServer/EntityTypeConfigurationCosmos/EntityTypeConfigurationSqlite), or annotates[UseDataSource(DataSource.X)]directly. The engine-awareEntityTypeConfiguration<TEntity, TId>reads that attribute and applies the matching mapping (table + schema for SQL Server, table for SQLite, container + partition key for Cosmos) plus the right key generation (server identity, vs. client-sideCosmosIntIdValueGenerator, vs. never). The configuration body is portable: moving an entity between engines is a single attribute change with no body edits. - One concrete context per engine, one instance per database.
SQLServerDbContext,SqliteDbContext, andCosmosDbContextare sealed contexts over the abstractApplicationDbContext. Combined with ADR-006's "one instance perDataSourceKey", a host materializes one context instance per physical (engine, name) source. - Configuration drives routing.
DataSourceResolverbuilds a per-engine logical-to-physical map from the engine-specific connection strings (SQLServerConnectionString/CosmosConnectionString/SqliteConnectionString, plusCosmosDatabaseNameand a per-source migrations assembly for each relational engine,SQLServerMigrationsAssemblyandSqliteMigrationsAssembly), read from either configuration shape: the top-levelConnectionStringssection, or a named entry underDataSources. Either shape supplies an engine'sDefaultsource on its own. The top-level value is the first answer; where it names nothing for that engine and the named entries declare exactly one distinct database on it, that database is the host's single database and becomesDefault, which is what lets a host declare its databases only underDataSourcesand still route the framework-owned tables (outbox, inbox, scheduled jobs, audit trail) that resolve to theDefaultname (Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceResolver.cs:198-233). Several distinct databases with no top-level value leaveDefaultempty, since there is no single answer: a genuinely multi-database host names the one it wants shared by adding aDataSources:Defaultentry. Logical names with no entry for an engine collapse onto that engine'sDefault; engines never collapse into each other.EntityDataSourceRegistry(and theDataSourceServicefacade) eagerly map every entity to its physical source up front, so routing never depends on a model already being built. - Cross-engine relationships auto-degrade.
CrossDataSourceDegradeConventionremoves FK constraints and navigations whose ends live in different physical sources (which now includes different engines); scalar FK columns plus a compensating index survive. Runtime joins flow throughINavigationPopulator(ADR-002); cross-source consistency flows through the outbox (ADR-003). - Cosmos specifics. All of a module's entities share one container (so intra-module relationships
and the navigation populators work), the entity Id is the partition key, Ids are generated client-side
(
CosmosIntIdValueGenerator, since a document store has no server identity), and relational-only constructs (indexes) are stripped at model-build time. - The host surface reads the same two shapes. The Aspire AppHost helpers
With{SQLServer,Cosmos,Sqlite}DataSourceinject theDataSources__{logicalName}__*environment variables for the source they attach (Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:493,:522-523,:547). The database health checks enumerate the top-level section and every named entry, deduplicated by connection string, so each physical database contributes exactly one readiness check and the entries that collapse onto one database contribute one between them (Source/Hosting/MMCA.Common.Aspire/Extensions.cs:541-573). The requirement that a host have a database at all is engine-agnostic:AddInfrastructureHealthChecks(requireDatabase)is satisfied by SQL Server or SQLite, declared in either shape (:308, the rule at:498-511).
Rationale
- Right store per access pattern, as a configuration decision. The engine becomes an attribute on a configuration class, not a rewrite. The same domain entity, application handler, and repository code run unchanged whichever engine backs the aggregate.
- One mental model, one set of extension points. Polyglot persistence reuses the exact resolver, registry, context-factory, and degrade-convention machinery that database-per-service already needed, so there is no parallel data layer to maintain.
- Portability lowers the cost of being wrong. Because the configuration body is engine-agnostic, an aggregate that turns out to be a poor fit for its engine can be moved with an attribute change plus a data migration, not a code rewrite.
Trade-offs
- No cross-engine JOINs, FKs, or transactions. This is the ADR-006 cost made sharper: across engines
it is a hard limit, not a deployment choice. A query spanning engines (for example a public-session
read that needs published-event Ids from a relational source) must be split into per-engine steps
rather than one LINQ query, and consistency across engines is eventual via the outbox. The
CrossSourceSpecificationhelper makes that split engine-portable (resolve principal keys, then filter byFK IN (keys)with no navigation), and theSpecificationsDoNotNavigateToOtherEntitiesfitness rule (thespecificationscategory in ADR-015) fails the build if a specification silently embeds a cross-engine navigation. - Each engine carries its own operational model. Separate EF provider, separate migration story (Cosmos has no relational migrations), separate backup/restore and cost profile. Adding an engine to a deployment is a real operational commitment, not just a connection string.
- Cosmos constraints leak into modeling. Container-per-module, Id-as-partition-key, and client-side Id generation are not the relational defaults; an aggregate moved to Cosmos must tolerate them.
- Latent today. The plumbing is complete, tested, and in production, but no production entity uses a
non-SQL engine, so the cross-engine paths (degrade across engines, the
CrossSourceSpecificationhelper, Cosmos client-side Id generation) are proven by tests and a reverted local trial rather than by production load until the first migration ships.
Related
ADR-006 (database-per-service: the Name axis this ADR's Engine axis is orthogonal to; they share
DataSourceKey), ADR-002 (navigation populators bridge the relationships the degrade convention strips
across sources), ADR-003 (the outbox is the cross-source, and now cross-engine, consistency mechanism).
Revision (2026-08-29): engine substitution for a single-engine host
Decision item 4 says engines never collapse into each other, and that held while every host in this
workspace configured SQL Server. A host that configures only SQLite (or only Cosmos) broke on it,
because the engine choice for the framework's own tables is not made by that host: Outbox:DataSource,
Scheduler:DataSource and AuditTrail:DataSource all default to SQLServer. Honoring that default
literally handed the scheduler, the outbox, the audit trail, the refresh-session store and
DbContextFactory's transaction coordination a physical source with an empty connection string, and
the first query each ran failed with "The ConnectionString property has not been initialized"
(Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceResolver.cs:107-115). The
host built, started, and reported healthy first.
The rule is now: a request naming an engine the host configures nowhere is served from the engine the host does configure.
- The resolver records which engines carry a connection string anywhere, top-level or on a named
DataSourcesentry, while it builds the per-engine maps (DataSourceResolver.cs:64-72, the predicate at:131-136; the named-entry half of that rule is whatDataSourceResolverTests.cs:350exercises). - The substitute is the first configured engine in a fixed preference order,
SQLServerthenSqlitethenCosmosDB(:26, selected at:74-77). Relational first because every table the framework owns is relational, and SQL Server ahead of SQLite so a host that configures SQL Server at all keeps exactly the routing it had. ResolveLogicalmaps the requested engine throughSubstituteUnconfiguredEnginebefore it looks anything up (:93, the substitution at:124-125), which returns the request unchanged whenever the host configures that engine.- A host that configures no database at all substitutes nothing (
:37-42): there is nothing to substitute to, and its startup validation is what fails, not its first query. - A substitute other than SQL Server is announced once at startup, naming the engine and the framework
tables it now serves (
:79-85, message at:468).
Nothing moves for a host that configures the requested engine, so a SQL-Server-only host and a
genuine polyglot host that configures two engines resolve exactly as this record describes; only a
request that could not have been served at all is redirected (:116-120). The pinned tests cover both
directions (Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/DataSourceResolverTests.cs).
The companion change is that startup validation stopped assuming SQL Server. A [Required] annotation
on SQLServerConnectionString encoded "SQL Server is the only engine a host can boot on" and failed a
SQLite-only host whose every entity resolved to a configured database. It is replaced by
ConnectionStringSettingsValidator
(Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/ConnectionStringSettingsValidator.cs:30), registered
with ValidateOnStart, which accepts a connection string for any supported engine, either
top-level or on a named DataSources entry (:50, :56-71). The rule is not weakened for the hosts
that do run on SQL Server: a host with no connection string anywhere still fails to start, with a
message naming both configuration shapes (:38-43), because silently booting one trades a clear
startup failure for a failure on the first query.