Architecture Decision Record
ADR-130: Per-Engine Data Source Strategy (Engine Facts Instead of Branching on the Enum)
Status
Accepted (2026-10-01). Targeted at MMCA.Common v1.218.0, which is unreleased at the time of writing (the CHANGELOG carries no entry for it yet). Refines ADR-018 (engine as a routing decision) and ADR-113 (the fourth engine) without changing ADR-006 (one sealed context class per engine, one instance per database).
Context
MMCA.Common persists through four engines named by one shipped public enum, DataSource
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IDataSourceService.cs:6):
CosmosDB (:9), Sqlite (:12), SQLServer (:15) and PostgreSQL (:22), the last appended
rather than inserted because the three before it are public API with fixed ordinals (:17-21).
Every place the framework behaved differently per engine named an enum value or sniffed the EF
provider name, so the knowledge of what one engine can do was spread over the resolver, context
creation, model building, conventions, the soft-delete filter, the audit row-version stamp, the read
repository, transactions, migration targets and the startup initializer. Adding PostgreSQL under
ADR-113 meant finding every one of those branches by hand, and nothing in the compiler points at a
branch that was missed. Two engine facts also lived in odd places: whether a context
could hold the outbox was a separate ApplicationDbContext.SupportsOutbox override, and the SQL
Server identity-insert path sat inside the context factory under an engine-specific name,
RequestIdentityInsert. SQLite also quoted identifiers two ways: its soft-delete filter used double
quotes while its filtered-index predicates used SQL Server brackets. The prior shape is described in
the two MMCA.Common commits that replaced it, aa8297fb (the contract) and 23eff3a0 (the routing).
Decision
One internal strategy object per engine answers every engine question, looked up through a static registry keyed by the shipped enum. Call sites read engine facts; they no longer name an engine.
The contract.
IDataSourceEngine(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/IDataSourceEngine.cs:18) groups its members in three kinds (:14-15): DESCRIPTIVE facts (substitution priority, entity configuration interface, migrations setting name, connection identity, and the connection-string and migrations-assembly readers,:29-69), oneCapabilitiesvalue (:74), and BEHAVIOUR hooks where the engine must act rather than answer (context creation, key and table mapping, INCLUDE columns, column quoting, the soft-delete filter and the explicit-key dialect,:83-119). The enum value stays the engine's identity (:20-21).The registry is static.
DataSourceEngines(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/DataSourceEngines.cs:19) holds the four engines in one array (:22-28), indexes them in a frozen dictionary (:30-31), and exposesFor(DataSource), which throwsInvalidOperationExceptionfor an unregistered value (:40-43), andAll(:34). Its remarks record why it is not a DI service (:9-12) and that a fifth engine is one class plus one line in the array (:15-16).The enum and its ordinals stay.
DataSourceis unchanged; the registry is keyed by it (DataSourceEngines.cs:30-31), so configuration,[UseDataSource]attributes and anything that persisted or serialized an ordinal keep working.Capabilities are only the facts that differ.
DataSourceEngineCapabilities(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/DataSourceEngineCapabilities.cs:23-28) has five members, and its remarks state the rule (:5-7): anything that splits the engines exactly along the relational line is oneIsRelationalflag, which covers same-source.Include(), transactions, raw SQL, indexes, foreign-key delete behavior,Any(predicate)translation and the framework's own tables (:15-19). The other four areMigrations(a three-valueMigrationPolicy,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/MigrationPolicy.cs:8-20),ConnectionStringRequired,NullsSortFirstAscending, andRowVersion(a three-valueRowVersionStrategy,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/RowVersionStrategy.cs:8-17). The four engines answer:Engine Substitution priority Migrations Connection string required IsRelational Nulls first ascending RowVersion Explicit-key dialect Column quoting SQL Server 0 Always yes yes yes StoreGenerated itself [col]PostgreSQL 1 WhenAssemblyConfigured no yes no ClientStamped none "col"SQLite 2 WhenAssemblyConfigured no yes yes ClientStamped none "col"Cosmos DB 3 Never no no yes None none [col](unused)Sources:
SQLServerDataSourceEngine.cs:29,:42-46,:49,:114;PostgreSQLDataSourceEngine.cs:27,:40-44,:47,:113;SqliteDataSourceEngine.cs:26,:39-43,:46,:112;CosmosDataSourceEngine.cs:28,:42-46,:49,:114(all underMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/). Cosmos builds no soft-delete filter (CosmosDataSourceEngine.cs:117).Call sites read the engine. A context exposes its engine as
ApplicationDbContext.Engine(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:61). The resolver orders substitution bySubstitutionPriority(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceResolver.cs:36) and reads migration policy and connection strings through the engine (:393,:450,:470,:500,:503);PhysicalDataSource.UsesMigrationsswitches onMigrations(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:42); the row-version mapping and stamp readRowVersion(ApplicationDbContext.cs:591,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:59); include support readsIsRelational(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceService.cs:32); context creation, key mapping and the soft-delete SQL call the behaviour hooks (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:32,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:77,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:35,:70). The one policy constant that still names an engine is the resolver's framework default,SQLServer(DataSourceResolver.cs:23).Outbox support is the engine's relational flag.
ApplicationDbContext.SupportsOutboxis gone; the outbox routing decision readscontext.Engine.Capabilities.IsRelational(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/InProcessEventBus.cs:81,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerEventBus.cs:70,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:236).Explicit-key insert is engine-neutral at the API.
IUnitOfWork.RequestIdentityInsertis renamedRequestExplicitKeyInsert, with no alias (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:45, documented at:38-44), and so areIDbContextFactory(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IDbContextFactory.cs:40) andDbContextFactory(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:321). The engine-specific half isIExplicitKeyInsertDialect(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/IExplicitKeyInsertDialect.cs:13), which finds the per-table groups (:21, grouped asExplicitKeyInsertGroup,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/ExplicitKeyInsertGroup.cs:12) and builds the toggle statement (:28); the factory keeps the engine-neutral rounds (:5-11) and uses the dialect only when the engine has one (DbContextFactory.cs:292). SQL Server is its own dialect (SQLServerDataSourceEngine.cs:19,:49) and emitsSET IDENTITY_INSERT [schema].[table] ONorOFF(:163); the other three returnnull, so the save runs unchanged.Raw SQL is registered only where it can run.
AddRawSqlQueryExecutor(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:400, called at:116) resolves the host's framework default engine and registersIRawSqlQueryExecutoronly when that engine is relational (:409-411). A Cosmos-default host gets no registration, so a service that injects the executor fails at container validation instead of on its first statement (:391-396). Both branches are tested (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/DependencyInjectionInfrastructureTests.cs:273,:294).SQLite quotes with double quotes everywhere.
SqliteDataSourceEngine.QuoteColumnreturns"col"(SqliteDataSourceEngine.cs:111-112), the same form as its soft-delete filter (:115), so filtered-index predicates on SQLite (the outbox, internal-command and push-notificationDedupKeyfilters) change from[Column]to"Column".Settings keep their per-engine properties.
ConnectionStringSettings(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/ConnectionStringSettings.cs:12) andDataSourceEntrySettingsstill carry one named property per engine (for exampleConnectionStringSettings.cs:18,:28,:40,:43;MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceEntrySettings.cs:22,:28,:38,:56), and each engine reads its own (SQLServerDataSourceEngine.cs:52-70). A fifth engine adds one property per settings class.
A source-scanning fitness test keeps new branches out:
MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/DataSourceBranchingFitnessTests.cs:71
fails on any DataSource.<Engine> reference or engine-context type check outside a file-level
allow-list of the reviewed remaining references (engine classes, sealed contexts naming themselves,
settings defaults, the design-time helper, the configuration shim bases, IndexBuilderExtensions'
default parameter, framework-default engine requests and FrameworkDefaultEngine), each entry
carrying its reason, and :83 fails on an allow-list entry that no longer has a hit, so the list
cannot go stale. Its failure message names this record.
Rationale
- One place per engine. An engine's behavior is now one class, and a fifth engine is one class
plus one registry line (
DataSourceEngines.cs:15-16); a missed branch can no longer hide in a call site, because call sites no longer hold engine branches. - Static because the consumers have no container. Model-building conventions,
EntityTypeConfiguration.ApplyEngineConventions,SoftDeleteFilterSqlandIndexBuilderExtensionsrun where no service provider is reachable (DataSourceEngines.cs:9-11), and the engines are stateless facts about a closed enum only Common extends (:11-12), so DI would add a lookup path that half the callers cannot use and no extensibility. - Fewer, truer capabilities. A flag per relational feature would be seven booleans that always
move together; one
IsRelationalflag says what is actually true today (DataSourceEngineCapabilities.cs:14-20), and a capability gets its own member only when an engine genuinely answers differently (PostgreSQL's null ordering, SQL Server's required connection string, the three row-version strategies). - Fail at startup, not at first use. Registering the raw-SQL executor conditionally turns a
call-time
NotSupportedExceptionon Cosmos into a container-validation failure (DependencyInjection.cs:391-396), the same fail-fast stance as ADR-070. - One dialect per engine. SQLite accepts both quote forms, but a model that mixes them is two
dialects to read and to reason about; the filter and the index predicates now agree
(
SqliteDataSourceEngine.cs:111-115). - An API name that describes the request, not the engine.
RequestExplicitKeyInsertsays what the caller wants; whether an engine needs a toggle to honour it is the dialect's business (IExplicitKeyInsertDialect.cs:5-11).
Trade-offs
- Breaking for consumers. The
RequestIdentityInsertrename has no alias (IUnitOfWork.cs:45); a Cosmos-default host that injectsIRawSqlQueryExecutornow fails at startup (DependencyInjection.cs:409); and a SQLite consumer with migrations sees a model diff on the outbox, internal-command and push-notification filtered indexes and needs a migration. - Static means not replaceable. A consumer cannot substitute or add an engine; the registry is
internal (
DataSourceEngines.cs:19) and so is the contract (IDataSourceEngine.cs:18). That is the intended scope, since the enum is closed. IsRelationalis coarse by design. An engine that is relational but lacks one of the features it stands for (DataSourceEngineCapabilities.cs:15-19) would force the flag to split.- Settings stay engine-shaped. Each engine is a named property on each settings class
(
ConnectionStringSettings.cs:18-49), so a fifth engine touches every settings class and its validator, not just the engine. - The gate is textual.
DataSourceBranchingFitnessTestsmatches source text, so a branch written without naming an engine value or an engine context type (for example on a provider-name string) passes it; review still owns that case. - Cosmos carries an unused quoting answer. It returns brackets from
QuoteColumn(CosmosDataSourceEngine.cs:114) only because the interface requires one; it builds no filtered index (:117).
Alternatives rejected
- Engines registered in DI (weighed 2026-10-01). Engines as
IDataSourceEngineservices resolved from the container. Rejected because the conventions,EntityTypeConfigurationand the SQL builders run without a container (DataSourceEngines.cs:9-11), so they would need the static lookup anyway, leaving two lookup paths, and because the enum is closed so DI buys no extensibility (:11-12). Revisit if consumers are ever allowed to contribute an engine. - A dictionary-keyed per-engine settings model (rejected 2026-10-01). One
Enginesdictionary keyed by engine name in place of the named per-engine properties. Rejected because it breaks every consumer'sappsettingsand every Bicep app setting that names those keys, for no current need: four engines fit four properties. Revisit if the engine count grows enough that per-property settings become the bottleneck, and then in a release that ships the configuration migration with it. - Keep SQLite's mixed quoting (weighed 2026-10-01). Leaving brackets in SQLite filtered-index predicates would have kept existing SQLite snapshots stable with no migration. Rejected by the owner in favor of one dialect per engine; the cost is the one-time model diff above.
- A capability flag per relational feature (weighed 2026-10-01). Separate booleans for include,
transactions, raw SQL, indexes and the rest. Rejected because every engine answers them identically
along the relational line (
DataSourceEngineCapabilities.cs:5-7). Revisit when an engine splits them. - Keep
SupportsOutboxas its own override. Rejected because the outbox needs exactly the relational tablesIsRelationalalready describes (DataSourceEngineCapabilities.cs:18-19); a second answer to the same question can disagree with the first.
Consequences
- Consumer upgrade. Rename
RequestIdentityInsertcalls toRequestExplicitKeyInsert; on a Cosmos-default host, remove anyIRawSqlQueryExecutordependency; on SQLite with migrations, add a migration for the re-quoted filtered indexes. - Adding an engine. Append to
DataSource(never insert,IDataSourceService.cs:17-21), write oneIDataSourceEngine, add it toDataSourceEngines.Registered(DataSourceEngines.cs:22-28), and add one property per settings class. - What to watch. Allow-list growth in
DataSourceBranchingFitnessTests: a new entry is a new engine-specific site outside the strategy and needs the same justification as the existing ones.
Related
ADR-006 (one sealed context per engine),
ADR-018 (engines behind one model and the resolver),
ADR-113 (the fourth engine),
ADR-030 (the startup migrator that reads Migrations),
ADR-035 (the row-version token RowVersion maps),
ADR-095 (the soft-delete filtered indexes whose SQLite quoting
changed), ADR-125 (the raw-SQL executor now registered
conditionally). Framework version and package figures live in MMCA.Common/FACTS.md.