↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Onboarding guide

7. Persistence & EF Core

What this group covers. This is the framework's data-access engine: everything between a domain aggregate and a row in a database. It is the single largest group in the guide because it carries a lot of load. One abstract ApplicationDbContext base with a sealed subclass per engine (SQLServerDbContext, PostgreSQLDbContext, CosmosDbContext, SqliteDbContext) and a stateless engine registry (IDataSourceEngine, DataSourceEngines) that answers every engine-specific question as data; four EF Core save interceptors that turn a plain SaveChangesAsync into audit stamping, tenant enforcement, transactional domain-event capture, and a field-level change trail; a small repository family behind an interface-segregated contract (IReadRepository<TEntity, TIdentifierType>, IWriteRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>) coordinated by a UnitOfWork, with the query-shaping helpers (SpecificationEvaluator, KeysetQueryBuilder) that turn a specification or a cursor into SQL; a durable job queue (InternalCommandMessage, InternalCommandScheduler, InternalCommandProcessor) that commits a unit of deferred work in the same transaction as the change that asked for it; a data-source routing layer that lets every entity resolve to its own physical database ("database per service") and every tenant optionally to its own copy of it; three model-finalizing conventions that keep that routing, its unique indexes and its delete behavior honest; an engine-portable entity-configuration hierarchy; and a supporting cast of value converters, value generators, an encryption converter, seeders, and design-time factories. The group also owns the settings objects the whole engine binds from configuration (PersistenceSettings, ConnectionStringSettings, DataSourcesSettings, TenancySettings, AuditTrailSettings, InternalCommandsSettings) plus the two startup validators that fail a boot on a misconfiguration, and the Application-layer storage and push contracts whose implementations are composed in Group 14. The whole thing is the [Rubric §8, Data Architecture] chapter of the codebase, and it leans hard on [Rubric §7, Microservices Readiness] and [Rubric §3, Clean Architecture].

One base context, one class per engine, one instance per database

ApplicationDbContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:46) is an abstract primary-constructor class over EF's DbContext. It holds the cross-cutting model configuration every engine shares. It applies a global soft-delete query filter to every non-owned IAuditableEntity using a runtime-built expression tree, registered as a named "SoftDelete" filter (ApplicationDbContext.cs:454-468, name at :475); it applies a second named "Tenant" filter to every non-owned ITenantEntity (ApplicationDbContext.cs:515-575); it configures the RowVersion optimistic-concurrency token by the engine's RowVersionStrategy, mapped as a SQL Server rowversion or as a plain token the audit interceptor stamps on PostgreSQL and SQLite (ApplicationDbContext.cs:577-608, strategy read at :592); and it maps the framework's own bookkeeping tables so every relational database carries its own (OutboxMessage (indexes at :695-714), InboxMessage (indexes at :730-735), InternalCommandMessage at :753, ScheduledJobEntry at :818, AuditTrailEntry at :855), each with the filtered indexes its poll path and its retention sweep need (IX_OutboxMessages_Pending at :695, IX_OutboxMessages_Processed at :702, the keyed-ordering index IX_OutboxMessages_Ordering at :714, IX_InboxMessages_MessageId at :730, IX_InboxMessages_ProcessedOn at :735, IX_InternalCommands_Pending at :780, IX_InternalCommands_Processed at :787, IX_InternalCommands_DeadLettered at :792, IX_ScheduledJobs_NextRunOn at :837, IX_AuditTrailEntries_Entity at :869, IX_AuditTrailEntries_ChangedOn at :875). Four of the seven framework tables are gated: the job table is mapped only when Scheduler:Enabled is set AND this context targets the Default source (jobs are host-scoped, :327-329), the trail table only when AuditTrail:Enabled is set, on every relational source (a trail row must commit with the change it describes, and a transaction does not span databases, :332), the refresh-session table only when RefreshSessions:Enabled is set AND this context targets the source that setting names (:336-339, applied at :893), and the permission-grant table only when the host called AddStoredPermissionGrants AND this context targets the source Authentication:PermissionGrants:DataSourceName names (:343, resolved at :915-918, applied at :937). That fourth gate has no configuration flag of its own: the DI call itself is the opt-in, expressed as the marker type PermissionGrantModelGate (.../Persistence/Auth/PermissionGrantModelGate.cs:19, reasoning at :6-12). A host that opted into none of them keeps the model it had before those features shipped, so none of those tables ever appears in its migrations. The outbox, the inbox and the internal-command queue are deliberately not gated: they are mapped into every relational source whether or not this host drains them, so flipping InternalCommands:Enabled is never a migration (:738-753).

Its SaveChangesAsync(userId, ...) overload (ApplicationDbContext.cs:194-208) is the one entry point handlers care about: it stashes the current user id in CurrentSaveUserId so the audit interceptor can read it, delegates to base, then clears it in a finally so a later internal save cannot silently reuse the previous caller's identity (:197, :206). The base also overrides both SaveChanges overloads purely to run change detection once per save and suppress it for the rest, through the DetectChangesOnce helper and its DetectChangesScope disposable (:212-218, :245-249, helper at :274): each interceptor's ChangeTracker.Entries<T>() call would otherwise trigger a full DetectChanges, so a save paid three snapshot comparisons where one suffices, and the previous auto-detect setting is restored when the scope is disposed (:274).

The design decision that shapes this whole group is stated in the base's own doc comment: one context class per engine, one instance per physical data source (ApplicationDbContext.cs:34-40). The same SQLServerDbContext class (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:15) is instantiated once per SQL Server database, each instance carrying a different PhysicalDataSource (connection string, per-engine migrations assembly, Cosmos database name). To keep EF from silently reusing the first-built model for every database, DataSourceModelCacheKeyFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/DataSourceModelCacheKeyFactory.cs:16) keys EF's model cache by context type plus physical source name plus the design-time flag (:19-22), and is installed by the base in OnConfiguring (ApplicationDbContext.cs:350). This is deliberately not a per-module context split: one sealed context per engine over the abstract base is ADR-006's ruling. Each instance also exposes the engine it targets as an internal Engine property resolved through DataSourceEngines (ApplicationDbContext.cs:61), which is how the base, the interceptors and the factory ask an engine question without naming an engine. SQLServerDbContext adds the provider-specific touches: a per-environment command timeout read from PersistenceSettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/PersistenceSettings.cs:10, bound from the Persistence section at :13, range-checked between 1 and 600 seconds and defaulting to the same 30 the framework applied implicitly before the section existed, :21-22) rather than ADO.NET's silent 30-second default (SQLServerDbContext.cs:55, with the settings object resolved once into a field at :36-37 using GetService so the design-time provider, which registers no options at all, cannot be made to throw), transient-fault retry (EnableRetryOnFailure with 5 attempts and a 10-second cap, :63-66), and a suppressed PendingModelChangesWarning (:79) so an extracted service that registers only its own module's entity configurations starts cleanly against a migration snapshot that captures every module's tables. That warning suppression is a direct [Rubric §7, Microservices Readiness] decision, and the source comment states the trade-off plainly: monolith hosts lose the "you forgot a migration" safety net, so CI is expected to run dotnet ef migrations has-pending-model-changes as a separate gate (:68-78). The retry comment also carries a rule the rest of the group depends on: with retry-on-failure enabled a manual BeginTransactionAsync must run inside Database.CreateExecutionStrategy().ExecuteAsync (:60-62). SqliteDbContext (.../DbContexts/SqliteDbContext.cs:12) is the same shape with UseSqlite and its own migrations assembly (:24-33), and CosmosDbContext (.../DbContexts/CosmosDbContext.cs:15) deliberately does not call base.OnModelCreating, because the relational bookkeeping tables have no place in a document store; it re-applies only the filters it needs (:139-145). PostgreSQLDbContext (.../DbContexts/PostgreSQLDbContext.cs:23) is the fourth engine class (ADR-113), and its mapping conventions deliberately match SQLServerDbContext rather than PostgreSQL house style: PascalCase identifiers and the module schema, so an entity configuration body is portable between the two engines with no edits, and a consumer that wants snake_case adds a naming plugin in its own host (:15-21). It maps every DateTime to timestamp with time zone (:34) through UtcDateTimeConverter (.../Persistence/Conversions/UtcDateTimeConverter.cs:25), because Npgsql refuses to write a value whose Kind is not Utc and a consumer property, a JSON round-trip or a legacy row can arrive Unspecified; the process-wide Npgsql.EnableLegacyTimestampBehavior switch is rejected precisely because it changes the mapping for connections the framework does not own and stores timestamps without a zone (:5-24). One more base-context decision belongs here: SensitiveDataLoggingGate (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SensitiveDataLoggingGate.cs:24) decides whether EF may render parameter values into logs and exception messages, and the answer is an AND rather than a single switch: the host must both ask for it and report itself as Development (ApplicationDbContext.cs:345, :367-371). A null IHostEnvironment, which is what design-time tooling and a directly-constructed test context have, counts as not Development, so unknown fails closed (SensitiveDataLoggingGate.cs:10-22). That is [Rubric §11, Security] in one helper.

SaveChanges as an interceptor pipeline

The base context resolves its interceptors from DI in OnConfiguring (ApplicationDbContext.cs:290-322), and registration order is execution order. The audit interceptor runs first, the tenant interceptor between it and the domain-event interceptor (so the outbox rows describe an entity whose tenant is already final, :300-312), and the change-trail interceptor last, because it diffs the final values (:314-322). Two of the four are resolved with GetService rather than GetRequiredService: a directly-constructed test context or a host that never called AddAuditTrail must still build, and their absence has to read as "the feature is off" rather than fail every context construction.

AuditSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:38) runs on SavingChanges: it walks every tracked IAuditableEntity, stamps CreatedOn/By plus LastModifiedOn/By on Added (:79-83), and on Modified marks the two Created* properties unmodified before re-stamping LastModified* (:90-94), reading the timestamp from an injected TimeProvider and the user id from CurrentSaveUserId (falling back to default as the system-operation sentinel, :65-66). An owner whose only edit is to an owned value object (an OwnsOne address, an OwnsMoney price) is still Unchanged to EF, because the change is tracked on the owned entry, so the interceptor asks OwnedDependents.HaveChanges and stamps that owner as Modified (:71-75). That helper (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/OwnedDependents.cs:12, HaveChanges at :24-26) walks the owner's owned references and owned collection items for an entry being inserted, updated or deleted, and its remarks name the one blind spot: an optional owned reference set to null leaves no entry to read, so clearing one is not detected (:18-21). It also writes the soft-delete stamps, and it drives them off the transition of the IsDeleted flag rather than its value: DeletedOn/By are written when the flag goes false to true and cleared when it goes back (StampSoftDeleteTransition, doc at :125-129, with the original value read by WasDeleted at :122-123), so a later update to an already-deleted row keeps the stamps of the delete that produced it, exactly as CreatedOn/By survive every update. This is why the domain declares audit fields with private setters and never writes them: the interceptor sets them centrally through entry.Property(...).CurrentValue, bypassing setter visibility. On an engine whose RowVersionStrategy is ClientStamped (PostgreSQL, SQLite) it also writes a fresh RowVersion on every insert and update, so the UPDATE's WHERE clause detects a concurrent writer where no server-generated row version exists (:67, :88, :97, StampRowVersion at :113-119). That is the [Rubric §12, Performance & Scalability] payoff, one enforcement point instead of copy-paste in every handler.

DomainEventSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:54) is the producer end of the outbox, and it is the most subtle type in the group. On SavingChanges it snapshots each tracked IAggregateRoot and its pending IDomainEvents into an AggregateCapture record (:242-246, record at :445), then writes an OutboxMessage row for each event into the same context, so the events land in the database in the same transaction as the aggregate changes (:228-302). The routing split happens right there: an IIntegrationEvent gets a row but no in-process dispatch (its row stays unprocessed so the OutboxProcessor publishes it over IMessageBus), while a local event gets both a row and the fast in-process path (:274-283). On the async path that local row is inserted already leased: NewLocalLease builds a LocalLease record struct (:465) of now plus Outbox:LeaseSeconds under a fresh token (:315-316, the seconds read once into a field at :66), and ApplyLocalLease writes it onto the row's LockedUntil and LockToken (:305-312). No replica's poller can therefore deliver the event a second time while this process is still dispatching it, a crash leaves the row to be retried once the lease expires, and a failed dispatch hands it back early (:18-23). Only the async path leases, because only it dispatches in-process (:96-98). Before capturing, DiscardAbandonedCapture detaches the Added outbox rows left by a previous SavingChanges that never reached SavedChanges (:323-340), which is what stops an execution-strategy retry from writing a second row per event and publishing every integration event twice. The captured state, including the lease token its local rows were written under, is parked in a CapturedState record (:455-460) held in a ConditionalWeakTable keyed by context (:73), so it is cleaned up automatically when the context is disposed. A third weak table (:88) holds a per-context capture exclusion set: BeginCaptureExclusion / EndCaptureExclusion (:194, :210) let DbContextFactory name exactly the entries it hides from an explicit-key insert round, so an event is never serialized and cleared a round before the insert that justifies it. The exclusion is by instance rather than by entity state on purpose: a state-based filter would also drop events raised on an already-saved aggregate, which is how the identity module publishes its registration events (:187-191).

After the save, the post-save path DispatchAndFinalizeAsync (DomainEventSaveChangesInterceptor.cs:346-363, reached from SavedChangesAsync at :117-126) does one of two things. With no ambient transaction it flushes immediately through FlushStateAsync (:369-400): dispatch local events through IDomainEventDispatcher, remove exactly the captured events from their aggregates, mark the local outbox rows processed through OutboxFinalizer, and signal the outbox for integration events (:373-381). With an active transaction it removes the captured events (so a second save inside the same transaction cannot re-capture them) and parks a DeferredDispatch (:468) in a second weak table (:80, parked at :353-359); DbContextFactory then calls the static FlushDeferredAsync only after a successful commit (:154-170) and DropDeferred on rollback (:177). That is what keeps handler side effects from acting on state that could still roll back, and what keeps a retrying execution strategy from dispatching the same events once per attempt. Note the precision of the clearing: the interceptor calls RemoveDomainEvents(capture.Events) rather than clearing the aggregate wholesale (:424-434), so an event a handler raises on the same aggregate during in-process dispatch survives to a later capture instead of being wiped. If in-process dispatch throws, the interceptor logs a warning, releases the lease its local rows were inserted under through ReleaseLocalLeaseAsync (guarded by the save's lease token and run with CancellationToken.None, so a dispatch that failed by cancellation still hands its rows back, :402-422), and signals the outbox, so the processor retries from the persisted rows after the processing delay rather than after the full lease (:383-394). A failed release is itself only logged, and the rows then wait out the lease (:418-421, :439-440). The synchronous SavedChanges path cannot await a dispatcher at all, so it removes the captured events, signals the outbox, and leaves delivery entirely to it (:128-152). Two conditions take the everything-in-process branch instead: Cosmos DB has no relational outbox table, so the interceptor asks the context's engine whether it is relational (DataSourceEngineCapabilities.IsRelational, read at :141 and :257), which is false only for CosmosDbContext's engine; and a host can turn the outbox off outright, which the interceptor reads once from the message-bus options into _outboxEnabled (:64) and honors at the same branch (:257, :292-298). This split, atomic persistence plus best-effort immediate dispatch with a durable fallback, is the at-least-once contract of ADR-003; the consumer end lives in Group 04.

Deferred work, the internal-command queue

The same "write it in the transaction, run it afterwards" reasoning the outbox applies to events is applied to work by the internal-command queue (ADR-114). InternalCommandMessage (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandMessage.cs:21) is one unit of deferred work persisted to the InternalCommands table, and its doc comment states the kinship plainly: like OutboxMessage it is framework bookkeeping rather than domain state, so it is deliberately not an IAuditableEntity, carries no soft-delete flag (no global query filter applies to it), no audit stamps and no concurrency token; its concurrency control is an explicit claim lease (:9-19). InternalCommandScheduler (.../InternalCommands/InternalCommandScheduler.cs:36) writes that row on the same scoped IDbContextFactory the calling handler's repositories use, which is the whole atomicity story: inside an ITransactional command the factory has already begun a transaction on every context it hands out, so the row commits with the aggregate change or rolls back with it, and outside a transaction the row is saved immediately and the processor is signalled (:19-25, :118-125). An enrolled row cannot signal at enrollment, because nothing can read it until the commit, so the scheduler records the wake it owes in EnrolledCommandWake (.../InternalCommands/EnrolledCommandWake.cs:14, Defer called at InternalCommandScheduler.cs:113), a static class over a ConditionalWeakTable keyed weakly on the context, like the deferred domain-event dispatch (EnrolledCommandWake.cs:7-16). The unit of work that owns the transaction calls Release once every commit has succeeded, which signals each distinct processor wake exactly once however many rows or contexts enrolled (:24-49), and Drop on rollback or retry (:51-53), so a rolled-back row never wakes the processor and a context nobody commits leaves nothing behind. Each row also carries an InternalCommandOrigin (.../InternalCommands/InternalCommandOrigin.cs:16): the scheduling user, roles, tenant, correlation id and trace context, because without it a deferred command would run as an anonymous tenant-less system call, which is both an authorization hole (an IRequiresPermission command would be denied) and an audit hole (its writes would carry the system sentinel, :1-9). The snapshot is taken by the scoped InternalCommandOriginCapture (.../InternalCommands/InternalCommandOriginCapture.cs:21, Capture at :28-39), which the scheduler calls as it builds the row (InternalCommandScheduler.cs:40, :88). It is scoped like the request services it reads, because the snapshot must describe this request's caller, and it flattens roles through the same helper the outbox capture uses, so the two hops store one shape (InternalCommandOriginCapture.cs:13-17).

InternalCommandProcessor (.../InternalCommands/Processing/InternalCommandProcessor.cs:43) is the drain: a background service that claims due rows with a lease, executes each in a fresh DI scope, and records the outcome. Every relational physical source this host uses has its own queue table and each cycle drains them all, so a service only ever runs the work queued in its own databases, and the lease is what makes scale-out safe, since two replicas racing for a row issue the same conditional update and exactly one matches (:20-35). Execution is explicitly at-least-once: a replica that dies after its handler committed and before the row was stamped releases the row when the lease expires and the command runs again, which is the same idempotency contract the outbox already places on event handlers (:30-34). The batch claim is renewed row by row just before each command runs, in a statement guarded on the claim token, so a slow batch cannot outlive its lease and let another replica start a later row, and a row another replica has taken over is skipped rather than run twice (:197-208, RenewClaimAsync doc at :329-333). Execution itself goes through InternalCommandDispatcher (.../Processing/InternalCommandDispatcher.cs:21), and the load-bearing detail is that it resolves the closed ICommandHandler<TCommand, Result> interface: Scrutor's decorators are registered against the open generic, so what comes back is already wrapped in the feature gate, the authorization check, logging, cache invalidation, validation, the timeout budget and the transaction. A deferred execution is therefore the same execution a controller would have got, not a parallel path that re-implements any of it (:10-20). The rest of the processing namespace is the machinery that loop needs: IInternalCommandSignal and its InternalCommandSignal implementation (.../Processing/InternalCommandSignal.cs:10), a thin wrapper over the same WakeUpSignal the outbox signal wraps (:5-9, :12); InternalCommandCycleResult (.../Processing/InternalCommandCycleResult.cs:17), the record struct that lets a cycle report whether more due work is waiting and when the oldest not-yet-due row comes due, so the loop smart-waits to that instant instead of sleeping a full interval (:7-16); InternalCommandNameResolver (.../Processing/InternalCommandNameResolver.cs:19), the job-queue counterpart of EventNameResolver that stores a command under its InternalCommandNameAttribute name when it declares one and under its assembly-qualified name otherwise, so a rename or a namespace move does not orphan queued rows (:7-18); and InternalCommandMetrics (.../Processing/InternalCommandMetrics.cs:17), the one meter (MMCA.Common.InternalCommands, :20) carrying completions, dead letters, execution latency and observed backlog depth.

The loop mechanics themselves are not the processor's own code. They live in the Polling namespace, shared with the outbox drain in Group 04, so both table-draining services wait, drain and back off identically while every log, metric and activity name stays with the processor that owns it (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Polling/PollingLoop.cs:5-11). PollingLoop (PollingLoop.cs:12) is a static class with three pieces. RunAsync (:40-91) waits a 5-second StartupDelay (:18), returns early when the host owns no relational source, then runs cycles until shutdown: a cycle that reports more ready work re-polls at once, a failed cycle is reported and treated as one with nothing pending, and every other cycle waits on the wake-up signal for ComputeWaitTime (:104-122), which is the moment the earliest upcoming row becomes ready plus the processing delay, capped at the polling interval and floored at one second so an overdue row cannot hot-loop the processor (:21). DrainAllAsync (:135-171) drains every TenantDataSourceTarget in order and aggregates the results, and a source that throws is reported and contributes nothing, so one unreachable database cannot starve the others (:162-167). ComputeRetryBackoffSeconds (:183-197) is the exponential backoff for a failed row, base * 2^(attempts - 1) with the exponent clamped at 16, multiplied by a jitter factor in [0.8, 1.2] before the per-processor cap, so a batch that failed together in one dependency outage does not retry in lockstep, and a capped backoff stays exactly at the ceiling (:173-178, :188-196). The processor calls all three (InternalCommandProcessor.cs:76, :106, :127, :633). The wake-up it waits on is WakeUpSignal (.../Polling/WakeUpSignal.cs:16), a SemaphoreSlim created with a maximum count of one (:18) whose Signal swallows the SemaphoreFullException a second pending wake-up raises (:21-31). The cap is the point: each processor drains a whole batch per cycle, so one pending wake-up is all the information a burst of writes carries, and an uncapped semaphore made N writes cost N candidate-fetch cycles per relational source that found nothing (:3-15).

The administration namespace is the operational half. InternalCommandsSettings (.../InternalCommands/Administration/InternalCommandsSettings.cs:15) binds the InternalCommands section with a default for every property: Enabled defaults to true, the outbox's posture (:27), a batch of 50 (:31), 5 attempts before dead-lettering, where a Result.Failure counts exactly like a thrown exception because both mean the work did not happen (:39), a 2-second fallback poll that is only the backstop for a wake that never arrives (a row another process wrote, a commit path that released no signal), since even a transaction-scheduled row is woken by the unit of work once its commit succeeds (:41-50), a processing delay of 0 rather than the outbox's 5, because the queue has no in-process fast path to race (:52-59), and a 300-second claim lease that must sit comfortably above the longest expected handler duration and, being renewed before each row runs, bounds one handler rather than the whole batch (:61-70). A host that sets Enabled=false registers neither the processor nor the sweep, so InternalCommandsDisabledNoticeService (.../Administration/InternalCommandsDisabledNoticeService.cs:20) states that once at startup at Information: a web front end queueing work for a dedicated worker is a legitimate posture and the wrong thing to discover from an absent background service (:6-18). InternalCommandCleanupService (.../Administration/InternalCommandCleanupService.cs:44) purges completed rows past RetentionDays, because the table stores serialized command payloads that may contain personal data and the pending index every poll re-scans grows with it, and deletes dead-lettered rows on their own wider window, logging every such deletion at Warning because it destroys the only record that the work was ever asked for (:13-28). InternalCommandAdministration (.../Administration/InternalCommandAdministration.cs:37) is the operator surface over the same targets, visiting each in its own DI scope (a tenant target only routes correctly once ITenantContext is set for that scope) and expressing requeue and purge as one set-based statement per target, because an operator clearing a backlog touches thousands of rows and none of the values written depend on the row's current state (:15-26). Both background services and the administration type expand their target list per tenant that keeps its own copy of a source, for the same reason the audit-trail sweep does: that database has a queue table nothing else would drain.

The tenant boundary, read filter plus write guard

Multi-tenancy (ADR-073) is two independent halves that meet in this group. The read half is the named Tenant query filter the base context applies to every non-owned ITenantEntity (ApplicationDbContext.cs:515-574). Three details make it work: the filter body embeds the context instance as a constant typed as ApplicationDbContext, so EF rewrites it to the executing context and lifts CurrentTenantId into a SQL parameter, letting one compiled model serve every tenant (:502-507, :519-523); the predicate is CurrentTenantId == null || e.TenantId == CurrentTenantId, so a scope with no tenant (the outbox processor, the seeders, the retention jobs) sees every tenant's rows (:509-511, :567-571); and the column itself is declared required, 64 characters, non-Unicode, and indexed on relational engines, because every tenant-scoped read carries it as the leading predicate (:532-554, width constant at :484). That index follows the filter composition rather than the column: an entity that is also an IAuditableEntity gets (TenantId, IsDeleted), matching the AND-composed predicate every read of a soft-deletable tenant row actually carries, and a tenant-only entity keeps the single-column index (:537-553). The filter reads the value through EF.Property rather than a CLR member access, so an explicitly implemented interface member or a shadow property translates identically (:558-565). Because the two filters are named, EF composes them with AND, and a caller asking for soft-deleted rows drops exactly the SoftDelete filter while the tenant filter stays in force: the repository contract says so in as many words (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:16-19, :75-79), and the repository passes that one filter name explicitly (.../Repositories/EFReadRepository.cs:41).

The write half is TenantSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:36). It stamps the scope's tenant onto an insert that declares none (:116-119), refuses an insert that names a different one (:122-123), and on update or delete checks both the original and the current value, so touching another tenant's row and reassigning a row to another tenant are both rejected (:131-159, with the original reported in preference to the current one at :161-166). An untenanted insert from an untenanted scope is refused too, because silently writing a row no tenant can ever read is worse than failing the save (:107-110). Owned types are skipped on both sides: an owned value has no independent existence and its owner's tenant is already the row's tenant (:69-75). Failures surface as CrossTenantWriteException (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/CrossTenantWriteException.cs:24), which derives from InvalidOperationException so existing catch sites treat it like any other save-time invariant failure (:19-22). The deliberate asymmetry is documented in the interceptor's own remarks: a caller who bypasses the read filter with EF's parameterless IgnoreQueryFilters() can read across tenants, but still cannot write across them (:29-33). That is [Rubric §11, Security] and [Rubric §30, Compliance and Data Governance] in one type. The scope's tenant itself lives in TenantContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/TenantContext.cs:13), which is set-once-per-scope and idempotent for the same value, and throws rather than switching tenants mid-scope (:20-44).

What a host declares about tenancy is bound into TenancySettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:50) from the Tenancy section (:53), and the settings themselves carry the shape of the feature. Enabled gates resolution, not isolation, so the query filter and the save interceptor are always present and simply inert while no tenant is resolved, which is why a host that never opts in keeps exactly the behavior it had (:37-40, default false at :67). RequireTenant defaults to true, so once resolution is on a request that yields no tenant is rejected with 400 Bad Request rather than reading across every tenant (:91-96). Both collection properties express their defaults as "empty means the default", read through EffectiveResolutionOrder (claim, then header) and EffectiveExcludedPathPrefixes (/health, /alive, /.well-known), because the configuration binder ADDS to a pre-populated collection instead of replacing it, so a non-empty default would leave a host that configured its own order running the framework's entries too (:42-48, :56-61, :76-77, :106-107). TenantResolutionStrategy (:6) names the three sources: Claim is the trustworthy one (the token issuer signed it, so a caller cannot pick its own tenant), Header is for service-to-service calls behind a trusted gateway, and Host is defined but not implemented, present only so the configuration contract stays stable (:8-27). TenancySettingsValidator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:24) is what makes that honest: registered with ValidateOnStart, it fails the boot when a resolution order names Host (:54-65) and checks each tenant's overrides against the resolved physical sources when IDataSourceResolver is registered (:14-18, :39-42), on the reasoning that every failure here would otherwise surface as silent cross-tenant behavior, which is the exact bug class tenancy exists to prevent (:8-13). Declaring a tenant is only required for the database-per-tenant case: a TenantEntrySettings (TenancySettings.cs:121) entry holds per-source overrides keyed by physical source name, and each TenantDataSourceOverrideSettings (:138) carries connection information only, because a tenant database has the same schema as the shared one and the migrations assembly is deliberately not overridable (:124-137, :140-153). A source without an entry stays shared and is isolated by the query filter (:126-127).

Database-per-tenant is handled one layer up, in the factory, and background sweeps expand their work list through TenantDataSourceTargets.Expand (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:52-82), which emits the shared target for every source plus one extra TenantDataSourceTarget (:15) per tenant that overrides a source, because a tenant with its own database is invisible to the shared sweep (:36-40). The sweeps do not each assemble that list themselves: FrameworkTableTargets (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/FrameworkTableTargets.cs:32) is the one answer to "which databases hold the framework's own relational tables on this host", shared by the outbox and internal-command processors, both cleanup services, both operator surfaces and the audit-trail sweep (:14-19). Relational() returns every relational source backing a registered entity, deduplicated and expanded per tenant (:43-44), and its overload adds the source the caller's own settings name as the table's home when that engine is relational (:54-60). It is recomputed per call, so late-loading module assemblies are seen, and Cosmos sources are skipped because they host none of those tables (:21-22).

Recording what changed, the audit trail

AuditTrailSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:64) is the fourth interceptor (ADR-075). It records a field-level history for entities marked IAuditedEntity, writing AuditTrailEntry rows in the same transaction as the change they describe, on the outbox precedent that a trail committable without its data is worse than no trail (:20-24). A Modified save produces one row per property whose value actually changed; Added and Deleted produce a single summary row with a null PropertyName (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailEntry.cs:17-23, class at :23). An owner left Unchanged whose owned value object changed is recorded too, through the same OwnedDependents check the audit interceptor uses (AuditTrailSaveChangesInterceptor.cs:233-240), with the owned property named navigation.property (:394). Four things are worth knowing about it. It is opt-in twice over, once through AddAuditTrail (the interceptor is resolved with GetService) and once through AuditTrail:Enabled (which maps the table), and both are checked cheaply per save by asking the model whether the entity type exists at all (:35-37, model check at :184). Both switches read the same bound AuditTrailSettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSettings.cs:16, section name at :19), whose Enabled decides both whether changes are recorded and whether the AuditTrailEntries table is mapped at all, defaulting to false so adopting the framework never adds a table or a write per change to a host that did not ask for one (:10-15, :21-26), whose RetentionDays defaults to 90 and is inert unless the host also runs the scheduler (:28-38), and whose DataSource names the one engine the v1 read surface queries, defaulting to SQL Server because the table is relational (:40-46). Personal data never reaches the table: a property carrying PiiAttribute records PiiRedactor.RedactedToken on both sides, and the redaction happens at capture, not at read, so the trail cannot become a second copy of a data subject's personal data that erasure would have to chase (:40-43, applied at :324-325, ADR-005). The framework's own bookkeeping types are excluded by CLR type rather than by marker absence, which is what stops the trail from recording its own rows in an unbounded feedback loop (FrameworkEntityTypes at :115, checked at :230). And the correlation value it records is the ambient Activity trace id rather than a scoped correlation service, because a singleton interceptor holding a context built by the singleton physical factory cannot reach a scoped service without a lifetime bug; the doc comment says exactly that and names the accessor pattern tenancy introduced as the way to change it later (:46-55). The values every row of one save shares (user, instant, trace id, tenant) are gathered once into a CaptureContext record struct (:190-195, declared at :663), and a row describing an insert whose key the database has not assigned yet is parked as a PendingEntityKey (:672) until the store-generated key exists (parked at :205-217, read back after the save at :491). Every free-text value a row carries (entity type, key, property name, trace id, tenant) is cut to its column width through ColumnWidth.Truncate (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/ColumnWidth.cs:4, body at :10-11), an internal helper that truncates and preserves null (AuditTrailSaveChangesInterceptor.cs:194-195, :255, :323, :602); the internal-command processor and the scheduled-job runner reuse it for their error columns (InternalCommandProcessor.cs:549, :573). Two more types close the feature: AuditTrailReader (.../AuditTrail/AuditTrailReader.cs:35) serves paged history for one entity behind IAuditTrailReader and states its own v1 limitation, that it reads only the Default source's trail table (:17-24), and AuditTrailCleanupJob (.../AuditTrail/AuditTrailCleanupJob.cs:47) is the framework's own recurring IScheduledJob, purging rows past RetentionDays from every relational source nightly at 03:00 UTC in 1000-row batches that read ids first and delete them with ExecuteDelete (:57, :62, :66, :69-82, :152-166), expanding that source list through TenantDataSourceTargets so a tenant with its own database gets swept too (:78, per-tenant scope at :94-105). It only runs if the host also runs the scheduler, and a host that records the trail without one is fully supported: pruning is then the operator's job (:23-26).

Repositories, specifications, and the unit of work

Handlers do not touch a DbContext directly. They ask a UnitOfWork (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:20) for a repository. The repository contract is deliberately interface-segregated (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs, the contract ADR-055 states): a handler that only needs a lookup can depend on the narrow IEntityReader<TEntity, TIdentifierType> (IRepository.cs:21) or IEntityQuerier<TEntity, TIdentifierType> (:80); IReadRepository<TEntity, TIdentifierType> (:332) combines both plus four IQueryable surfaces (tracking, no-tracking, single-query, split-query, :336-345), IWriteRepository<TEntity, TIdentifierType> (:369) adds mutation, and IRepository<TEntity, TIdentifierType> (:497) is the union. That layering is the group's clearest [Rubric §1, SOLID] (interface-segregation) statement, and ReadRepositoryExtensions (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ReadRepositoryExtensions.cs:10) adds the GetByIdOrFailAsync convenience that turns a miss into a Result failure (:27-48). The concrete EFReadRepository<TEntity, TIdentifierType> (.../Repositories/EFReadRepository.cs:21) and EFRepository<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:24) wrap an EF DbSet; the read side keeps its GROUP BY projections in the private GroupedCount<TKey> and GroupedSum<TKey> records (EFReadRepository.cs:185, :190) so the aggregation happens in the database rather than in memory. Its lookup read (GetAllForLookupAsync, EFReadRepository.cs:223) follows the same rule for a non-string display property: a string property projects straight onto BaseLookup<TIdentifierType>, while any other type projects onto LookupRow<TId, TName> (EFReadRepository.cs:735), the id plus the raw name value in its own CLR type (:343-362), because EF can translate that projection for any mapped property but cannot translate a ToString() on a value object (:712-716). The rows are ordered and capped server-side at EntityQueryPipeline.MaxUnboundedResultLimit (1000, MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:23) and formatted to display strings only after materialization (:280-297). The compiled selectors are cached by the property's canonical name and capped at 512 entries (:30, :313-341), so the case permutations of one column name sent to an anonymous endpoint cannot mint unbounded cache entries (:305-311). The write side patches already-tracked entities in place through an O(1) Local.FindEntry lookup instead of re-attaching (EFRepository.cs:53-65) and seeds RowVersion original values for optimistic concurrency on both the aggregate and any child implementing IRowVersioned (:75-93, ADR-035). Two set-based escape hatches sit beside the tracked path: ExecuteDeleteAsync, which the interface itself documents as bypassing domain events, audit stamps, and soft-delete (IRepository.cs:455), and ExecuteUpdateAsync (:480), the contention-proof conditional update whose guard predicate lets the database arbitrate two racing callers with no rowversion retry loop. The latter is described through the persistence-agnostic IUpdatePropertySetter<TEntity> surface and replayed onto EF's setters builder by UpdatePropertySetterBuilder<TEntity> (.../Repositories/UpdatePropertySetterBuilder.cs:14), which is what keeps EF Core out of the Application layer, and because ExecuteUpdate bypasses the interceptor pipeline the repository stamps LastModifiedOn/By itself unless the caller assigned them (EFRepository.cs:141-155).

The read repository does not compose queries by hand. SpecificationEvaluator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/SpecificationEvaluator.cs:21) turns an ISpecification<TEntity, TIdentifierType> into an IQueryable: criteria always, then the includes, the OrderExpression chain, and the paging a QuerySpecification<TEntity, TIdentifierType> carries (:40-68), with the shape deliberately skipped for aggregate reads because joining includes to count rows costs a join per navigation (:30-34). Tracking and soft-delete scope are not its business: those choose the base queryable, which only the repository can do (:15-19). It also owns the one split-query heuristic in the framework, opting into AsSplitQuery as soon as any include targets a collection navigation (:93-101), and EFReadRepository.ApplyIncludes delegates to it so the string-include path and the specification path cannot drift (:78-79, EFReadRepository.cs:562-565). Cursor paging is the sibling helper: KeysetQueryBuilder (.../Repositories/KeysetQueryBuilder.cs:22) resolves the requested sort property or fails validation (:35), orders by (sortKey, Id) with the identifier tie-break that makes the order total (:50-51), and builds the composite seek predicate against the last row of the previous page (BuildSeekPredicate at :118), so GetPageByCursorAsync (IRepository.cs:318, implemented at EFReadRepository.cs:632) seeks straight to the boundary instead of counting past every skipped row. Exactly one sort key is supported, by design (KeysetQueryBuilder.cs:17-20). That is [Rubric §12, Performance and Scalability] expressed as a contract rather than as advice.

Two factories keep the wiring honest. RepositoryFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:15) builds a repository over a given context and conditionally wraps it in a MiniProfiler decorator (EFRepositoryDecorator<TEntity, TIdentifierType> or EFReadRepositoryDecorator<TEntity, TIdentifierType>) when UseMiniProfiler is on (:34-38, :58-62), adding timing without the base repository knowing, and it activates both through a cached compiled ObjectFactory rather than reflecting on every call (:70-84). DbContextFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:49) is the scoped coordinator: it caches one ApplicationDbContext per DataSourceKey so every repository in a scope shares one change tracker (:99-133), gives each new context live scope accessors rather than copied values (:115, doc at :135-141), and enlists a late-created context into an already-open transaction (:117-120). It is also the database-per-tenant routing point: when the scope's tenant overrides a source, the context is created against that tenant's connection string while keeping the original DataSourceKey, which is what lets one compiled model serve every tenant's database (ResolveTenantOverride, :163-169), and a cached routed context is refused to a second tenant rather than silently serving the first tenant's rows (:124-125, guard at :202-219), just as a shared context created before the tenant resolved is refused to a tenant that overrides that source (:127-129, :227-230). Its save loop runs up to MaxSavePasses (3, :64) passes over the cached contexts, because dispatching events in-process can materialize a context for a source nobody had touched yet (:279-297), and it closes with a hard assertion: any context still reporting ChangeTracker.HasChanges() when the unit of work returns throws rather than silently discarding those changes (:299-316).

Because there can be more than one physical source in play, ExecuteInTransactionAsync (:551) runs the operation under the first transactional context's execution strategy, opens a transaction per source, and commits them sequentially with no two-phase commit (TryCommit at :719); cross-source consistency is the outbox's job, and the doc comment is explicit that a commit failure on the second source leaves the first one committed (:540-549). The method is re-entrant: a nested call joins the ambient transaction instead of opening a second one, so only the outermost call may begin, commit, roll back, or flush (:555-563). A returned failed Result rolls back exactly like an exception (:615-622), which is what makes ADR-013's Result-over-exceptions rule safe for partial persistence; rollback also drops the deferred event dispatch (:617-620), the deferred flush runs only after every commit has succeeded (:636-643), and a retry resets the change tracker first so the aborted attempt's Added entities are not inserted twice (ResetForRetry at :836, called at :581). Before committing, the unit also refuses to discard work: anything still tracked but unsaved throws, except an internal-command row that was only enrolled on the context while the transaction was open, which is saved inside the transaction so it commits with the change that scheduled it (FlushEnrolledCommandsBeforeCommitAsync, :671-707, called at :625). Once every commit has succeeded it releases the processor wakes those enrolled rows owe through EnrolledCommandWake, before the deferred event flush so a throwing in-process handler cannot swallow them (:631-634), and DropDeferredWork forgets both the deferred dispatch and the owed wake on rollback or retry (:850-854). DbContextFactory further carries the engine-neutral half of the explicit-key insert (RequestExplicitKeyInsert at :322, SaveWithExplicitKeyInsertAsync at :330): when one is requested and the context's engine has an IExplicitKeyInsertDialect, the save is split into one ExplicitKeyInsertGroup round per table with the toggle on (:264-270, :293-294), for importing entities with explicit database-generated ids. The rounds run principals first: ExplicitKeyInsertRoundOrder (.../Factory/ExplicitKeyInsertRoundOrder.cs:17) derives the order from the EF model's foreign keys rather than from the order entities were added to the change tracker, because each round hides every other round's added rows, so EF's own per-command ordering cannot help across rounds and a dependent table written first would fail its foreign key (:6-15, applied at DbContextFactory.cs:340-344). Its Order is Kahn's algorithm over the table dependency graph: lowest ready index first so independent rounds keep their original order, a self-reference is no edge because EF orders rows within one round itself, and a cycle is broken deterministically at the lowest remaining index (ExplicitKeyInsertRoundOrder.cs:19-34, :70-99). The factory also pins one connection for the whole sequence, because the toggle is session state and EF would otherwise hand the connection back to the pool between commands (DbContextFactory.cs:349-359). It also runs the MigrateAsync / HasPendingMigrationsAsync sweeps (:784, :820) over every source whose resolved PhysicalDataSource.IsMigrationTarget holds: the source UsesMigrations under its engine's MigrationPolicy, and it is not an optional source left without a connection string (PhysicalDataSource.cs:42-60, target selection at DbContextFactory.cs:804-817).

UnitOfWork sits on top, resolving an entity's physical source through IDataSourceService, handing the matching context to the factory, and caching the resulting repository per closed generic interface type (UnitOfWork.cs:48-81). The physical creation itself runs through PhysicalDbContextFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:19), a singleton that hands construction to the key's engine through IDataSourceEngine.CreateDbContext (:32) and whose doc comment warns it must never be pooled, because each instance carries per-source constructor state that pooling would smear across databases (:13-17). The interfaces (IUnitOfWork, IDbContextFactory, IPhysicalDbContextFactory, IRepositoryFactory) keep the application layer talking to abstractions. Every member of that factory family has its own section below, including the type that exists only because of what the coordinator does, TransactionCommitAmbiguousException (.../Factory/TransactionCommitAmbiguousException.cs:22), which the commit path raises when the commit itself fails with an outcome nobody can vouch for, naming each physical source's outcome (committed, ambiguous, or rolled back) so the partial state is observable rather than inferred (:57-70, DbContextFactory.cs:719-744). That exception is thrown outside the execution strategy on purpose (DbContextFactory.cs:592-593), because the strategy walks an exception's whole inner chain to decide retriability and would otherwise re-run the operation on top of a possibly-durable commit.

Routing an entity to its database

The heart of ADR-006 is that every entity resolves to a DataSourceKey (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/DataSourceKey.cs:15), a (Engine, Name) record struct where the DataSource engine (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IDataSourceService.cs:6) is one of Cosmos DB, SQLite, SQL Server, or PostgreSQL, and Name is a physical database name defaulting to "Default" (DataSourceKey.cs:18-23). Two layers compute this. DataSourceResolver (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceResolver.cs:16), the IDataSourceResolver singleton (.../DataSources/IDataSourceResolver.cs:15), builds the logical-to-physical map once per engine from configuration and hands out the resolved PhysicalDataSource (BuildEngineMap at :195, ResolveLogical at :93, GetPhysical at :183). Named sources with no connection string, or whose connection identity equals the top-level one, collapse onto the Default source, so a host with no DataSources section behaves exactly like a single-database monolith (DataSourceResolver.cs:295-345), and sources sharing a connection identity collapse onto one canonical key named after their alphabetically-first member (:380-411, canonical name at :386). What the Default source is built from is itself resolved rather than assumed: the top-level ConnectionStrings section first, and when it names nothing for this engine while DataSources names exactly one database on it, that database becomes Default, which is what keeps the framework's own tables working in a host that declares its database only under DataSources (:219-285, captured in the private DefaultSeed record at :217). Identity is the connection string compared ordinally, with the database name appended on an engine whose ConnectionIdentityIncludesDatabaseName is set (Cosmos, because one account hosts many databases, :496). The resolver fails fast when two logical names collapsing to one database declare conflicting migrations assemblies (:464-480) and logs a warning when a separate source on an Always MigrationPolicy engine (SQL Server) falls back to the Default migrations assembly (:401, message at :509). It also substitutes the host's own engine for a request naming an engine the host does not configure, in a fixed relational-first preference order ranked by each engine's SubstitutionPriority (:35-36, applied through SubstituteUnconfiguredEngine at :128), because every table the framework owns is relational and honoring an unconfigured engine literally handed those components an empty connection string.

What the resolver reads is two bound settings objects. ConnectionStringSettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/ConnectionStringSettings.cs:12) is the top-level ConnectionStrings section (:15): one connection string per engine plus the SQL Server and PostgreSQL migrations assemblies, and deliberately no required property, because a host may run entirely on SQLite or Cosmos, or declare its databases only under DataSources (:5-10, :17-33). DataSourcesSettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourcesSettings.cs:13) is the named half, a dictionary keyed by logical source name (:16, :23-25) that AddInfrastructure builds directly from configuration rather than through the options pipeline, because a root-level dictionary section does not bind there (:8-11); it rejects an empty entry name and reserves Default, which is configured through the top-level section instead (:27-39). Each entry is a DataSourceEntrySettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceEntrySettings.cs:19) whose properties are all optional and fall back to the corresponding top-level value, which is exactly the collapse-onto-Default behavior the resolver implements (:3-8). The one property with no top-level fallback is SqliteMigrationsAssembly: ConnectionStrings carries SQL Server and PostgreSQL migrations assemblies but no SQLite one, so a SQLite Default source declares its own through a collapsing entry, and a mixed-engine host cannot silently apply its SQL Server migrations assembly to a SQLite database (:42-55). Across both sections one rule is enforced at startup by ConnectionStringSettingsValidator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/ConnectionStringSettingsValidator.cs:30), registered with ValidateOnStart: the host must be able to reach at least one database, top level or named, or it fails to boot with a message naming both configuration shapes (:38-43, :46-59). That rule replaced a [Required] annotation on the SQL Server connection string, which 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 (:10-24).

EntityDataSourceRegistry (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/EntityDataSourceRegistry.cs:21), also a singleton, scans the configuration assemblies and maps each entity to its physical key, deriving the engine from the UseDataSourceAttribute on the configuration class and the logical name from UseDatabaseAttribute, the entity's module namespace via NamespaceConventions.GetModuleName, or Default (EntityDataSourceRegistry.cs:172-185). It caches an immutable Snapshot of frozen collections built on first access (:25-28, :82-92, built at :115-160), rescans once on a lookup miss when the assembly set changed so late-loaded module assemblies are picked up (:95-113), rejects an entity claimed by two different sources (:141-149), and precomputes the distinct physical sources in use so the outbox processor's per-poll call allocates nothing (:75-80, :157-159). Because the registry reads the same attributes the model configuration reads, routing and model contents agree by construction, and configurations that implement a provider interface directly without the attributed base classes are deliberately skipped as legacy (:163-176). DataSourceService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceService.cs:12) is the thin application-facing facade over IEntityDataSourceRegistry, and it answers the one question navigation loading needs: two entities support EF .Include() only when their physical keys are equal and the engine is relational (DataSourceService.cs:32).

One registry answers every engine question

Four engines would otherwise mean a four-way switch in every component that maps a key, quotes a column, picks a migrations assembly or decides whether a source has an outbox. The framework keeps exactly one place that knows engines apart. IDataSourceEngine (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/IDataSourceEngine.cs:18) is the internal contract, and its doc comment states the goal: no call site branches on DataSource (:9-16). Its members fall into three groups. The descriptive ones say how the engine is configured: its rank when the resolver substitutes it for an unconfigured engine (:29), the open generic entity-configuration interface whose implementations belong to its model (:32), the setting that names its migrations assembly (:38), whether connection identity includes the database name (:44), and how to read its connection string and migrations assembly from the top-level section, a named entry or a tenant override (:49-69). The capabilities are one value (:74). The behavior hooks are the few places the engine has to act rather than answer: build its sealed context over one physical source, never pooled (:83-86), apply its table or container mapping and key generation to one entity (:95-97), add covering index columns (:103), quote a column and build the soft-delete index predicate (:108-113), and expose an explicit-key insert dialect, or none (:119).

DataSourceEngines (.../Engines/DataSourceEngines.cs:19) is the registry: a static class holding the four engines (:22-28) in a frozen dictionary keyed by the shipped DataSource value (:30-31), whose For throws on an unregistered value (:40-43). It is static rather than a DI service on purpose: several consumers run where no container is reachable (the model-building conventions, EntityTypeConfiguration.ApplyEngineConventions, SoftDeleteFilterSql, IndexBuilderExtensions), and the engines are stateless facts about a closed enum only Common can extend, so a DI extension point would buy no extensibility; adding a fifth engine is one class plus one line (:6-18). The four implementations are SQLServerDataSourceEngine (.../Engines/SQLServerDataSourceEngine.cs:19), PostgreSQLDataSourceEngine (.../Engines/PostgreSQLDataSourceEngine.cs:17), SqliteDataSourceEngine (.../Engines/SqliteDataSourceEngine.cs:16) and CosmosDataSourceEngine (.../Engines/CosmosDataSourceEngine.cs:18), with substitution priorities 0, 1, 2 and 3 (SQLServerDataSourceEngine.cs:29, PostgreSQLDataSourceEngine.cs:27, SqliteDataSourceEngine.cs:26, CosmosDataSourceEngine.cs:28), which is the relational-first order the resolver substitutes in.

What differs between engines is a DataSourceEngineCapabilities record (.../Engines/DataSourceEngineCapabilities.cs:23) of five values (:24-28). Only facts whose values genuinely differ get a member, and everything that splits the engines exactly along the relational line is the single IsRelational flag: same-source .Include(), transactions, raw parameterized SQL, indexes, foreign keys whose delete behavior matters, Any(predicate) translation and the framework's own relational tables (:3-20). That one flag is what the outbox capture, the transaction coordinator, the conventions and the .Include() check read (DomainEventSaveChangesInterceptor.cs:257, DbContextFactory.cs:860-861, SoftDeleteUniqueIndexConvention.cs:43, DataSourceService.cs:32). MigrationPolicy (.../Engines/MigrationPolicy.cs:8) decides whether a source is migrated or created outright: Never for Cosmos, WhenAssemblyConfigured for SQLite and PostgreSQL, and Always for SQL Server, where a named source with no assembly falls back to the Default source's with a warning (:10-20; values at SQLServerDataSourceEngine.cs:42, PostgreSQLDataSourceEngine.cs:40, SqliteDataSourceEngine.cs:39, CosmosDataSourceEngine.cs:42). RowVersionStrategy (.../Engines/RowVersionStrategy.cs:8) decides the concurrency token: None on Cosmos, StoreGenerated (a rowversion, never stamped) on SQL Server, and ClientStamped on PostgreSQL and SQLite, where the audit interceptor writes it (:10-17). ConnectionStringRequired is true only for SQL Server, so a SQL Server source with no connection string is a startup failure rather than an optional source to skip (DataSourceEngineCapabilities.cs:10-13, SQLServerDataSourceEngine.cs:43), and NullsSortFirstAscending is false only for PostgreSQL, which keyset pagination has to know (DataSourceEngineCapabilities.cs:21, PostgreSQLDataSourceEngine.cs:43).

The explicit-key insert shows the split between engine-specific and engine-neutral work most clearly. IExplicitKeyInsertDialect (.../Engines/IExplicitKeyInsertDialect.cs:13) is the engine half, and only an engine that refuses an explicit value for a store-generated key without a session toggle has one: SQL Server, whose engine class implements the dialect itself (SQLServerDataSourceEngine.cs:19, :49) and emits SET IDENTITY_INSERT [schema].[table] ON or OFF (:163). Every other engine returns null and the save runs unchanged (PostgreSQLDataSourceEngine.cs:47, SqliteDataSourceEngine.cs:46, CosmosDataSourceEngine.cs:49). The dialect finds the added entries carrying explicit store-generated keys, grouped by table (IExplicitKeyInsertDialect.cs:21), each group an ExplicitKeyInsertGroup record of schema, table and entries (.../Engines/ExplicitKeyInsertGroup.cs:12), and builds the toggle statement (IExplicitKeyInsertDialect.cs:28); grouping the rounds, hiding other tables' entries, excluding their domain events and pinning the connection stay in DbContextFactory (:5-12). Every engine type is internal, so a consumer never sees one: the public surface is still the DataSource value on an attribute, and this registry is what turns that value into behavior.

Three model-finalizing conventions and the identifier mapping

The base context adds all three of its conventions in ConfigureConventions (ApplicationDbContext.cs:378-400), and each exists because a cross-cutting policy above would otherwise produce an invalid or surprising model. CrossDataSourceDegradeConvention (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/CrossDataSourceDegradeConvention.cs:34, added at ApplicationDbContext.cs:387) is what lets routing be lazy and attribute-driven and still produce a valid EF model. When a relationship's two ends live in different physical sources it removes the foreign key (a database cannot enforce an FK into another database), keeps the declared scalar FK columns plus a compensating index unless an existing index already covers them as a prefix, ignores the CLR navigation members, and drops the foreign entity types out of this database's model entirely (CrossDataSourceDegradeConvention.cs:39-90, :110-140). It works through EF's mutable model API rather than convention builders, because the soft-delete and concurrency helpers have already promoted every entity type to the Explicit configuration source (:22-24, :44-46), it eagerly drops the convention-created FK index before the coverage check so the column does not end up unindexed (:126-131), and it skips the compensating index on Cosmos, which auto-indexes everything and rejects explicit index definitions, which the convention reads as the engine's IsRelational capability (:66). Runtime navigation across sources then flows through the INavigationPopulator<in TEntity> batch-loading machinery in Group 11. Crucially, when every entity collapses onto one physical source (the monolith case) nothing is foreign and the convention returns early (:52-55), so the model is identical to the single-database model. That is the property that lets the same codebase run as a monolith today and as split services later without a rewrite, the core [Rubric §7, Microservices Readiness] claim.

SoftDeleteUniqueIndexConvention (.../Conventions/SoftDeleteUniqueIndexConvention.cs:34, added at ApplicationDbContext.cs:393) closes a smaller but sharper hole. Soft-delete hides a row from queries, but a plain unique index still enforces uniqueness against it, so "deleting" a speaker would permanently block re-creating one with the same email. The convention adds an IsDeleted = 0 filter to every unique index on a soft-deletable entity and extends rather than skips a hand-authored filter, appending its clause with AND so an index already narrowed on something else keeps its own predicate and still stops enforcing uniqueness against soft-deleted rows; a filter that already constrains the soft-delete column is left exactly as it is, so the append is idempotent (SoftDeleteUniqueIndexConvention.cs:36-81). It is a no-op for Cosmos (:43, any non-relational engine). The predicate text itself is not built inline: both this convention and the opt-in HasSoftDeleteFilter extension go through SoftDeleteFilterSql.Build (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:18, Build at :35), which reads the column name from the model and asks the engine for the predicate and the quoting (IDataSourceEngine.BuildSoftDeleteFilter and QuoteColumn, :35, :70; null where filtered indexes are unsupported, which is Cosmos), with a normalized comparison for the already-present check (:57-58), so the automatic and the hand-authored path can never disagree. IndexBuilderExtensions (.../Configuration/IndexBuilderExtensions.cs:10) is that opt-in half, an extension(IndexBuilder) block for the case the convention deliberately leaves alone: a hand-authored non-unique index that serves a live-row query and wants the same predicate (:12-65, with an optional additional predicate joined by AND at :60-64). Both conventions run at model finalization, after module configurations have declared their indexes and after EF's own relationship discovery, which is why they can see the finished picture.

The third convention is RestrictDeleteByDefaultConvention (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/RestrictDeleteByDefaultConvention.cs:42, added last of the three at ApplicationDbContext.cs:400 so it never stamps a relationship the cross-source convention has already removed, ADR-119). EF's own default is the opposite posture: a required relationship cascades, so deleting a parent silently deletes its children in the database, below the aggregate's invariants, below the soft-delete filter and below the domain events the framework raises. Restricting by default inverts that, so a delete that would orphan rows fails loudly and a genuine cascade becomes a decision recorded with .OnDelete(DeleteBehavior.Cascade) in the entity configuration (:9-19). Two things are left alone: an ownership foreign key keeps its cascade, because an owned type has no identity apart from its owner and EF requires it, and any behavior anybody configured (fluent OnDelete or the [DeleteBehavior] attribute) is kept exactly as configured. Both are stamped rather than changed: every foreign key carries a MMCA:DeleteBehaviorSource annotation naming which of the three sources decided it, so a finalized model can be audited without re-running the convention, and DeleteBehaviorConventionTestsBase reads exactly that annotation to assert every cascading relationship was opted into on purpose (:21-31). Like its siblings it is a no-op for Cosmos, which has no foreign key constraints to restrict and where stamping a decision the provider cannot enforce would only make the audit lie (:33-37, checked at :66).

ConfigureConventions ends with the one piece that is not a convention at all: StronglyTypedIdModelConfiguration.Apply (.../Persistence/Conventions/StronglyTypedIdModelConfiguration.cs:21, called at ApplicationDbContext.cs:402-410, ADR-115). A host that calls AddStronglyTypedIds registers a StronglyTypedIdRegistry, and this declares one pre-convention type mapping per declared identifier, so every property of that type maps to the primitive it wraps: keys, cross-module scalar references, owned-type members and framework-table columns alike, on SQL Server, PostgreSQL, SQLite and Cosmos, because it runs on the one base context (:7-20). Absent the registry it is a no-op, which is the default posture: the primitive identifier aliases stay the identifier model and no existing entity changes (ApplicationDbContext.cs:402-410). Pre-convention is the load-bearing word. The actual conversion is StronglyTypedIdValueConverter<TSelf, TValue> (.../Persistence/Conversions/StronglyTypedIdValueConverter.cs:28), with NullableStronglyTypedIdValueConverter<TSelf, TValue> (:56) and StronglyTypedIdValueComparer<TSelf> (:80) beside it, and a converter attached at model finalization would arrive after the provider's value-generation conventions had already inspected the property, so a wrapped int key would silently lose its IDENTITY strategy. Declared pre-convention, the provider CLR type is known before any of that runs, the column stays the same int, bigint, uniqueidentifier or nvarchar it was, and adopting a wrapper is therefore neither a schema change nor a migration (StronglyTypedIdValueConverter.cs:7-25).

Entity configuration and engine portability

Concrete entity configurations derive from the engine-aware EntityTypeConfiguration<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:27) or, more commonly, one of the fixed-engine shims like EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> (.../EntityTypeConfigurationSQLServer.cs:17), which is that base annotated [UseDataSource(DataSource.SQLServer)] (:16), with EntityTypeConfigurationSqlite<TEntity, TIdentifierType> (.../EntityTypeConfigurationSqlite.cs:17) and EntityTypeConfigurationCosmos<TEntity, TIdentifierType> (.../EntityTypeConfigurationCosmos.cs:18) and EntityTypeConfigurationPostgreSQL<TEntity, TIdentifierType> (.../EntityTypeConfigurationPostgreSQL.cs:22) as its three siblings. The base reads the attribute off its own runtime type and throws a clear error when it is missing (EntityTypeConfiguration.cs:43-46), then applies the engine's conventions in ApplyEngineConventions (:69-77), which hands the mapping to the engine's ApplyKeyAndTableMapping hook (:77): SQL Server gets a table in a module schema (SQLServerDataSourceEngine.cs:94-103), SQLite a plain table, and Cosmos a per-module container with the entity id as partition key (CosmosDataSourceEngine.cs:86-99), each mapping key generation according to the entity's EntityTypeExtensions.IsIdValueGenerated marker (SQLServerDataSourceEngine.cs:103, CosmosDataSourceEngine.cs:98-99): ValueGeneratedOnAdd for database identity, ValueGeneratedNever otherwise, or the CosmosIntIdValueGenerator for Cosmos, which has no server-side identity and increments a process-level counter seeded from the Unix timestamp (.../ValueGenerators/CosmosIntIdValueGenerator.cs:16-25). Because the engine is a single attribute and the base implements all four provider marker interfaces (IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, IEntityTypeConfigurationSqlite<TEntity, TIdentifierType>, IEntityTypeConfigurationCosmos<TEntity, TIdentifierType>, IEntityTypeConfigurationPostgreSQL<TEntity, TIdentifierType> (.../EntityTypeConfiguration/IEntityTypeConfigurationPostgreSQL.cs:13), all over the common IEntityTypeConfigurationBase<TEntity, TIdentifierType>), moving an entity between engines is a one-line attribute change with no configuration-body edits (EntityTypeConfiguration.cs:9-23). The shared EntityTypeConfigurationBase<TEntity, TIdentifierType> (.../EntityTypeConfigurationBase.cs:19) handles the one universal concern: excluding the in-memory DomainEvents collection from mapping (:25-32). Value objects reach the database through this layer too: EntityTypeBuilderExtensions (.../Configuration/EntityTypeBuilderExtensions.cs:13) flattens a Money into an amount plus a three-character non-Unicode ISO 4217 code column with a read-leg fallback to the zero-Money sentinel Currency (:26, mapping at :63-94, fallback at :84), and its OwnsAddress sibling flattens an Address into six columns on the owner's table with the lengths and required-ness the framework's consumers already configured by hand (:96-99, method at :131); the four converters in Persistence/Conversions map Email and PhoneNumber to plain strings in required (EmailValueConverter at .../Conversions/EmailValueConverter.cs:33, PhoneNumberValueConverter at .../Conversions/PhoneNumberValueConverter.cs:33) and optional (NullableEmailValueConverter at EmailValueConverter.cs:60, NullablePhoneNumberValueConverter at PhoneNumberValueConverter.cs:61) flavors; and EnumerationValueConverter<TEnumeration> (.../Conversions/EnumerationValueConverter.cs:33) plus its nullable sibling NullableEnumerationValueConverter<TEnumeration> (:62) store a smart enumeration as its plain int value, so replacing a CLR enum property with an enumeration is not a schema change.

Discovery runs through ModelBuilderExtensions.ApplyAllConfigurations (.../DbContexts/ModelBuilderExtensions.cs:10, an extension(ModelBuilder) block at :12), which the base calls with an entity filter so each database's model receives only its own entities (ApplicationDbContext.cs:954-988, filter application at ModelBuilderExtensions.cs:57-60), over the assemblies supplied by IEntityConfigurationAssemblyProvider and its DefaultEntityConfigurationAssemblyProvider implementation (.../Persistence/DefaultEntityConfigurationAssemblyProvider.cs:12), which scans loaded *.Infrastructure assemblies, excludes Common.Infrastructure itself, and appends whatever a host registered through EntityConfigurationOptions (DefaultEntityConfigurationAssemblyProvider.cs:16-21, options at .../Persistence/EntityConfigurationOptions.cs:10). Two configurations ship inside the framework itself, PushNotificationConfiguration and UserNotificationConfiguration (.../Configuration/EntityTypeConfiguration/Notifications/PushNotificationConfiguration.cs:16, .../UserNotificationConfiguration.cs:15), both tagged [UseDatabase("Notification")] and re-declaring the Notification schema because namespace derivation would otherwise resolve them to Common (PushNotificationConfiguration.cs:8-15, :25). This engine-portability design is ADR-018 (polyglot persistence); note the current-reality caveat: the SQLite and Cosmos plumbing is shipped and tested, but every concrete subclass of the SQLite and Cosmos configuration bases lives in Common's own test projects, so SQL Server is the only engine backing production entities today.

Two tables one database owns, refresh sessions and permission grants

Most of the framework's own tables are infrastructure every relational source needs. Two are not: both are Identity-module data that exactly one database owns, so both are mapped on request rather than everywhere. The stored permission grants are the smaller half. PermissionGrantModelBuilderExtensions (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/PermissionGrantModelBuilderExtensions.cs:15, table name at :18) maps the PermissionGrants table into one model, and its doc comment gives the same reason the refresh-session mapper gives: mapping them everywhere would leave an empty PermissionGrants table in every other module's migrations (:6-14). The opt-in is the AddStoredPermissionGrants DI call, expressed as the PermissionGrantModelGate marker rather than a second configuration flag (.../Auth/PermissionGrantModelGate.cs:19, :6-12). EFPermissionGrantStore (.../Auth/EFPermissionGrantStore.cs:34) reads and writes that table, resolving which database holds it exactly as EFRefreshSessionStore does (the registry first, then the source Authentication:PermissionGrants:DataSourceName names, :15-22), and reads untracked, the mirror image of the refresh-session store's choice: a grant is inserted or deleted, never edited, so tracking would only cost the change detector work on every snapshot load (:23-27). That snapshot is PermissionGrantCache (.../Auth/PermissionGrantCache.cs:35), a singleton that rebuilds the whole set in one query rather than lazy-loading per role, because the authorization read is synchronous and a miss cannot go and fetch; a role whose last grant was deleted is evicted in the same pass rather than left to expire, since a revoke that keeps granting until a TTL runs out is the one staleness direction that is not safe (:14-31). PermissionGrantRefreshService (.../Auth/PermissionGrantRefreshService.cs:31) primes that snapshot at startup and rebuilds it on the configured interval, so a grant added by another replica takes effect here without a restart, and a failed load is logged and retried rather than fatal: authorization keeps working off the compiled registry while the table is unreachable, so a database blip degrades the feature to "no stored grants", a denied request an operator can see rather than a granted one nobody notices (:8-23).

Multi-device refresh sessions (ADR-097) are the other half. RefreshSessionModelBuilderExtensions (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/RefreshSessionModelBuilderExtensions.cs:16) maps the RefreshSession table on request rather than everywhere, and the doc comment gives the reason: the outbox is infrastructure every relational source needs, while refresh sessions are Identity-module data that exactly one database owns, so mapping them everywhere would leave an empty RefreshSessions table in every other module's migrations (:6-14). It maps the token hash as a fixed-width non-Unicode column, uniquely indexed because that is the lookup a refresh answers and because a collision across users would let one account's token validate against another's session (:44-49, :60-66), plus a (UserId, RevokedAt) index for the per-user family questions the session cap, reuse detection and sign-out-everywhere all ask (:68-71). The base context calls it from the gated ConfigureRefreshSessions (ApplicationDbContext.cs:893-901) precisely because a downstream app runs on the sealed engine contexts and has no OnModelCreating of its own to override. EFRefreshSessionStore (.../Auth/EFRefreshSessionStore.cs:31) is the IRefreshSessionStore implementation over that table; it resolves which database holds it the same way the rest of the framework routes an entity (the registry first, then RefreshSessions:DataSourceName, :15-23) and reads tracked on purpose, because the caller revokes by mutating the instances it hands back, with one read-only exception, FindByIdUntrackedAsync, the re-read for the rotation loser (:25-29). RefreshSessionCleanupService (.../Auth/RefreshSessionCleanupService.cs:48) hard-deletes spent rows past RetentionDays: the row is bookkeeping, not an aggregate, and its content is a credential digest plus the IP and user agent of a device, so keeping it past its usefulness is both a growing table and a growing set of records describing a data subject (ADR-005, :14-23). Its own doc comment names the trade the retention window makes: a rotation chain older than the window is gone, so a replay of a token that old reads as an unknown token instead of signalling reuse (:25-30). That is [Rubric §11, Security] meeting [Rubric §30, Compliance and Data Governance] again, in a table rather than in an interceptor.

Encryption, seeding, design time, and the shared helpers

A handful of supporting pieces round out the EF side. EncryptedStringConverter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Encryption/EncryptedStringConverter.cs:72) is a value converter that transparently encrypts a string column with authenticated AES-256-GCM. The stored value is a versioned Base64 envelope, a one-byte key version, a random 12-byte nonce, the ciphertext, then a 16-byte tag, with the version byte passed to AES-GCM as associated data so it is covered by the authentication tag and a rewritten version byte fails decryption instead of silently selecting a different key (:38-44, envelope assembled at :204-210). A converter is built over either a single key, registered as version 1 (:94-97), or a whole key ring plus a current version so reads resolve their key from the version byte stored with each value and rotation needs no downtime (:109-112), and it rejects any key that is not exactly 32 bytes on both paths (:127-141, :146-181). Its own doc comment states the constraint that governs where it may be used: the ciphertext is non-deterministic, so the column cannot back equality predicates, unique indexes, or server-side sorting (:19-32). It is the [Rubric §11, Security] control that IAnonymizable points at for fields that must remain retrievable after erasure. Its current reality matches ADR-037: it is shipped and unit-tested but unadopted, no entity configuration wires it (the only non-test references are its own file and the IAnonymizable doc comment). On the read side, IQueryableExecutor and its implementation EFQueryableExecutor (.../Persistence/EFQueryableExecutor.cs:11) abstract async query materialization so higher layers can execute an IQueryable without referencing EF, detecting a real EF query by its IAsyncEnumerable<T> implementation and degrading to LINQ-to-Objects otherwise (EFQueryableExecutor.cs:43). That is what makes the specification evaluation in Group 03 unit-testable without a database, a small but real [Rubric §14, Testability] win. Two more helpers sit at this level: SqlServerUniqueConstraintViolationDetector (.../Persistence/SqlServerUniqueConstraintViolationDetector.cs:31) is the IUniqueConstraintViolationDetector implementation that classifies a duplicate-key failure by SQL Server error number 2601 or 2627, walking the whole inner-exception chain and falling back to the message text so a wrapped or non-SQL-Server provider failure still classifies (:34-40, :47-73); and ProfilingHelper (.../Persistence/ProfilingHelper.cs:9) is the MiniProfiler step wrapper the repository decorators share (:11-31). The framework's shared base for fixed-interval sweeps, PeriodicBackgroundService, is a scheduling type documented in Group 14, and ADR-052 covers in-process background work generally.

Seeding and design time close the loop. IDbSeeder (.../Seeding/IDbSeeder.cs:7) and the DbSeeder base (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/DbSeeder.cs:7) give module seeders a GetId<TIdentifier> helper that maps integer seed ids to either int or a deterministic Guid so seed data reproduces across key strategies (:20-39), and IdentityModuleDbSeederBase<TUser> (.../Seeding/IdentityModuleDbSeederBase.cs:38) hoists the repeated account-seeding idiom out of the two app identity modules, leaving only app-specific hooks and a ShouldSeed opt-in gate that defaults to true (:44, :48, :57, :60-71), each account described by a SeedAccount record (.../Seeding/SeedAccount.cs:17) whose own remarks warn that seed credentials are plaintext by construction and therefore development-only data (:6-11). For migrations, DesignTimeDbContextHelper (.../DbContexts/Design/DesignTimeDbContextHelper.cs:41) builds a SQLServerDbContext for dotnet ef without the app's DI container: a downstream migrations project writes a few-line IDesignTimeDbContextFactory (:18-32, entry points at :50 and the SQLite twin at :71), and dotnet ef migrations add X -- --datasource Conference selects which physical source to build against (:165-181), so each database gets its own migrations project. It composes minimal stand-ins (ExplicitAssemblyProvider at :185, NullDomainEventDispatcher at :190) and a DesignTimeDbContextOptions (.../Design/DesignTimeDbContextOptions.cs:11) carrying the connection settings, then wires the same DataSourceResolver and EntityDataSourceRegistry the runtime uses so the design-time model matches the runtime one (:105-157). It registers the tenant interceptor, the scheduler options, the audit-trail options and the refresh-session options unconditionally, defaulted to disabled, precisely so dotnet ef scaffolds the same migration for consumers with and without those features (:126-154), and the refresh-session registration carries the extra twist that its gate is source-sensitive, so the source name it registers is the one this context resolved to (:144-154).

The storage, image, and push contracts

The group also carries the Application-layer contracts for the storage-adjacent services that are not EF at all. Each has a null default in the container so a host that has not configured the backend still starts and degrades cleanly, and the Azure, ImageSharp, and null implementations behind them are composition-side types documented in Group 14. IFileStorageService (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/IFileStorageService.cs:11) stores and deletes blobs behind a Result-returning API and exposes an IsConfigured flag handlers can gate features on (:14). IImageProcessor (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/IImageProcessor.cs:11) is the normalization contract for untrusted uploads, and the dependency-free ImageContentSniffer (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/ImageContentSniffer.cs:10) is its upload-side companion, deciding the accepted formats (JPEG, PNG, WebP) from magic bytes rather than the client-declared content type (:14-36). Both are ADR-045, and both are squarely [Rubric §11, Security] (EXIF GPS is PII, and a full re-encode is the defense against polyglot payloads). Three more dependency-free helpers sit beside them, all ADR-045. DocumentContentSniffer (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/DocumentContentSniffer.cs:49) is the document sibling of the image sniffer: a payload is accepted only when the actual bytes and the file-name extension agree on one of the formats the caller allowed, so a .pdf name over zip bytes or an executable renamed .docx is rejected, and the client-declared content type is never consulted (:42-48). What a caller may allow is the [Flags] DocumentFormats enum in the same file (:9-39, All at :39); an Office Open XML package is told from an ordinary renamed zip by the [Content_Types].xml entry every package carries at its root (:51-55), and an archive declaring more entries than the cap is rejected without inspecting them, because a genuine Office document holds tens of parts and never thousands (:57-61). BlobNames (.../Storage/BlobNames.cs:10) reduces a user-supplied file name to a blob-safe segment, since a blob name is part of a URL and of a storage path: anything outside A-Z a-z 0-9 . _ - collapses to a single -, consecutive dots collapse so no .. can appear, and nothing surviving means the file fallback (:5-9, :12, :21-25). FileUploadOptions (.../Storage/FileUploadOptions.cs:13) carries the response headers stored with the blob, because how a browser treats the bytes on the way back out is a header decision: an RFC 6266 Content-Disposition for a document download (:28), a long Cache-Control for a content-addressed asset (:34), and None (:21) to leave the store's own defaults in place. On the push side, INativePushSender (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/INativePushSender.cs:10) and IPushDeviceRegistrar (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/IPushDeviceRegistrar.cs:11) describe OS-level delivery and the device-installation registry that backs it (ADR-044). This channel sits beside the persisted notification record and the SignalR path in Group 10.

One more Application interface sits in this group's persistence namespace without being repository-shaped at all. IOutboxAdministration (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IOutboxAdministration.cs:16) is the operator surface that gives an undelivered event a way back into delivery, projecting each abandoned row into an OutboxDeadLetter record (:80) that deliberately omits the payload, because it can carry personal data and no replay decision depends on reading it (:69-79). It is [Rubric §13, Observability and Operability] material; the rows it administers are written by this group's domain-event interceptor and delivered by the processor and consumers in Group 04.

Where this group sits

Persistence is the concrete floor the abstract domain stands on. The entity bases and audit contracts from Group 02 are what the interceptors stamp and the query filters hide; the domain events aggregates raise are what DomainEventSaveChangesInterceptor drains into the outbox that Group 04 delivers; the transactional decorator in Group 05 is what opens the transaction whose commit releases the deferred dispatch; the specifications and query service in Group 03 are evaluated by this group's SpecificationEvaluator against its repositories and IQueryable surfaces; the refresh-session table this group maps is read and written by the auth stack in Group 08; the navigation populators in Group 11 fill the cross-source gaps the degrade convention opens; the scheduler this group's gated tables answer to lives in Group 14, which also composes the storage, push, and event-consumer implementations behind the contracts this group declares; and the entity-source registry answers the .Include() questions the populators ask. The design axes here are three orthogonal ones collapsed behind a single DataSourceKey plus a scoped tenant: ADR-006's Name axis (which database), ADR-018's Engine axis (which storage technology), and ADR-073's tenant axis (whose rows, and optionally whose database), so application code never has to know which it is running on. Read this group as the answer to one question the rest of the guide keeps asking: how does a framework that describes persistence in pure domain terms actually put a row in a database, and do it in a way that survives a module being pulled out into its own service.

IChannelJoinAuthorizer

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Notifications · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/IChannelJoinAuthorizer.cs:16 · Level 0 · interface

  • What it is: an optional entitlement check consulted before a SignalR connection is allowed to join a live channel, so channel membership is not decided by the channel-key shape pattern alone.
  • Depends on: ClaimsPrincipal (BCL) for the caller's identity; otherwise no first-party types.
  • Concept introduced, closing the gap between a shape-valid channel key and an authorized one. [Rubric §11, Security] assesses whether an authorization decision that a hub cannot make structurally (any well-formed key like event:42 passes the configured pattern) is pushed out to a pluggable check rather than left implicit. The doc comment (IChannelJoinAuthorizer.cs:18-25) frames the contract as a single yes/no gate: given the connection's principal and the requested channelKey, decide whether the connection may join. Because the parameter is optional and defaults to null on the consuming hub, an existing host that never registers an implementation keeps working unchanged and every authenticated caller may join any well-formed channel key; a host that publishes anything not public to every signed-in user must register one deliberately.
  • Walkthrough: one method.
    • CanJoinAsync(ClaimsPrincipal? user, string channelKey, CancellationToken cancellationToken = default) (IChannelJoinAuthorizer.cs:26-29): returns true to admit the connection to channelKey. user is the connection's principal (the hub requires authentication, so this is never null in practice); channelKey is the caller-requested channel, already validated against the shape pattern before this check runs.
  • Why it's built this way: keeping the check as a single optional interface, rather than a required constructor argument, lets NotificationHub ship with a permissive default (NotificationHub.cs:18-23) while still giving a host with tenant- or role-scoped channels a single extension point to close the gap; the doc comment is explicit that the shape pattern alone is not authorization. See ADR-039 for the live-channel-push design this sits inside.
  • Where it's used: injected as the optional joinAuthorizer parameter of NotificationHub (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/NotificationHub.cs:27), consulted before admitting a join (NotificationHub.cs:160-165). MMCA.ADC is the first registered implementation: LiveChannelJoinAuthorizer (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Live/LiveChannelJoinAuthorizer.cs:43-45) is wired with AddScoped<IChannelJoinAuthorizer, LiveChannelJoinAuthorizer>() (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:239).

INativePushSender

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Notifications · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/INativePushSender.cs:10 · Level 0 · interface

  • What it is: the contract for sending OS-level push notifications to registered device installations, the delivery channel that reaches a phone when the app is backgrounded or killed.
  • Depends on: the UserIdentifierType global alias (INativePushSender.cs:19, see primer §2); BCL otherwise. Its registry counterpart is IPushDeviceRegistrar.
  • Concept introduced, native push as the third delivery channel. [Rubric §6, CQRS & Event-Driven Design] assesses whether a delivery mechanism is a swappable abstraction declared in Application and implemented at the edge rather than a hardcoded SDK call inside a handler. The doc comment (INativePushSender.cs:3-9) places this beside the two other channels under ADR-044: the persisted inbox record and the SignalR real-time push handled by IPushNotificationSender. Where SignalR reaches a connected browser, native push reaches a device that is not running the app. Infrastructure targets Azure Notification Hubs (FCM v1 plus APNs), and the default registration is a no-op, so a host that never configures a hub degrades cleanly instead of failing.
  • Walkthrough: two methods, both returning a plain Task rather than a Result, because a push is fire-and-forget from the caller's point of view.
    • SendToUsersAsync(IEnumerable<UserIdentifierType> userIds, string title, string body, Dictionary<string, string>? metadata = null, CancellationToken cancellationToken = default) (INativePushSender.cs:19): targets specific users, resolved to installations via user tags (INativePushSender.cs:13); metadata carries optional key-value data such as a deep-link route in the platform payload (INativePushSender.cs:16).
    • BroadcastAsync(string title, string body, Dictionary<string, string>? metadata = null, CancellationToken cancellationToken = default) (INativePushSender.cs:27): sends to every registered installation.
  • Why it's built this way: targeting users rather than raw device tokens keeps the caller out of the tag and token bookkeeping, which IPushDeviceRegistrar owns; the no-op default makes native push an opt-in capability rather than a hard dependency of every host.
  • Where it's used: injected into SendPushNotificationHandler (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:47) alongside the SignalR sender. NullNativePushSender is registered by default with TryAddTransient (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:331) and AzureNotificationHubNativePushSender replaces it when a hub is configured (DependencyInjection.Notifications.cs:98).

IPushDeviceRegistrar

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Notifications · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/IPushDeviceRegistrar.cs:11 · Level 3 · interface

  • What it is: maintains the device-installation registry behind INativePushSender, tagging each installation with its owning user so sends can target users rather than raw device tokens.
  • Depends on: Result (via MMCA.Common.Shared.Abstractions, IPushDeviceRegistrar.cs:1), DeviceInstallationRequest (from MMCA.Common.Shared.Notifications.PushNotifications, IPushDeviceRegistrar.cs:2), and the UserIdentifierType alias (IPushDeviceRegistrar.cs:18).
  • Concept introduced, ownership scoping on a client-supplied identifier, and the existence-oracle problem. [Rubric §11, Security] assesses whether a resource identifier supplied by a caller is authorized against that caller before it is acted on, and whether error responses leak the existence of other users' resources. Installation ids are client-generated, so nothing stops a caller from sending someone else's; the delete verifies the user:{id} tag stamped by UpsertAsync before deleting, and reports a mismatch as success rather than not-found, because answering differently for "no such installation" and "not yours" would turn the endpoint into an existence oracle for other users' installation ids, and the caller has nothing to do with either answer (IPushDeviceRegistrar.cs:28-36). The stronger property is structural: every delete is scoped to an owner, there is no unscoped form, so an implementation cannot skip the check by accident (IPushDeviceRegistrar.cs:31-32). [Rubric §6, CQRS & Event-Driven Design] also applies: the type doc (IPushDeviceRegistrar.cs:6-10, ADR-044) explains the split, where this type owns the installation registry, tagged by user, so INativePushSender can send to users, and the default implementation is a no-op until a notification hub is configured.
  • Walkthrough: two methods, both returning Result.
    • UpsertAsync(UserIdentifierType userId, DeviceInstallationRequest request, CancellationToken cancellationToken = default) (IPushDeviceRegistrar.cs:18): creates or refreshes an installation, tagging it with the authenticated owner (IPushDeviceRegistrar.cs:13-14).
    • DeleteAsync(UserIdentifierType userId, string installationId, CancellationToken cancellationToken = default) (IPushDeviceRegistrar.cs:37): the ownership-scoped delete, and the only delete. Unknown installation ids and installations owned by another user both succeed without deleting anything (IPushDeviceRegistrar.cs:20-23).
  • Why it's built this way: separating the registry (this type) from the send (INativePushSender) means the send API can target users while token bookkeeping stays in one place. Making the owner a required parameter of the only delete member is what removes the class of bug where a caller passes a raw client-supplied id straight through: there is no overload to pass it to.
  • Where it's used: DevicesController injects it (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:27) and passes the authenticated user id on both calls, UpsertAsync at DevicesController.cs:44 and DeleteAsync at DevicesController.cs:68. NullPushDeviceRegistrar is the default registration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:332); AzureNotificationHubDeviceRegistrar replaces it when a hub is configured (DependencyInjection.Notifications.cs:99).

DataSource

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IDataSourceService.cs:6 · Level 0 · enum

  • What it is: a four-value enum (CosmosDB, Sqlite, SQLServer, PostgreSQL) naming which database engine persists a given entity type. It shares a file with IDataSourceService: the enum at IDataSourceService.cs:6, the interface at IDataSourceService.cs:31.
  • Depends on: nothing first-party. The file has no using directives at all (IDataSourceService.cs:1 is the namespace declaration), which is the point: this is the Application layer's vocabulary for a persistence decision, with no persistence library behind it.
  • Concept introduced, database-per-service routing at the entity level. [Rubric §8, Data Architecture] assesses whether storage is deliberately partitioned, whether the routing strategy is explicit, and whether cross-database JOINs happen by accident; this enum is where that decision becomes a first-class value the query pipeline can branch on. [Rubric §7, Microservices Readiness] assesses whether a module could be lifted out without a rewrite; because each module owns its own store (ADR-006), the engine is a per-entity fact rather than a global one. The values encode a capability, not just a name: the CosmosDB doc comment says "document store, no cross-container JOINs" (IDataSourceService.cs:8), while Sqlite supports "JOINs within a single database file" (IDataSourceService.cs:11), SQLServer has "full relational JOIN support" (IDataSourceService.cs:14), and PostgreSQL has the same full relational JOIN support (IDataSourceService.cs:18). That distinction is what lets the framework decide whether a navigation can be an EF .Include() or must become a manual batch load. ADR-113 records PostgreSQL's addition as a first-class engine alongside the original three.
  • Walkthrough: four members, each documented with its JOIN capability. CosmosDB (IDataSourceService.cs:9), Sqlite (IDataSourceService.cs:12), SQLServer (IDataSourceService.cs:15), and PostgreSQL (IDataSourceService.cs:22). PostgreSQL is appended after SQLServer rather than inserted alphabetically: the doc comment on the member says why (IDataSourceService.cs:17-21), the three original members are shipped public API with fixed ordinal values, and renumbering them would break every consumer that persisted or serialized one. There is no None/Unknown member and no explicit numeric assignment, so CosmosDB is the default 0 value.
  • Why it's built this way: encoding the JOIN-capability difference in the enum lets IDataSourceService.HaveIncludeSupport (IDataSourceService.cs:61) answer the include-versus-batch-load question from a value comparison rather than a scattered chain of provider checks, and it keeps that decision in the framework-pure Application layer with no EF Core reference.
  • Where it's used: paired with a database name in DataSourceKey (DataSourceKey.cs:15); resolved and compared by IDataSourceService; mapped per entity by EntityDataSourceRegistry; consumed by NavigationMetadataProvider to classify each navigation as an EF include or a manual populate.

IConcurrencyConflictDetector

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IConcurrencyConflictDetector.cs:23 · Level 0 · interface

  • What it is: a one-method classifier that answers whether a failed save was rejected because of an optimistic-concurrency conflict, so a handler recovering from a lost race can recognise the collision without depending on the provider exception type that reported it.
  • Depends on: nothing first-party. The single method takes a BCL Exception (IConcurrencyConflictDetector.cs:23).
  • Concept introduced, classifying a concurrency failure without naming the provider. [Rubric §3, Clean Architecture] assesses whether technology details stay outside the inner layers: EF Core's DbUpdateConcurrencyException is a provider type, and this interface lets Application recognise the failure by asking the question rather than catching the concrete type. [Rubric §29, Resilience & Business Continuity] applies to how a handler reacts to a lost race: the interface mirrors IUniqueConstraintViolationDetector in shape and intent, one boolean classifier over an exception chain, so the same recovery pattern serves two different failure modes.
  • Walkthrough: one method, bool IsConcurrencyConflict(Exception exception) (IConcurrencyConflictDetector.cs:25). The doc comment states the contract on the parameter (IConcurrencyConflictDetector.cs:16-19): the answer covers exception or anything in its inner-exception chain, which is what makes it usable against a wrapped save failure. It is synchronous and takes no cancellation token, since it inspects an object already in hand.
  • Why it's built this way: declaring the question in Application and answering it in Infrastructure, where the provider exception type is already referenced, keeps the classification swappable per engine while every handler that recovers from a concurrency conflict keeps working unchanged (ADR-035).
  • Where it's used: implemented by EfCoreConcurrencyConflictDetector (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EfCoreConcurrencyConflictDetector.cs:2), registered in MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs. Consumed by MMCA.ADC's RefreshFromSessionizeHandler to distinguish a concurrency conflict from other save failures during a Sessionize refresh (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:1), registered by MMCA.ADC.Conference.Infrastructure's DependencyInjection.

IEntityConfigurationAssemblyProvider

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IEntityConfigurationAssemblyProvider.cs:10 · Level 0 · interface

  • What it is: a single-method contract returning the assemblies that hold EF Core entity type configurations, so a DbContext can discover and apply them without hardcoding module assembly-name patterns.
  • Depends on: System.Reflection (BCL) only (IEntityConfigurationAssemblyProvider.cs:1).
  • Concept introduced, a module-agnostic model surface that keeps EF out of Application. [Rubric §3, Clean Architecture] assesses whether the inner layers declare intent while the outer layers own the technology; here Application declares which assemblies carry configurations and Infrastructure performs the EF scan. [Rubric §7, Microservices Readiness] assesses extraction cost: each module ships its own IEntityTypeConfiguration<T> classes in its own Infrastructure assembly, so removing a module from the returned list removes it from the model, no context rewrite required. The doc comment states exactly this framing (IEntityConfigurationAssemblyProvider.cs:5-9).
  • Walkthrough: one method, IReadOnlyList<Assembly> GetConfigurationAssemblies() (IEntityConfigurationAssemblyProvider.cs:15). It takes no arguments and no cancellation token: the assembly set is a composition-time fact, resolved once, not a per-request query.
  • Why it's built this way: routing configuration discovery through an injected provider means the active module set determines the model without any context knowing a module by name, which is the extraction invariant behind ADR-006 and ADR-007.
  • Where it's used: injected into ApplicationDbContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:49) and each engine subclass (SQLServerDbContext at SQLServerDbContext.cs:18, CosmosDbContext at CosmosDbContext.cs:18, SqliteDbContext at SqliteDbContext.cs:15); consumed by EntityDataSourceRegistry to build the entity-to-source map (EntityDataSourceRegistry.cs:22) and by PhysicalDbContextFactory (PhysicalDbContextFactory.cs:22). The default implementation DefaultEntityConfigurationAssemblyProvider is registered with TryAddSingleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:59), and DesignTimeDbContextHelper substitutes its own nested ExplicitAssemblyProvider for dotnet ef runs (DesignTimeDbContextHelper.cs:193 and :185).

IQueryableExecutor

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IQueryableExecutor.cs:7 · Level 0 · interface

  • What it is: an abstraction over the EF Core IQueryable operations the Application layer needs (Include, AsSplitQuery, ToListAsync, CountAsync) that would otherwise require a direct Microsoft.EntityFrameworkCore reference there.
  • Depends on: System.Linq (BCL) only; the file declares no using directives.
  • Concept introduced, inverting EF's terminal operators out of Application. [Rubric §3, Clean Architecture] assesses whether the inner layers stay free of framework dependencies; EF's async materializers (ToListAsync, CountAsync) and Include are extension methods living in the EF assembly, so calling them directly would drag EF into Application. This interface inverts that: Infrastructure implements each by calling EF, and Application receives the interface by DI. The doc comment (IQueryableExecutor.cs:3-6) states exactly that intent.
  • Walkthrough: four methods; the two queryable transforms are constrained where T : class (IQueryableExecutor.cs:15 and :27) because EF requires a reference type there, while the two materializers accept any T so a projection to a scalar or DTO still works.
    • Include<T>(IQueryable<T> query, string navigationPropertyPath) (IQueryableExecutor.cs:14): a string-based include path, for example "Category" or "Order.OrderLines" (IQueryableExecutor.cs:12), deliberately not a lambda, because the generic query pipeline builds include paths at runtime from navigation-property name strings.
    • AsSplitQuery<T>(IQueryable<T> query) (IQueryableExecutor.cs:26): switches EF to split-query mode. The doc comment (IQueryableExecutor.cs:17-22) explains why it matters: paginating (Skip/Take) a query that has collection includes in single-query mode truncates or mis-correlates child rows, so list reads come back with empty collections. It is documented as a no-op for non-EF (in-memory) queryables.
    • ToListAsync<T>(IQueryable<T> query, CancellationToken cancellationToken = default) (IQueryableExecutor.cs:34) and CountAsync<T>(IQueryable<T> query, CancellationToken cancellationToken = default) (IQueryableExecutor.cs:41): the async materializers.
  • Why it's built this way: the string include path (rather than Expression<Func<T, TProperty>>) is required because the query pipeline composes includes from runtime navigation metadata, not compile-time lambdas; and naming AsSplitQuery as an explicit operation keeps a well-known EF pagination defect from silently reaching list endpoints.
  • Where it's used: implemented by EFQueryableExecutor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EFQueryableExecutor.cs:11), registered with TryAddSingleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:111). Injected into EntityQueryPipeline (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:13) and into the framework's own notification handlers, for example GetMyNotificationsHandler (GetMyNotificationsHandler.cs:18).

IRawSqlQueryExecutor

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRawSqlQueryExecutor.cs:25 · Level 0 · interface

  • What it is: an escape hatch for interpolated raw SQL reads, materializing rows into a scalar or an unmapped DTO, for the cases a Queryable-based repository cannot express.
  • Depends on: nothing first-party; the interpolated statement type is the BCL FormattableString.
  • Concept introduced, raw SQL as a declared, parameterized escape hatch. [Rubric §11, Security] assesses whether raw SQL access is disciplined rather than ad hoc: both methods take a FormattableString, so every interpolation hole becomes a command parameter rather than a string-concatenated value, which is what keeps this interface off the path to SQL injection. [Rubric §3, Clean Architecture] assesses whether the inner layers stay technology-free; declaring the contract here and executing it in Infrastructure keeps MMCA.Common.Application free of a direct ADO.NET or EF Core reference while still giving a handler a way to run a statement the query pipeline cannot translate.
  • Walkthrough: two methods, both generic in the row shape T.
    • QueryAsync<T>(FormattableString sql, CancellationToken cancellationToken = default) (IRawSqlQueryExecutor.cs:32): runs the statement and materializes every row into T, empty when none match.
    • QuerySingleOrDefaultAsync<T>(FormattableString sql, CancellationToken cancellationToken = default) (IRawSqlQueryExecutor.cs:40): runs a statement expected to select at most one row, throwing InvalidOperationException when the statement selected more than one (IRawSqlQueryExecutor.cs:39).
    • Neither method documents a runtime "not relational" failure. The type-level remarks (IRawSqlQueryExecutor.cs:18-21) move that check to registration instead: the executor is registered only when the host's default physical data source is on a relational engine, so a Cosmos-default host (its own query language, no SQL command surface to parameterize) has no registration at all, and a service that injects this interface there fails when the container is validated. The gate is DataSourceEngines.For(defaultEngine).Capabilities.IsRelational in MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:411-413.
  • Why it's built this way: an interpolated FormattableString parameter, rather than a plain string plus a parameter array, keeps the parameterization automatic at the call site: a developer writes ordinary string interpolation and the implementation is responsible for turning each hole into a command parameter before the statement reaches the database.
  • Where it's used: implemented by EFRawSqlQueryExecutor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EFRawSqlQueryExecutor.cs:2), registered conditionally as scoped in MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:413 (covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/DependencyInjectionInfrastructureTests.cs). Exercised by the architecture fitness base MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Cqrs/RawSqlConventionTestsBase.cs and its ADC concrete test MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/RawSqlConventionTests.cs.

IUniqueConstraintViolationDetector

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUniqueConstraintViolationDetector.cs:31 · Level 0 · interface

  • What it is: a one-method classifier that answers whether a failed save was rejected for violating a unique constraint, so a handler whose pre-check lost a race against a concurrent insert can recognise the collision and return the same conflict the pre-check would have produced.
  • Depends on: nothing first-party. The file has no using directives at all, and the single method takes a BCL Exception (IUniqueConstraintViolationDetector.cs:42).
  • Concept introduced, classifying a provider error without naming the provider. [Rubric §3, Clean Architecture] assesses whether technology details stay outside the inner layers, and the interface remarks make the constraint concrete (IUniqueConstraintViolationDetector.cs:10-20): the error identity lives in a provider type, SQL Server reports the collision as SqlException.Number 2601 (unique index) or 2627 (primary key or unique constraint), and neither that type nor the EF Core DbUpdateException wrapping it is reachable from MMCA.Common.Application, which references MMCA.Common.Domain and no data provider. [Rubric §15, Best Practices & Code Quality] assesses whether a behavior rests on something stable: reading the exception message from a handler is what the layering constraint used to force, and the remarks reject it twice over, because the wording is a provider and locale detail that can change under a working handler, and matching on it drags provider vocabulary into a persistence-neutral layer. [Rubric §29, Resilience & Business Continuity] applies to the failure mode chosen here: an unclassified exception simply propagates, so a miss is safe rather than silent and the caller sees exactly the failure it would have seen with no detection at all (IUniqueConstraintViolationDetector.cs:26-29).
  • Walkthrough: one method, bool IsUniqueConstraintViolation(Exception exception) (IUniqueConstraintViolationDetector.cs:42). The contract is stated on the parameter rather than left to the implementation: the answer covers exception or anything in its inner-exception chain (IUniqueConstraintViolationDetector.cs:33-41), which is what makes it usable against EF's wrapping exception. It is synchronous and takes no cancellation token, because it inspects an object already in hand.
  • Why it's built this way: the question is declared in Application and answered in Infrastructure, where the provider types are already referenced, so swapping engines swaps the implementation and every handler that recovers from a lost insert race keeps working unchanged (IUniqueConstraintViolationDetector.cs:21-25).
  • Where it's used: implemented by SqlServerUniqueConstraintViolationDetector (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SqlServerUniqueConstraintViolationDetector.cs:31), which walks the inner-exception chain (SqlServerUniqueConstraintViolationDetector.cs:48-68) matching the two error numbers (:34 and :37) with a message fallback for engines that report the same rejection in words (:40, :43, matched at :63-64). It is registered with TryAddSingleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:121), deliberately TryAdd so a host on another engine can register its own implementation first and keep it (DependencyInjection.cs:118-120). Consumers inject it into the when clause of a catch: GetOrCreateMyBadgeHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:21 and :53), CreateSessionHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:27 and :60, where the catch drives a bounded manual-id retry), CreateBookmarkHandler (CreateBookmarkHandler.cs:20), SetLeaderboardParticipationHandler (SetLeaderboardParticipationHandler.cs:35), and PointsAwarder (PointsAwarder.cs:32). The framework-level registration replaced per-module copies: the ADC Conference module records that it no longer carries its own pair (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:28).

IUpdatePropertySetter<TEntity>

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUpdatePropertySetter.cs:13 · Level 0 · interface

  • What it is: a persistence-agnostic builder for the SET clause of a bulk update. A handler describes which properties change and to what, and Infrastructure translates the description into the provider's set-based UPDATE.
  • Depends on: System.Linq.Expressions (BCL, IUpdatePropertySetter.cs:1) only. It is the parameter type of IWriteRepository.ExecuteUpdateAsync (IRepository.cs:480), which the doc comment cross-references (IUpdatePropertySetter.cs:7).
  • Concept introduced, describing a SET clause without leaking EF Core. [Rubric §3, Clean Architecture] assesses whether the technology choice stays outside the inner layers; [Rubric §12, Performance & Scalability] assesses whether hot write paths avoid needless round trips, and a single set-based statement replaces the load, mutate, save cycle. The doc comment (IUpdatePropertySetter.cs:5-11) is explicit that the shape mirrors EF Core's SetPropertyCalls without referencing it, which is what keeps MMCA.Common.Application EF-free while still offering EF's most useful bulk primitive.
  • Walkthrough: two Set overloads, both generic in TProperty and both returning IUpdatePropertySetter<TEntity> so calls chain.
    • Set<TProperty>(Expression<Func<TEntity, TProperty>> property, TProperty value) (IUpdatePropertySetter.cs:20-22): assigns a fixed value, for example a status or a timestamp.
    • Set<TProperty>(Expression<Func<TEntity, TProperty>> property, Expression<Func<TEntity, TProperty>> valueFactory) (IUpdatePropertySetter.cs:33-35): assigns from an expression over the current database row. The doc comment (IUpdatePropertySetter.cs:24-28) gives the motivating case, quantity => quantity.Amount - 5, which becomes an atomic read-modify-write that the database itself arbitrates, so two racing callers cannot both win and no rowversion retry loop is needed.
  • Why it's built this way: passing an Action<IUpdatePropertySetter<TEntity>> (rather than a dictionary of property names or a prebuilt EF expression) keeps the call site strongly typed and refactor-safe while the concrete translation stays swappable per provider.
  • Where it's used: implemented by UpdatePropertySetterBuilder<TEntity> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/UpdatePropertySetterBuilder.cs:14), which collects each assignment as a delegate (UpdatePropertySetterBuilder.cs:16) and replays them onto EF Core's UpdateSettersBuilder<TSource> (UpdatePropertySetterBuilder.cs:52). It also records assigned property names (UpdatePropertySetterBuilder.cs:17 and :64) and exposes SetsProperty (UpdatePropertySetterBuilder.cs:49) so EFRepository<TEntity, TIdentifierType> can stamp LastModifiedOn and LastModifiedBy only when the caller did not (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:143-159), keeping audit fields correct on a path that bypasses the save pipeline (EFRepository.cs:20).

OutboxDeadLetter

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IOutboxAdministration.cs:80 · Level 0 · record (sealed, positional)

  • What it is: one dead-lettered outbox row flattened for an operator view: the handle to replay it, which source it came from, what it was, when it happened, how hard delivery was tried, and why the last attempt failed.
  • Depends on: nothing first-party (BCL Guid and DateTime). It is the element type returned by IOutboxAdministration.ListDeadLettersAsync (IOutboxAdministration.cs:30) and it shares that interface's file.
  • Concept introduced, projecting an operational row without projecting its payload. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether personal data is exposed only where it is needed; the type doc says outright that the event PAYLOAD is deliberately not projected, because it can carry personal data (ADR-005) and nothing an operator decides about a replay depends on reading it (IOutboxAdministration.cs:68-72). [Rubric §13, Observability & Operability] assesses whether an operator can see enough to act: RetryCount and LastError are what turn "this failed" into a decision, and OrderingKey says whether the row belongs to an ordered stream. See OutboxMessage for the persisted row this flattens.
  • Walkthrough: seven positional members, each documented on the record (IOutboxAdministration.cs:73-79): Id (:81), the outbox row id and the handle ReplayDeadLettersAsync takes; DataSource (:82), the source whose outbox table holds the row, and the same string the dataSource filters accept; EventType (:83), the stored event type name; OccurredOn (:84); RetryCount (:85), attempts made before the row was abandoned; the nullable LastError (:86) and nullable OrderingKey (:87).
  • Why it's built this way: a positional record gives an immutable, structurally comparable read model for free, and keeping it in the Application layer beside its interface means an admin surface can render dead letters without referencing the EF entity or the Infrastructure assembly.
  • Where it's used: built by the Infrastructure implementation's projection (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxAdministration.cs:86), from rows matching the dead-letter predicate ProcessedOn == null && RetryCount >= maxRetries (OutboxAdministration.cs:82), collected across sources and returned oldest first (OutboxAdministration.cs:101).

DataSourceKey

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/DataSourceKey.cs:15 · Level 1 · record struct (readonly)

  • What it is: the identity of a physical data source, a (DataSource Engine, string Name) pair, where Name distinguishes multiple databases on the same engine ("database per microservice").
  • Depends on: DataSource (Level 0, same namespace). No using directives at all.
  • Concept, physical-key comparison as the include-support test. [Rubric §8, Data Architecture] assesses explicit storage partitioning and routing. A readonly record struct gives correct structural equality with zero boilerplate, which is the whole point: Application code that needs to know whether two entities can be joined compares their DataSourceKey values. The doc comment (DataSourceKey.cs:6-11) stresses that Name is the physical source name produced by the Infrastructure resolver after collapsing logical names that share a connection string, so two logical names pointing at the same connection string end up with the same physical key (and are joinable), while genuinely distinct databases do not.
  • Walkthrough
    • The positional record DataSourceKey(DataSource Engine, string Name) (DataSourceKey.cs:15), parameters documented at DataSourceKey.cs:13-14.
    • DefaultName (DataSourceKey.cs:18): the const string "Default" reserved for the top-level ConnectionStrings section.
    • Default(DataSource engine) (DataSourceKey.cs:23): a static factory building the default key for an engine.
    • ToString() (DataSourceKey.cs:26): an override rendering "{Engine}/{Name}" for diagnostics.
  • Why it's built this way: making the key a value type with structural equality reduces the routing decision (same physical database, relational engine) to an equality comparison, and a host with no DataSources configuration collapses everything onto Default and behaves like a single-database monolith (ADR-006).
  • Where it's used: EntityDataSourceRegistry maps each entity type to a key and DataSourceResolver performs the logical-to-physical collapse; IDataSourceService resolves and compares them; DbContextFactory caches one context instance per key.

IDataSourceService

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IDataSourceService.cs:31 · Level 2 · interface

  • What it is: resolves which physical data source (DataSourceKey: engine plus database) backs a given entity type, and determines whether two entity types support EF Core .Include() between them, all without the Application layer touching EF or Infrastructure.
  • Depends on: DataSource (Level 0) and DataSourceKey (Level 1), both in the same namespace.
  • Concept introduced, multi-database routing exposed to the Application layer. [Rubric §8, Data Architecture] assesses deliberate database-per-service design, explicit routing, and the absence of accidental cross-database JOINs. The layer must decide whether a navigation between two entities can use an EF .Include(), which is valid only when both entities live in the same physical database and that engine is relational. This interface answers that question without an EF reference, keeping Application pure (ADR-006). The doc comment names the consumer directly (IDataSourceService.cs:25-30): NavigationMetadataProvider uses it to classify navigation properties as supported or unsupported includes.
  • Walkthrough: six members across three concerns, every one of them synchronous, because the resolution is a lookup against an eagerly built registry, not I/O.
    • GetDataSourceKey(Type entityType) (IDataSourceService.cs:36) and GetDataSourceKey(string entityFullName) (IDataSourceService.cs:41): resolve the physical key by CLR type or by full type name (the name overload exists because navigation metadata carries names, not resolved types).
    • GetDataSource(string entityFullName) (IDataSourceService.cs:46) and GetDataSource(Type entityType) (IDataSourceService.cs:51): resolve just the engine.
    • HaveIncludeSupport(DataSourceKey first, DataSourceKey second) (IDataSourceService.cs:61): the crux. The contract documented at IDataSourceService.cs:53-60 is that it returns true only when both keys identify the same physical database and the engine is relational, since Cosmos DB has no cross-document JOINs.
    • HaveIncludeSupport(string firstEntityFullName, string secondEntityFullName) (IDataSourceService.cs:70): the same test by entity name, resolving each side's key first.
  • Why it's built this way: ADR-006 replaces cross-service foreign keys with scalar columns and routes consistency through the outbox, so to build a query the Application layer must know the routing topology well enough to classify each navigation as include-able or manual-load-required. Exposing that as a narrow query interface (rather than handing Application the registry) keeps the collapse rules on the Infrastructure side.
  • Where it's used: consumed by NavigationMetadataProvider to drive the supported/unsupported include split, and indirectly by the query pipeline's eager-loading decisions. The Infrastructure implementation is a facade over EntityDataSourceRegistry.

IOutboxAdministration

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IOutboxAdministration.cs:16 · Level 3 · interface

  • What it is: the operator surface over the outbox tables a host owns: list the dead letters, replay them, and count the pending backlog.
  • Depends on: Result and its generic form (via MMCA.Common.Shared.Abstractions, IOutboxAdministration.cs:1) and OutboxDeadLetter, the record declared in the same file (IOutboxAdministration.cs:80).
  • Concept introduced, a way BACK into delivery for an undelivered event. [Rubric §13, Observability & Operability] assesses whether the system can be operated after something goes wrong, not just observed while it works. The interface doc puts the alternative plainly (IOutboxAdministration.cs:5-9): without this surface the only terminal states for a failed message are "eventually deleted by the retention sweep" and "edited by hand in production SQL". [Rubric §29, Resilience & Business Continuity] assesses recovery from partial failure, which is exactly what a replay is: the outbox (ADR-003) guarantees an event is recorded in the same transaction as its state change, and this interface is how a recorded-but-undelivered event gets another chance. [Rubric §9, API & Contract Design] shows up in the return type: every method returns Result, because an unreachable source or an unknown source name is an expected failure an operator screen renders, not an exception (IOutboxAdministration.cs:10-14).
  • Walkthrough: three methods, each taking a nullable dataSource filter where null means "every source this host owns", and each with an explicit (non-defaulted) cancellation token.
    • ListDeadLettersAsync(string? dataSource, int skip, int take, CancellationToken cancellationToken) (IOutboxAdministration.cs:30-34): the read. Dead-lettered means unprocessed with retries exhausted, and rows come back oldest first (IOutboxAdministration.cs:18-21) as OutboxDeadLetter records, paged by skip/take.
    • ReplayDeadLettersAsync(string? dataSource, IReadOnlyCollection<Guid>? ids, CancellationToken cancellationToken) (IOutboxAdministration.cs:51-54): returns rows to the pending pool and answers with the number of rows moved. The doc comment (IOutboxAdministration.cs:36-41) pins the semantics: RetryCount back to zero and the claim lease cleared, so the next poll cycle picks the rows up, while LastError is deliberately KEPT (the reason a message failed is the first thing anyone asks after a replay) and OccurredOn is untouched, so a replayed row keeps its place in its ordering key. A null or empty ids replays EVERY dead letter in the selected scope (IOutboxAdministration.cs:45-48), which is a wide operation stated as such.
    • CountPendingAsync(string? dataSource, CancellationToken cancellationToken) (IOutboxAdministration.cs:65): counts rows still awaiting dispatch. The doc comment draws the distinction that matters for operators (IOutboxAdministration.cs:56-61): unlike the outbox.pending.depth gauge, which reports what the processor last observed (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxMetrics.cs:76), this counts the tables at the moment of the call and includes rows currently under a claim lease.
  • Why it's built this way: declaring the operator surface in Application means an admin endpoint, a support command, or a scheduled job can drive it without referencing EF or the outbox entity, and the Result return keeps a bad source name on the same rails as any other user error. The retention sweep that would otherwise be a dead letter's only exit is OutboxCleanupService, whose own comment points at IOutboxAdministration.ReplayDeadLettersAsync as the alternative (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxCleanupService.cs:142).
  • Where it's used: implemented by OutboxAdministration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxAdministration.cs:35-42), registered with TryAddScoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:216-217), scoped because it creates one child scope per data source it visits and holds no state of its own (DependencyInjection.cs:228-229). The implementation caps one page at 500 rows so an admin call cannot ask for the whole table at once (OutboxAdministration.cs:40), rejects a negative skip or an out-of-range take as validation errors (OutboxAdministration.cs:42-46), and expresses replay as one set-based UPDATE per target (OutboxAdministration.cs:143-144) rather than as loaded entities.
  • Caveats / not-in-source: the framework registers the service but ships no controller over it. No MMCA.Common.API endpoint and no ADC or Store call site references ListDeadLettersAsync, ReplayDeadLettersAsync, or CountPendingAsync today; outside the registration the only callers in the workspace are the Infrastructure tests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/OutboxAdministrationTests.cs). A host that wants an operator screen wires its own endpoint or command over the injected interface.

IEntityQuerier<TEntity, TIdentifierType>

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:80 · Level 4 · interface

  • What it is: the set-returning half of the repository split: collection reads, projections, single-row and grouped aggregate reads, lookup pairs, counts, the specification-first block, and keyset paging. Fifteen members in all.
  • Depends on: AuditableBaseEntity<TIdentifierType> (the TEntity constraint, IRepository.cs:81), BaseLookup<TIdentifierType> (the lookup projection, from MMCA.Common.Shared.DTOs, IRepository.cs:5 and :224), ISpecification<TEntity, TIdentifierType> (from MMCA.Common.Domain.Interfaces, IRepository.cs:3, first used in a signature at :151), Result with KeysetPageRequest and KeysetCollectionResult<T> (from MMCA.Common.Shared.Abstractions, IRepository.cs:4, used at :318-319), and System.Linq.Expressions (IRepository.cs:1).
  • Concept introduced, the ISP-split repository ladder. [Rubric §1, SOLID] assesses whether clients depend only on the members they use. IRepository.cs defines a deliberate ladder of ever-wider interfaces so a handler declares exactly the surface it needs: IEntityReader (by-id lookups, IRepository.cs:21), IEntityQuerier (set-returning reads, this type, :80), IReadRepository (reader plus querier plus raw IQueryable, :332), IWriteRepository (mutations, :369), and IRepository (read plus write, :495). The doc comment says it outright (IRepository.cs:68-71): prefer this over IReadRepository when a handler needs GetAllAsync, GetProjectedAsync, or CountAsync. [Rubric §12, Performance & Scalability] also applies: every member here exists so the database answers the question. GetProjectedAsync<TResult> takes an Expression<Func<TEntity, TResult>> translated to SQL, FirstOrDefaultAsync returns one row instead of a materialized set filtered in memory, and CountByAsync/SumByAsync push a GROUP BY down instead of folding rows client-side. The querier is the rung that keeps growing: fifteen members against the reader's five, so "focused" here means reads that return sets or aggregates, not a small interface (ADR-055 and its Revision (2026-08-31), which records the five most recent additions).
  • Concept introduced, ignoreQueryFilters means soft-deleted rows and nothing else. [Rubric §11, Security] assesses whether a convenience flag can widen a security boundary. An interface-level <remarks> block (IRepository.cs:75-79) pins the contract: every ignoreQueryFilters parameter here drops the named SoftDelete filter and leaves the named Tenant filter applied, and the wording is repeated on each parameter that carries it (for example IRepository.cs:99-103 and :126-130). That is enforced downstream, where EFReadRepository<TEntity, TIdentifierType> passes a one-element filter-name array to EF's named IgnoreQueryFilters overload (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:41, used at :52, :81, :102, :198, :288, :365, :379, and :445-446) rather than EF's parameterless form; its own comment (EFReadRepository.cs:38) spells out that dropping both would let a caller asking to see deleted rows silently read every tenant's data. The two named filters come from ApplicationDbContext (ApplicationDbContext.cs:475 and :391).
  • Walkthrough
    • GetAllAsync(IEnumerable<string> includes, where?, orderBy?, select?, asTracking, ignoreQueryFilters, CancellationToken) (IRepository.cs:85-92): the general collection read with optional includes, filter, ordering, and same-type projection. Note includes is the one non-optional parameter, so a caller must state its eager-loading intent explicitly (pass [] for none).
    • GetProjectedAsync<TResult>(select, where?, asTracking, ignoreQueryFilters, CancellationToken) (IRepository.cs:105-110): SQL-side projection to an arbitrary result type.
    • FirstOrDefaultAsync(where, includes?, asTracking, ignoreQueryFilters, CancellationToken) (IRepository.cs:134-139): the single-row predicate read. The remarks (IRepository.cs:115-122) name the alternative it exists to remove, GetAllAsync followed by an in-memory FirstOrDefault, which materializes the whole matching set to keep one entity, and warn that no ordering is applied, so "first" is whatever the provider returns first.
    • FirstOrDefaultAsync(ISpecification<TEntity, TIdentifierType> specification, CancellationToken) (IRepository.cs:150-152): the deterministic counterpart. Unlike the predicate overload it honors the specification's ORDERING, so "first" means what the specification says it means (IRepository.cs:141-145).
    • CountByAsync<TKey>(keySelector, where?, CancellationToken) (IRepository.cs:169-173): a GROUP BY count, returning one dictionary entry per key that has at least one row. The remarks (IRepository.cs:158-163) explain why the member has to exist at all: the Application layer references no EF Core, so a handler needing a grouped count has no IQueryable to group and would otherwise project every matching row out of the database and group client-side. TKey is constrained notnull (IRepository.cs:173).
    • SumByAsync<TKey>(keySelector, sumSelector, where?, CancellationToken) (IRepository.cs:186-191): the grouped SUM, the counterpart of CountByAsync (IRepository.cs:175-179).
    • FindIncludingDeletedAsync(where, includes?, asTracking, CancellationToken) (IRepository.cs:217-221): the resurrection read (BR-135, IRepository.cs:199), returning a named tuple of Active and SoftDeleted matches. The remarks (IRepository.cs:197-208) give the whole rationale: a create handler whose natural key already exists as a soft-deleted row must reactivate that row rather than insert a duplicate the unique index will reject, and it needs both halves of the answer in one round trip, since an active match is a conflict, a soft-deleted match is the row to bring back, and neither is a plain insert. The asTracking parameter carries its own warning (IRepository.cs:211-214): a caller intending to reactivate wants true, otherwise the reactivation saves nothing. See ADR-005 for the soft-delete policy this read serves.
    • GetAllForLookupAsync(string nameProperty, where?, asTracking, CancellationToken) (IRepository.cs:224-228): returns lightweight BaseLookup<TIdentifierType> id/name pairs for dropdowns without materializing full entities. It has no ignoreQueryFilters parameter: a lookup list never offers deleted rows.
    • CountAsync(CancellationToken) (IRepository.cs:231) and CountAsync(Expression<Func<TEntity, bool>> where, CancellationToken) (IRepository.cs:234-236): total and predicated counts.
    • CountAsync(ISpecification<TEntity, TIdentifierType> specification, CancellationToken) (IRepository.cs:245-247): the specification-shaped count. The doc comment (IRepository.cs:238-241) states that ordering and paging on the specification are ignored deliberately, since a count of "page 3 of the matches" is never what a caller means.
    • ListAsync(ISpecification<TEntity, TIdentifierType> specification, CancellationToken) (IRepository.cs:262-264): runs a specification and returns the matching entities. The remarks (IRepository.cs:252-258) draw the line between the two specification shapes: a plain ISpecification contributes its Criteria only, while a QuerySpecification<TEntity, TIdentifierType> also contributes includes, ordering, paging, tracking, and soft-delete scope, so one object describes the whole read instead of five loose arguments.
    • ListAsync<TResult>(specification, Expression<Func<TEntity, TResult>> select, CancellationToken) (IRepository.cs:281-284): the projecting overload, so only the selected columns leave the database. The remarks (IRepository.cs:270-275) pin the ordering rule: the projection is applied after the specification's ordering and paging, so a paged specification still pages over entity rows and projects only that page, and includes on the specification are redundant here but not harmful.
    • AnyAsync(ISpecification<TEntity, TIdentifierType> specification, CancellationToken) (IRepository.cs:293-295): existence by specification, with ordering and paging ignored for the same reason as the specification CountAsync (IRepository.cs:287-288).
    • GetPageByCursorAsync(KeysetPageRequest request, ISpecification<TEntity, TIdentifierType>? specification = null, CancellationToken) (IRepository.cs:318-321): one keyset ("seek") page plus the cursor for the next one, and the only member here that returns a Result, because an unknown sort column and a malformed cursor are caller errors rather than exceptions and must never come back as a silent first page (IRepository.cs:308-312). The remarks (IRepository.cs:301-313) state the trade in full: one index seek regardless of scroll depth and no skipped or repeated rows, against no random page access and no total count, with exactly one sort key supported and Id as the tie-break.
  • Why it's built this way: splitting reads into a focused querier lets a handler signal its access pattern through its constructor dependency and keeps projection, aggregation, and counting off the by-id interface. Declaring the specification-first members here rather than on a separate interface is what keeps a handler that already depends on the querier from needing a second dependency to run a specification.
  • Where it's used: query handlers needing collections, aggregates, specifications, or a keyset page; folded into IReadRepository<TEntity, TIdentifierType> (IRepository.cs:333) and implemented concretely by EFReadRepository<TEntity, TIdentifierType> (for example GetProjectedAsync at EFReadRepository.cs:75, CountByAsync at :127, SumByAsync at :150, the projecting ListAsync at :464), with EFReadRepositoryDecorator<TEntity, TIdentifierType> wrapping every call in a MiniProfiler step (EFReadRepositoryDecorator.cs:17, and :45-83 for the newer members). The mechanics behind the specification-first block are walked through later in this chapter rather than repeated here: SpecificationEvaluator turns a specification into an IQueryable, KeysetQueryBuilder builds the ordering, the seek predicate, and the cursor encoding, and EFReadRepository<TEntity, TIdentifierType> owns the tracking and filter-scope policy those two are handed.
  • Caveats / not-in-source: GetProjectedAsync carries ignoreQueryFilters before the cancellation token (IRepository.cs:109-110), so any caller or test double that passes arguments positionally has to account for it; the compiler catches positional callers, but a mock configured with a fixed argument count does not fail until run time. The two FirstOrDefaultAsync overloads are public overloads that both carry optional parameters, which the RS0026 public-API analyzer rule forbids; each is annotated with a targeted [SuppressMessage("ApiDesign", "RS0026...")] (IRepository.cs:133 and :149) whose justification records them as grandfathered: released after the v1.152 baseline while RS0026/RS0027 were off, so changing either signature now would be a breaking change. A new overload on this interface does not inherit that exemption.

IEntityReader<TEntity, TIdentifierType>

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:21 · Level 4 · interface

  • What it is: the by-id half of the repository split: GetByIdAsync (two overloads), GetByIdsAsync, and ExistsAsync (two overloads), for handlers whose data access is a point lookup.
  • Depends on: AuditableBaseEntity<TIdentifierType> (the TEntity constraint, IRepository.cs:22) and System.Linq.Expressions for the predicate overload (IRepository.cs:1).
  • Concept, minimal data access as a declared dependency. [Rubric §1, SOLID] (Interface Segregation, introduced on IEntityQuerier) and [Rubric §8, Data Architecture] (deliberate, minimal access patterns). The doc comment is explicit (IRepository.cs:9-13): prefer this over IReadRepository<> when a handler only needs GetByIdAsync or ExistsAsync, because that signals minimal data access. This interface carries the same <remarks> contract as the querier (IRepository.cs:16-20): ignoreQueryFilters means "include soft-deleted rows" and nothing more, with the Tenant filter left in force. It is also the rung that does not move: every round of specification and aggregate growth has landed on the querier, so the reader is still five members.
  • Walkthrough
    • GetByIdAsync(TIdentifierType id, CancellationToken) (IRepository.cs:26-28): plain fetch, returns null when missing.
    • GetByIdAsync(TIdentifierType id, IEnumerable<string> includes, bool asTracking = false, CancellationToken) (IRepository.cs:31-35): the eager-load overload; include paths are navigation-property names.
    • GetByIdsAsync(ids, includes?, asTracking, ignoreQueryFilters, CancellationToken) (IRepository.cs:48-53): a single-query bulk fetch that replaces an N+1 loop of point lookups. The doc comment warns it may return fewer entities than requested when some ids do not exist (IRepository.cs:47), so the caller must reconcile, and the ignoreQueryFilters parameter is documented in full (IRepository.cs:41-45) rather than by reference.
    • ExistsAsync(TIdentifierType id, bool ignoreQueryFilters = false, CancellationToken) (IRepository.cs:56-59) and ExistsAsync(Expression<Func<TEntity, bool>> where, bool ignoreQueryFilters = false, CancellationToken) (IRepository.cs:62-65): existence checks by key or by predicate. The flag is what lets a handler ask whether a soft-deleted row exists, for example to detect a conflict when re-creating a record that was deleted earlier.
  • Why it's built this way: a handler that only needs a point lookup takes the narrowest interface, which reads clearly at the constructor and mocks in two lines in a test.
  • Where it's used: command handlers that load an aggregate before mutating it; folded into IReadRepository<TEntity, TIdentifierType> (IRepository.cs:333). Note that the GetByIdOrFailAsync extension on ReadRepositoryExtensions, which turns a miss into a Result failure carrying Error.NotFound, hangs off IReadRepository rather than this interface and is implemented over GetAllAsync (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ReadRepositoryExtensions.cs:12, :27, and :34-38), so a handler that wants it must take the wider read interface.

IReadRepository<TEntity, TIdentifierType>

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:332 · Level 5 · interface

  • What it is: the full read surface over an entity, combining IEntityReader<TEntity, TIdentifierType> (by-id lookups) and IEntityQuerier<TEntity, TIdentifierType> (collections, projections, specifications, grouped aggregates, and keyset pages), and adding four IQueryable<TEntity> properties for handlers that need raw LINQ (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:332-348).

  • Depends on: IEntityReader<TEntity, TIdentifierType> and IEntityQuerier<TEntity, TIdentifierType>, both declared in the same file and both listed as base interfaces (IRepository.cs:333); AuditableBaseEntity<TIdentifierType> as the TEntity constraint (IRepository.cs:334). Externals: BCL IQueryable<T> only. No EF Core type appears anywhere in the declaration, which is what keeps this interface legal in the Application layer.

  • Concept, the composition point of the ISP ladder, and controlled IQueryable exposure. [Rubric §1, SOLID] assesses whether clients depend only on the members they use. IRepository.cs defines a ladder of ever-wider interfaces so a handler declares exactly the surface it needs: IEntityReader (five members, IRepository.cs:21), IEntityQuerier (fifteen members, IRepository.cs:80), this type (both of those plus four properties, twenty-four members in total, IRepository.cs:332), IWriteRepository (IRepository.cs:369), and IRepository (read plus write, IRepository.cs:497). The doc comment records a migration stance rather than a ban (IRepository.cs:324-329): existing code should continue using this interface, and new handlers can depend on the focused sub-interfaces for better ISP compliance.

    [Rubric §12, Performance & Scalability] assesses whether expensive query behavior is a deliberate choice. The four properties turn EF's tracking mode and query-splitting mode into a named decision at the call site: asking for TableNoTrackingSplitQuery is visible in review, where a plain DbSet would silently track every row and emit one cartesian join.

  • Walkthrough: this interface declares no methods of its own. Its whole body is four get-only IQueryable<TEntity> properties.

    • Table (IRepository.cs:338): the base queryable with change tracking enabled, the shape a mutation path composes over. Implemented as the raw DbSet (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:526).
    • TableNoTracking (IRepository.cs:341): the no-tracking queryable, documented as best for read-only queries and implemented as Entities.AsNoTracking() (EFReadRepository.cs:529).
    • TableNoTrackingSingleQuery (IRepository.cs:344): no-tracking, pinned to a single SQL statement through AsSingleQuery() (EFReadRepository.cs:532).
    • TableNoTrackingSplitQuery (IRepository.cs:347): no-tracking in split-query mode, which avoids the cartesian explosion that collection includes cause, through AsSplitQuery() (EFReadRepository.cs:535). It is the same concern IQueryableExecutor addresses for callers that hold an IQueryable rather than a repository.
    • The twenty inherited members come from the two base interfaces and are walked through on their own sections: five point-lookup members on IEntityReader, fifteen set-returning members on IEntityQuerier.
  • Why it's built this way: the framework needed one interface a query handler can take when it genuinely uses the whole read surface, without forcing every handler to take it. Keeping the composition free of new methods means the two halves stay independently declarable and independently mockable, and it gives the profiling decorator one surface to wrap. Naming the four queryables separately, rather than exposing a DbSet, is what makes tracking and split-query opt-in rather than accidental. The ladder itself is ADR-055.

  • Where it's used: obtained through IUnitOfWork.GetReadRepository<TEntity, TIdentifierType>() (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:29), which is the sanctioned way to get one; implemented by EFReadRepository<TEntity, TIdentifierType> (EFReadRepository.cs:21-25) and wrapped in EFReadRepositoryDecorator<TEntity, TIdentifierType> (EFReadRepositoryDecorator.cs:17) by RepositoryFactory only when MiniProfiler is enabled (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:55-63). EntityQueryService<TEntity, TEntityDTO, TIdentifierType> derives its Repository property from the unit of work at construction (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:88). It is also the receiver of the GetByIdOrFailAsync extension on ReadRepositoryExtensions, which turns a miss into a Result failure carrying Error.NotFound and is implemented over GetAllAsync rather than GetByIdAsync (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ReadRepositoryExtensions.cs:12, :27, :34-44), so a handler that wants that convenience must take this wider interface rather than IEntityReader.

  • Caveats / not-in-source: a mutation path must not compose its query off TableNoTracking and then expect the save to persist, because one no-tracking source makes the whole composed query untracked. That constraint is not stated in this file; it follows from EF's tracking semantics.

IWriteRepository<TEntity, TIdentifierType>

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:369 · Level 5 · interface

  • What it is: the write half of the repository abstraction, over an aggregate root: AddAsync, AddRangeAsync, UpdateAsync, UpdateRange, two SetOriginalRowVersion overloads, TouchConcurrencyToken, ExecuteDeleteAsync, and ExecuteUpdateAsync (IRepository.cs:369-484). Nine members, and not one of them saves.

  • Depends on: AuditableAggregateRootEntity<TIdentifierType> as the TEntity constraint (IRepository.cs:370), IRowVersioned for the child-concurrency overload (written by its qualified Domain.Interfaces.IRowVersioned name, IRepository.cs:419), and IUpdatePropertySetter<TEntity> for the set-based update builder (IRepository.cs:482). Externals: System.Linq.Expressions (IRepository.cs:1).

  • Concept introduced, writes enter through the aggregate root, and the repository never flushes. [Rubric §4, DDD] assesses whether the aggregate boundary is an enforced rule rather than a naming convention. The constraint here is the narrower AuditableAggregateRootEntity<TIdentifierType>, not the AuditableBaseEntity<TIdentifierType> the read side accepts, and the doc comment says exactly why (IRepository.cs:353-360): a write enters the aggregate through its root so that the root's invariants are enforced and its domain events are collected, and a repository over a child entity would let a caller persist a change the root never saw. Reading a child directly stays harmless and stays supported through IReadRepository<TEntity, TIdentifierType>. The rule is enforced by the compiler, not by review: asking for a write repository over a child entity does not compile.

    [Rubric §8, Data Architecture] assesses whether persistence is deliberate: one save boundary, one concurrency story. This interface has no Save and no SaveChangesAsync. The doc comment states the division (IRepository.cs:361-365): the repository stages changes and never flushes them, because persisting is the unit of work's job, so every repository touched in a scope is written as one unit under one audit stamp. A handler that mutates through a repository and then forgets IUnitOfWork.SaveChangesAsync has written nothing.

  • Concept introduced, optimistic-concurrency wiring and change-tracking-bypass writes. Five members carry the weight.

    • SetOriginalRowVersion(TEntity entity, byte[] rowVersion) (IRepository.cs:408): plants the client's last-observed RowVersion as the tracked entity's original concurrency token, so the next save emits its WHERE RowVersion = @original and raises DbUpdateConcurrencyException when the row moved since the client read it (IRepository.cs:401-405). The token only ever arrives through If-Match, so [SupportsIfMatch] answers that exception as 412 Precondition Failed, and the doc comment itself names that status and the attribute (IRepository.cs:403-404; the child overload's comment likewise promises "the same stale-token 412 protection", IRepository.cs:413) (MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:130-138, ADR-035). The implementation sets OriginalValue on the tracked entry's RowVersion property and rejects a null token outright (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:76-85).
    • SetOriginalRowVersion(Domain.Interfaces.IRowVersioned childEntity, byte[] rowVersion) (IRepository.cs:419): the same protection for a tracked child of the aggregate, for example a ProductVariant under a Product. The doc comment explains why a second overload exists at all (IRepository.cs:410-416): the repository's TEntity is the root, so the typed overload cannot reach children, and this one accepts any IRowVersioned entity instead (ADR-035). It reaches the entry through an (object) cast (EFRepository.cs:87-96).
    • TouchConcurrencyToken(TEntity entity) (IRepository.cs:442): forces the aggregate ROOT to take part in the next save when a conditional write left it untouched, so the precondition the caller sent is actually evaluated (SEC-Common-77). The remarks explain the gap it closes (IRepository.cs:426-441): SetOriginalRowVersion only stamps the tracked entry's ORIGINAL value, it does not make the entry dirty, so an applier that changes only child rows leaves the root Unchanged, EF emits no root UPDATE, no WHERE RowVersion = @token reaches the database, and the stale-token check silently does not fire, letting two administrators editing different children of the same aggregate from the same ETag both get 200 while the second silently discards the first's edit. Touching the root restores the 412 ADR-035 promises. It is declared with a default no-op body (IRepository.cs:442-445) so an existing implementer stays source- and binary-compatible; a repository that does not override it keeps the pre-hardening behaviour rather than failing to compile. EFRepository<TEntity, TIdentifierType> overrides it.
    • ExecuteDeleteAsync(Expression<Func<TEntity, bool>> where, CancellationToken) (IRepository.cs:455-457): a set-based delete run directly in the database, one statement, no change tracker. The doc comment warns in capitals that it does not trigger domain events, audit stamps, or soft delete, and is for maintenance scenarios only (IRepository.cs:447-454). The implementation is a one-liner over the DbSet (EFRepository.cs:119-125).
    • ExecuteUpdateAsync(where, Action<IUpdatePropertySetter<TEntity>> setProperties, CancellationToken) (IRepository.cs:480-483): a set-based UPDATE ... SET ... WHERE ... as one atomic statement. [Rubric §12, Performance & Scalability] applies alongside §8 here, and the long doc comment (IRepository.cs:459-479) is the teaching text for contention-proof conditional updates: guard the update inside where (the worked example is a stock decrement guarded by AvailableQuantity >= @qty), and then zero rows affected means the guard did not hold, so two racing callers can never both win and no rowversion retry loop is needed, because the database itself arbitrates. It also draws the exact boundaries: domain events are bypassed, global query filters (soft delete) DO apply to where, audit fields are NOT bypassed, and the statement runs on the ambient transaction when one is active, so a decrement rolls back with its caller. The audit guarantee is not something the database does; it is compensation code in the implementation, which stamps LastModifiedOn from the injected TimeProvider and LastModifiedBy from ICurrentUserService unless the caller assigned them explicitly, rejects an empty setter list, and, on an engine with an application-stamped row version (PostgreSQL, SQLite, RowVersionStrategy.ClientStamped), re-stamps RowVersion with a fresh GUID unless the caller set it, so a client still holding the pre-update token gets a concurrency conflict instead of passing its If-Match check (SQL Server's rowversion moves by itself; IRepository.cs:469-471, EFRepository.cs:157, :162-176) (EFRepository.cs:136-159, with the two optional constructor dependencies at EFRepository.cs:24-28).
  • Walkthrough: the nine members in teaching order.

    • AddAsync(TEntity entity, CancellationToken) (IRepository.cs:377-379) and AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken) (IRepository.cs:385-387): single and batch inserts, staged on the change tracker.
    • UpdateAsync(TEntity entity, CancellationToken) (IRepository.cs:393-395) and UpdateRange(IEnumerable<TEntity> entities) (IRepository.cs:399): mark tracked entities modified. UpdateRange is the one void member of the pair, since batch marking needs nothing awaited.
    • The two SetOriginalRowVersion overloads (IRepository.cs:408, :419), TouchConcurrencyToken (IRepository.cs:442), and the two database-side operations (IRepository.cs:455, :478) described above.
  • Why it's built this way: keeping writes in a focused interface means a query handler cannot accidentally acquire mutation methods, and the concurrency and set-based escape hatches are declared where a reader meets their warnings rather than buried in a concrete class. Constraining TEntity to the aggregate root mirrors the constraint on IUnitOfWork.GetRepository (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:20), which is how a handler is meant to obtain one, so the two agree by construction. Leaving Save off the interface entirely is what makes the single save boundary of ADR-006 enforceable: with several physical databases in one host, "save" cannot mean "save this repository".

  • Where it's used: command handlers that mutate aggregates, always through IUnitOfWork.GetRepository<TEntity, TIdentifierType>() (IUnitOfWork.cs:19); folded into IRepository<TEntity, TIdentifierType> (IRepository.cs:497); implemented by EFRepository<TEntity, TIdentifierType> (EFRepository.cs:24-30), which derives from EFReadRepository<TEntity, TIdentifierType> and adds only the write surface, and is wrapped in EFRepositoryDecorator<TEntity, TIdentifierType> when MiniProfiler is on (RepositoryFactory.cs:31-41). Both apps exercise the specialized members: in MMCA.Store, ChangeVariantPriceHandler calls the child-typed SetOriginalRowVersion so a race on one product variant conflicts even when the product row itself is untouched (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/UseCases/ChangeVariantPrice/ChangeVariantPriceHandler.cs:45-47, with the same pattern in ChangeVariantSkuHandler.cs:69); in MMCA.ADC, ScoreEventSessionsHandler uses ExecuteDeleteAsync to clear a session's previous AI scores before re-adding them (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringRunner.cs:29, :105-107).

  • Caveats / not-in-source: ExecuteDeleteAsync and ExecuteUpdateAsync bypass the domain-event capture that DomainEventSaveChangesInterceptor performs on save, so anything downstream that reacts to an event will not observe those writes. The interface says so in its doc comments; nothing in the type system stops it, so it stays a review rule rather than a compiler rule.

IRepository<TEntity, TIdentifierType>

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:497 · Level 6 · interface

  • What it is: the combined read-write repository over an aggregate root, extending both IReadRepository<TEntity, TIdentifierType> and IWriteRepository<TEntity, TIdentifierType>, so a command handler that reads an aggregate and then mutates it takes a single dependency (IRepository.cs:497).
  • Depends on: IReadRepository<TEntity, TIdentifierType> and IWriteRepository<TEntity, TIdentifierType>, both named as bases on IRepository.cs:497, and AuditableAggregateRootEntity<TIdentifierType> as the TEntity constraint (IRepository.cs:498).
  • Concept, the top of the ISP ladder, and where the aggregate-root constraint wins. [Rubric §1, SOLID] and [Rubric §4, DDD]. The interface is purely compositional: it declares no members of its own, only two base interfaces and two constraints (IRepository.cs:497-499). What matters is which constraint survives the composition. The read side accepts any AuditableBaseEntity<TIdentifierType> (IRepository.cs:334) and the write side only aggregate roots (IRepository.cs:370), so the combination inherits the narrower bound, and the doc comment states the consequence for the reader (IRepository.cs:489-493): a handler that only reads, and reads a child entity, depends on IReadRepository instead, which still accepts any auditable entity.
  • Walkthrough: no members. The declaration is a semicolon-terminated interface with two bases and two generic constraints (IRepository.cs:497-499), the C# shorthand for an empty body. Everything a caller can do through it is walked through on the two base sections, and beneath them on IEntityReader and IEntityQuerier.
  • Why it's built this way: keeping the combined interface empty means the read and write surfaces each stay independently usable and independently mockable, while a handler that genuinely needs both still takes one constructor parameter. Composition rather than a fresh member list is also what keeps the ladder honest: there is exactly one definition of "read" and one of "write" in the codebase, and the widest rung is a deliberate choice a reviewer can see at the constructor rather than a default.
  • Where it's used: obtained through IUnitOfWork.GetRepository<TEntity, TIdentifierType>() (IUnitOfWork.cs:19-21), which is the sanctioned way to get one; implemented by EFRepository<TEntity, TIdentifierType> (EFRepository.cs:24-30) and produced, optionally wrapped for profiling, by RepositoryFactory (RepositoryFactory.cs:26-42). UnitOfWork caches the instance per closed generic interface type, so typeof(IRepository<Order, int>) is the cache key for a whole scope (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:52, :43).
  • Caveats / not-in-source: AddInfrastructure does register the open generic IRepository<,> against EFRepository<,> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:128), which makes constructor-injecting IRepository<,> look supported. It is not the intended path: EFRepository<TEntity, TIdentifierType> takes a bare DbContext constructor parameter (EFRepository.cs:24-25) and that registration file adds no DbContext service, so a direct injection sidesteps both the data-source resolution and the per-scope repository cache that GetRepository performs (UnitOfWork.cs:52-60). Ask the unit of work.

IUnitOfWork

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:10 · Level 7 · interface

  • What it is: the one coordination point a handler uses to touch the database. It hands out typed repositories (read-write for aggregate roots, read-only for any entity), persists everything pending in one call, and exposes controlled transaction and explicit-key-insert operations. The doc comment states the contract in two sentences (IUnitOfWork.cs:5-9): it coordinates persistence across multiple repositories within a single database context, and SaveChangesAsync persists all pending changes and dispatches domain events raised by tracked aggregates.

  • Depends on: AuditableAggregateRootEntity<TIdentifierType> and AuditableBaseEntity<TIdentifierType> as the two constraint bounds (IUnitOfWork.cs:1, :20, :30); IRepository<TEntity, TIdentifierType> and IReadRepository<TEntity, TIdentifierType> as the two return types (IUnitOfWork.cs:19 and :29). Externals: BCL IDisposable and IAsyncDisposable, both declared as base interfaces (IUnitOfWork.cs:10). Notably absent: anything from Microsoft.EntityFrameworkCore, which is the point of the type.

  • Concept introduced, the Unit of Work as the Application layer's only persistence verb. [Rubric §8, Data Architecture] assesses whether persistence is deliberate: one save boundary, one transaction story, explicit concurrency and audit handling rather than scattered SaveChanges calls. A unit of work is the scope inside which a caller sees a consistent view of the data and inside which all of its changes either land together or not at all. Here that scope is the DI scope: UnitOfWork is registered TryAddScoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:130), it caches one repository per closed generic interface type (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:34, :37-45), and every repository it hands out is bound to a context obtained from the same IDbContextFactory (UnitOfWork.cs:56), so two handlers in one request share one change tracker instead of racing two.

    [Rubric §3, Clean Architecture] assesses whether the inner layers declare intent while the outer layers own the technology. This interface lives in MMCA.Common.Application and names no EF type, which is exactly what lets the architecture fitness rule "Application must not depend on EF Core, use IRepository/IUnitOfWork" hold (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Layering/ArchitectureRules.Purity.cs:53-63). The EF-shaped work (a context per physical source, execution strategies, the engine-specific explicit-key insert SQL) sits behind it in MMCA.Common.Infrastructure.

    [Rubric §6, CQRS & Event-Driven] assesses whether events are raised and delivered from a single, reliable place. SaveChangesAsync is that place: the doc comment (IUnitOfWork.cs:7-8) ties persistence and domain-event dispatch into one operation, and the mechanism behind it is the interceptor plus outbox flow described on DomainEventSaveChangesInterceptor.

    [Rubric §14, Testability] assesses whether the abstraction a handler depends on can be replaced without infrastructure. Because a handler asks this interface for its repositories rather than receiving them, one Mock<IUnitOfWork> substitutes the entire data layer; the framework ships that scaffold as HandlerTestBase<THandler>, which pre-stubs SaveChangesAsync to return 1 (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Support/HandlerTestBase.cs:42) and wires each registered repository mock into both GetRepository and GetReadRepository (HandlerTestBase.cs:61-62).

  • Walkthrough: five members, in three groups.

    • GetRepository<TEntity, TIdentifierType>() (IUnitOfWork.cs:19-21): the read-write repository. Its constraint is where TEntity : AuditableAggregateRootEntity<TIdentifierType> (IUnitOfWork.cs:20), so the DDD rule that the doc comment states in prose ("Only aggregate roots can be directly persisted", IUnitOfWork.cs:14) is enforced by the compiler. In the implementation the call resolves the entity's physical data source through IDataSourceService, fetches the matching context, builds the repository through IRepositoryFactory, and caches it under the closed interface type (UnitOfWork.cs:52-60).
    • GetReadRepository<TEntity, TIdentifierType>() (IUnitOfWork.cs:29-31): the read-only repository, constrained only to AuditableBaseEntity<TIdentifierType> (IUnitOfWork.cs:30), so child entities are readable even though they are not independently writable. Same resolution path, different factory method (UnitOfWork.cs:72-80).
    • SaveChangesAsync(CancellationToken cancellationToken = default) (IUnitOfWork.cs:36): persists everything and returns the number of state entries written (IUnitOfWork.cs:35). The implementation is a straight delegation (UnitOfWork.cs:84-85) to DbContextFactory, which saves every cached context, not just one: it snapshots the contexts and loops in bounded passes (three, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:64) so a context materialized by a domain event handler mid-save is still saved (DbContextFactory.cs:283-297), then throws if any tracked change is left behind rather than losing it silently (DbContextFactory.cs:305-316).
    • RequestExplicitKeyInsert() (IUnitOfWork.cs:45): a one-shot flag saying the next save may insert rows that carry explicit values for store-generated keys, for example rows imported from an external system with their source ids intact (IUnitOfWork.cs:38-44). The contract is engine-neutral: on an engine that refuses such a value unless it is switched on per table, the save pipeline does that around those inserts, and on every other engine the save runs unchanged (IUnitOfWork.cs:41-42). The implementation sets a boolean (UnitOfWork.cs:88, DbContextFactory.cs:322); the next SaveChangesAsync reads and immediately clears it (DbContextFactory.cs:273-274, which is what "automatically cleared after the save completes" means) and, for a context whose engine exposes an IExplicitKeyInsertDialect through IDataSourceEngine.ExplicitKeyInsert (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/IDataSourceEngine.cs:119), routes the save through SaveWithExplicitKeyInsertAsync (DbContextFactory.cs:293-295). Only SQLServerDataSourceEngine supplies a dialect (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:49, the SET IDENTITY_INSERT ON/OFF toggle per DbContextFactory.cs:264-267); the SQLite, PostgreSQL and Cosmos engines return null, so their save is the plain one. With a dialect, the save asks it for the affected entries grouped by table as ExplicitKeyInsertGroup values (DbContextFactory.cs:335), pins one open connection for the whole save because the toggle is session state (DbContextFactory.cs:349-359, closed again at :364-368), and saves each group in its own round with the toggle on, because SQL Server allows only one table per session to have it on (DbContextFactory.cs:324-329, SaveExplicitKeyGroupsAsync at :377).
    • ExecuteInTransactionAsync<TResult>(Func<CancellationToken, Task<TResult>> operation, CancellationToken cancellationToken = default) (IUnitOfWork.cs:63-65): the interface's only transaction-control member; the manual Save(), BeginTransaction(), CommitTransaction(), and RollbackTransaction() members it used to sit alongside are gone from the interface. Its doc comment (IUnitOfWork.cs:47-59) states the reason: the operation runs wrapped by the active execution strategy, so a retrying strategy such as SqlServerRetryingExecutionStrategy can retry the whole transaction as one retriable unit, committing on success and rolling back before an exception propagates. The same comment carries the save rule (IUnitOfWork.cs:53-58): the commit does not save the operation's work, so the operation must save before it returns; the one exception is an internal-command row scheduled after the last save, which is saved just before the commit, and any other change still tracked at that point throws InvalidOperationException and rolls the unit back rather than being silently discarded. The implementation adds six behaviors worth knowing (DbContextFactory.cs:551-596, with the attempt runner at :589-647): a nested call joins the ambient transaction instead of opening a second one (DbContextFactory.cs:562-563); a returned failed Result rolls back exactly like an exception (DbContextFactory.cs:615-622); before committing, FlushEnrolledCommandsBeforeCommitAsync (DbContextFactory.cs:625, :659-685) saves the unit when every pending entry is an added InternalCommandMessage and otherwise throws the unsaved-changes exception (DbContextFactory.cs:690-704); each retry starts from a reset change tracker, so entities a failed attempt added are not inserted twice (DbContextFactory.cs:580-581, ResetForRetry at :814-821); deferred in-process domain events are flushed only after a successful commit (DbContextFactory.cs:636-643); and a failure of the commit itself is never retried, surfacing as TransactionCommitAmbiguousException thrown outside the strategy instead (DbContextFactory.cs:588-593).
    • The two base interfaces (IUnitOfWork.cs:10) matter at scope teardown: UnitOfWork forwards both disposal paths to the context factory (UnitOfWork.cs:96-122), so the async path awaits _dbContextFactory.DisposeAsync() (UnitOfWork.cs:106) rather than blocking.
  • Why it's built this way: under ADR-006 a single host may own several physical databases, so "save" cannot mean "call SaveChanges on the one context". Splitting the responsibility keeps that manageable: IDbContextFactory owns multi-source routing, saving, transactions and disposal, while IUnitOfWork is the narrow per-scope facade the Application layer is allowed to see, adding only repository resolution and caching on top (UnitOfWork.cs:13, :23). Exposing ExecuteInTransactionAsync as the interface's only transaction-control member, rather than the manual BeginTransaction/CommitTransaction/RollbackTransaction/Save set it replaced, is what makes the ADR-014 pipeline's transaction rules enforceable in one place: business failures roll back and post-commit event dispatch is deferred. Keeping the interface in Application rather than Infrastructure is the ADR-013 and Clean Architecture pairing, since a handler returns a Result and never sees an EF type on the way there.

  • Where it's used: injected into command handlers across the framework and both apps. In MMCA.Common: TransactionalCommandDecorator<TCommand, TResult> takes it in its primary constructor (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TransactionalCommandDecorator.cs:24) and calls ExecuteInTransactionAsync for any command marked ITransactional (TransactionalCommandDecorator.cs:33); DeleteEntityHandler<TEntity, TIdentifierType> takes it and resolves its write repository from it (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/DeleteEntityHandler.cs:37, :70); EntityQueryService<TEntity, TEntityDTO, TIdentifierType> both holds it and derives its read repository from it (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:33, :43, :87). In MMCA.ADC, RefreshFromSessionizeHandler is the live consumer of the explicit-key insert path, calling RequestExplicitKeyInsert() immediately before the save because Sessionize imports preserve external ids into tables with store-generated keys (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:192-193). The single implementation is UnitOfWork (UnitOfWork.cs:13), which is internal sealed, so consumers only ever see this interface.

  • Caveats / not-in-source: ExecuteInTransactionAsync is not a distributed transaction: with several physical sources each gets its own transaction and commits are sequential and best effort, so a commit failure on the second source leaves the first already committed. The doc comment names that limitation and records that the thrown TransactionCommitAmbiguousException names each source's outcome (committed, ambiguous, or rolled back) so the partial state is observable rather than inferred, and that the caller's replay is what reconciles it (DbContextFactory.cs:539-549). A witness row that would let a replay learn what landed is deliberately not built, since a single transactional source needs none (DbContextFactory.cs:545-548); the outbox delivers whatever the commit did make durable (DbContextFactory.cs:536-537).

BlobNames

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/BlobNames.cs:10 · Level 0 · class (public static)

  • What it is: a dependency-free static helper that reduces a user-supplied file name to a blob-safe segment built only from A-Z a-z 0-9 . _ - (BlobNames.cs:33-119).
  • Depends on: nothing first-party; BCL StringBuilder.
  • Concept introduced, file-name sanitization as an upload trust boundary. [Rubric §11, Security] assesses whether untrusted input (here, a caller-chosen file name that becomes part of a storage path and later a Content-Disposition header) is normalized before use rather than passed through. This is the naming half of the document-upload path; DocumentContentSniffer and FileUploadOptions are its companions, mirroring how ImageContentSniffer and IImageProcessor pair up for avatars.
  • Walkthrough: one public method plus two private helpers.
    • SanitizeFileName(string fileName, int maxLength = 100) (BlobNames.cs:48-89): trims trailing separators, splits the last . into stem and extension, falls back to the literal file when nothing safe survives, lower-cases the extension, and truncates the stem so the whole result fits maxLength; the extension itself is never sacrificed, so a result can exceed maxLength only when the extension alone is longer than the budget.
    • KeepSafeCharacters(string fileName) (BlobNames.cs:95-117): rewrites the name over the safe alphabet, collapsing any run of unsafe characters to one - and any run of dots to one ..
    • ToLowerAscii(string value) (BlobNames.cs:108-116): a hand-rolled ASCII lower-case so the result is independent of the host's culture.
  • Why it's built this way: collapsing runs rather than dropping characters one-by-one keeps a long run of spaces or path separators from producing a long run of dashes, and preserving the extension unconditionally keeps a sanitized name usable by whatever later code inspects it (for example DocumentContentSniffer, which reads only the extension).
  • Where it's used: UploadSessionAssetHandler sanitizes the caller's file name before building the blob name (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/UploadFile/UploadSessionAssetHandler.cs:1). It has its own dedicated unit-test class (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/BlobNamesTests.cs).

DocumentFormats

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/DocumentContentSniffer.cs:9 · Level 0 · enum

  • What it is: a [Flags]-shaped enum of the document formats DocumentContentSniffer can recognize: None, Pdf, Presentation, WordDocument, Spreadsheet, a combined OfficeOpenXml, Zip, PlainText, Markdown, and a combined All (DocumentContentSniffer.cs:155-186).
  • Depends on: nothing; it is a plain BCL enum with bitwise-combinable members.
  • Concept introduced: none on its own; it is the vocabulary DocumentContentSniffer.Detect uses to let a caller declare which formats it accepts and to report which one it found.
  • Walkthrough: OfficeOpenXml is the bitwise-or of Presentation | WordDocument | Spreadsheet, and All is the bitwise-or of every leaf value; both let a caller pass one flag to accept a whole family instead of enumerating members.
  • Why it's built this way: [Flags] lets a caller such as the session-asset upload path accept exactly the subset it wants (for example documents and zips but not plain text) with a single value.
  • Where it's used: the allowed parameter of both DocumentContentSniffer.Detect overloads; passed by UploadSessionAssetHandler and UploadSessionAssetCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/UploadFile/UploadSessionAssetCommandValidator.cs:1).

FileUploadOptions

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/FileUploadOptions.cs:13 · Level 0 · record (sealed)

  • What it is: an immutable options record carrying the response headers a blob upload should be stored with, ContentDisposition and CacheControl (FileUploadOptions.cs:28-34), plus factory methods that build a correctly-encoded Content-Disposition from a user-facing file name.
  • Depends on: nothing first-party; BCL StringBuilder, Encoding.UTF8.
  • Concept introduced, RFC 6266/5987-correct Content-Disposition encoding. [Rubric §8, Data Architecture] assesses whether a storage-layer concern (here, the HTTP headers a blob is served with) is centralized in one place rather than hand-built at each call site. The static None instance (FileUploadOptions.cs:21) sets no headers, so a caller only reaches for Attachment/Inline when it actually wants a download disposition.
  • Walkthrough: two public factory methods and three private helpers.
    • Attachment(string fileName, string? cacheControl = null) (FileUploadOptions.cs:48-49): builds an attachment disposition, so the browser downloads the blob under fileName instead of rendering it.
    • Inline(string fileName, string? cacheControl = null) (FileUploadOptions.cs:59-60): builds an inline disposition, so the browser renders the blob when it can and otherwise downloads it under fileName.
    • ForDisposition(string dispositionType, string fileName, string? cacheControl) (FileUploadOptions.cs:62-85): trims the name (falling back to file when blank), then emits both an ASCII filename="..." fallback and a percent-encoded UTF-8 filename*=UTF-8''... parameter in the same header, per RFC 6266.
    • ToAsciiFallback(string fileName) (FileUploadOptions.cs:91-106) and ToRfc5987(string fileName) (FileUploadOptions.cs:112-129): the two encodings behind the header; the ASCII fallback drops quotes, backslashes, semicolons and line breaks and replaces the rest of non-printable-ASCII with _, while the RFC 5987 form percent-encodes every UTF-8 byte outside the attr-char alphabet (FileUploadOptions.cs:135-139).
  • Why it's built this way: emitting both an ASCII fallback and the UTF-8 filename* parameter in one header lets older clients that ignore filename* still get a usable name, while modern clients get the exact, non-ASCII-safe original; centralizing the encoding here keeps IFileStorageService implementations from having to get RFC 5987 right themselves.
  • Where it's used: the options parameter of IFileStorageService's document-upload overload (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/IFileStorageService.cs:37), implemented by AzureBlobFileStorageService and NullFileStorageService; called by UploadSessionAssetHandler to attach a download name to an uploaded document.

ImageContentSniffer

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/ImageContentSniffer.cs:10 · Level 0 · class (public static)

  • What it is: a dependency-free static helper that decides whether uploaded bytes are a JPEG, PNG, or WebP image by inspecting the leading magic bytes, never the client-declared content type or file extension.
  • Depends on: nothing first-party (BCL ReadOnlySpan<byte>; the file has no using directives). It is the upload-side companion to IImageProcessor.
  • Concept introduced, magic-byte content sniffing as an upload trust boundary. [Rubric §11, Security] assesses whether untrusted input is validated by its actual content rather than by a spoofable client-supplied MIME type or file extension; this type is the framework's answer for binary uploads. [Rubric §26, Front-End Security] also applies at the origin of an avatar upload, because the browser-supplied Content-Type is exactly what this code refuses to trust. The doc comment (ImageContentSniffer.cs:3-9) frames the division of labor under ADR-045: the sniffer narrows accepted inputs to jpeg/png/webp, then the caller hands content to the processor whose re-encoding keeps only pixels, while app-specific size limits and error codes stay in the calling handler.
  • Walkthrough: four span-based predicates, all expression-bodied and all public.
    • IsAllowedImage(ReadOnlySpan<byte> content) (ImageContentSniffer.cs:15-16): the entry point, a short-circuiting IsJpeg || IsPng || IsWebP.
    • IsJpeg (ImageContentSniffer.cs:21-22): length at least 3 and the SOI prefix FF D8 FF checked byte by byte.
    • IsPng (ImageContentSniffer.cs:27-28): length at least 8 and a SequenceEqual against the exact 8-byte PNG signature 89 50 4E 47 0D 0A 1A 0A.
    • IsWebP (ImageContentSniffer.cs:33-36): length at least 12, a RIFF container (bytes 0-3 against the UTF-8 literal "RIFF"u8) declaring the WEBP form type (bytes 8-11 against "WEBP"u8). Bytes 4-7 are the RIFF chunk size and are deliberately not inspected.
  • Why it's built this way: ReadOnlySpan<byte> plus UTF-8 literals mean the checks allocate nothing and run directly on the payload prefix, and being a pure static class it is callable from any layer without DI or mocking. Checking bytes rather than the declared type is the security point: a client can rename evil.exe to avatar.png, but it cannot forge a valid leading signature and still survive the re-encode that follows.
  • Where it's used: called by SetUserAvatarHandler as the first gate on an upload (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:63) before the bytes reach IImageProcessor (SetUserAvatarHandler.cs:103) and then IFileStorageService (SetUserAvatarHandler.cs:117). It has its own dedicated unit-test class (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/ImageContentSnifferTests.cs).
  • Caveats / not-in-source: sniffing establishes only that the prefix matches a known signature. It is not a decode, so a truncated or malformed body still passes here and is rejected later by IImageProcessor.

DocumentContentSniffer

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/DocumentContentSniffer.cs:49 · Level 1 · class (public static)

  • What it is: a dependency-free static helper that decides the canonical MIME type of uploaded document bytes by checking both the file name's extension and the content's actual bytes, refusing to trust either one alone.
  • Depends on: DocumentFormats; BCL ZipArchive, Utf8.IsValid. It is the document-side counterpart to ImageContentSniffer.
  • Concept introduced, extension-plus-signature agreement as an upload trust boundary. [Rubric §11, Security] assesses whether untrusted input is validated by its actual content rather than a spoofable extension or client-declared content type; this type requires both the extension and the bytes to agree before it will return a MIME type, which ImageContentSniffer does not need because an image has no user-visible extension trust decision to make. A zip-bomb guard (MaxInspectedEntries = 4096, DocumentContentSniffer.cs:61) caps how many zip central-directory entries are inspected, and only entry names are read, never entry streams (DocumentContentSniffer.cs:175-177), so a hostile archive costs nothing to refuse.
  • Walkthrough: a lookup table plus detection and signature-checking members.
    • KnownExtensions (DocumentContentSniffer.cs:68-79): the one place extension, DocumentFormats flag and canonical MIME type are tied together, compared case-insensitively.
    • Detect(ReadOnlySpan<byte> content, string fileName, DocumentFormats allowed) (DocumentContentSniffer.cs:91-106): resolves the extension, then for Office Open XML formats copies the span to an array (ZipArchive needs a seekable stream) and calls IsOfficeOpenXml; otherwise checks the format's byte signature directly.
    • Detect(ReadOnlyMemory<byte> content, string fileName, DocumentFormats allowed) (DocumentContentSniffer.cs:119-131): the same contract without the defensive copy, for callers that already hold a Memory<byte>.
    • IsPdf (DocumentContentSniffer.cs:136-137): leading bytes match %PDF-.
    • IsZip (DocumentContentSniffer.cs:142-143): leading bytes match the zip local-file-header signature PK 03 04.
    • IsOfficeOpenXml(ReadOnlyMemory<byte> content) (DocumentContentSniffer.cs:153-188): opens the bytes as a ZipArchive and checks for a [Content_Types].xml entry, the marker every Office Open XML package carries at its root; a malformed or truncated archive answers false rather than throwing (DocumentContentSniffer.cs:179-187).
    • IsPlainUtf8Text (DocumentContentSniffer.cs:197-211): strips an optional UTF-8 BOM, then rejects empty content, any embedded NUL byte, or anything Utf8.IsValid rejects.
    • TryResolve/MatchesSignature/Extension (DocumentContentSniffer.cs:216-267): the private glue that looks up the extension, checks it against allowed, and routes to the right signature check.
  • Why it's built this way: requiring extension and signature to agree stops both a renamed executable and a correctly-named file with forged content; reading only zip entry names (never opening entry streams) and capping inspected entries at 4096 make the Office Open XML check itself resistant to a zip bomb rather than merely fast.
  • Where it's used: UploadSessionAssetHandler and UploadSessionAssetCommandValidator call Detect to accept or reject an uploaded document (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/UploadFile/UploadSessionAssetCommandValidator.cs:1, UploadSessionAssetHandler.cs:1); SessionAssetValidationRules and SessionAssetLimits also reference it (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/Validation/SessionAssetValidationRules.cs:1, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/SessionAssets/SessionAssetLimits.cs:1). It has its own dedicated unit-test class (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/DocumentContentSnifferTests.cs).

IFileStorageService

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/IFileStorageService.cs:11 · Level 3 · interface

  • What it is: the contract for storing and deleting binary blobs, for example user avatar images. Implementations own the container or bucket; callers pass only a blob name scoped within it.
  • Depends on: Result and its generic form (via MMCA.Common.Shared.Abstractions, IFileStorageService.cs:1); FileUploadOptions; BCL Stream and Uri.
  • Concept introduced, the managed blob-storage boundary. [Rubric §8, Data Architecture] assesses whether data lands in the right store, and binary content belongs in object storage rather than in a relational row. [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether a transport like this is swappable behind an abstraction. Per the doc comment (IFileStorageService.cs:5-10, ADR-045) the default implementation is unconfigured (uploads fail with a clear error) until a host calls AddAzureBlobFileStorage(configuration) with a complete FileStorage section. Returning Result rather than throwing keeps a failed upload on the same error-flow rails as the rest of the stack (see the primer).
  • Walkthrough: one property and three methods.
    • IsConfigured (IFileStorageService.cs:14): whether a real store is wired, so a handler can gate a feature on it rather than attempt a doomed upload.
    • UploadAsync(string blobName, Stream content, string contentType, CancellationToken cancellationToken = default) (IFileStorageService.cs:22): uploads or overwrites a blob and returns its public absolute URL as Result<Uri>. The blob name is container-scoped, for example avatars/42-a1b2c3d4.jpg (IFileStorageService.cs:17), and the content is read from the stream's current position (IFileStorageService.cs:18).
    • UploadAsync(string blobName, Stream content, string contentType, FileUploadOptions options, CancellationToken cancellationToken = default) (IFileStorageService.cs:37): the overload a document upload wants, storing the response headers carried by FileUploadOptions (a Content-Disposition, for example, is what makes a stored blob come back as a download under its original file name). It has no default body: every implementation must honor the headers directly rather than falling back to the three-argument overload. It carries a SuppressMessage for analyzer RS0026 (multiple public overloads with optional parameters) (IFileStorageService.cs:36): the justification records it as a grandfathered public overload shipped after the v1.152 public-API baseline, so changing its signature would be a breaking change.
    • DeleteAsync(string blobName, CancellationToken cancellationToken = default) (IFileStorageService.cs:43): deletes a blob; unknown names succeed (idempotent, IFileStorageService.cs:39), which matches at-least-once cleanup semantics.
  • Why it's built this way: an unconfigured default plus an IsConfigured gate means the framework ships avatar support without forcing every consumer to provision blob storage, and idempotent delete makes cleanup safe to retry after a partial failure. The headers-aware overload is a plain interface member with no default implementation (IFileStorageService.cs:37), so every IFileStorageService implementation is forced to honor the response headers rather than silently dropping them via a forwarding default.
  • Where it's used: SetUserAvatarHandler uploads the normalized JPEG (SetUserAvatarHandler.cs:30 for the injection, :117 for the call). Deletes run through DeleteBlobInternalCommandHandlerBase<TCommand>, which takes the service (MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/DeleteBlobInternalCommandHandlerBase.cs:29) and calls DeleteAsync (DeleteBlobInternalCommandHandlerBase.cs:45): DeleteAvatarBlobInternalCommandHandler derives from it (DeleteAvatarBlobInternalCommandHandler.cs:20) and is the cleanup side scheduled by SetUserAvatarHandler, RemoveUserAvatarHandler and DeleteUserHandler. UploadSessionAssetHandler uses the headers-aware overload for document uploads (UploadSessionAssetHandler.cs:166), with DeleteSessionAssetBlobInternalCommandHandler on its cleanup side (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/DeleteSessionAssetBlob/DeleteSessionAssetBlobInternalCommandHandler.cs:20). NullFileStorageService is the default registration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:336); AzureBlobFileStorageService replaces it when configured (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:139).

IImageProcessor

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/IImageProcessor.cs:11 · Level 3 · interface

  • What it is: the contract for normalizing an untrusted uploaded image: decode it (rejecting non-images), correct EXIF orientation, center-crop to a square, strip all metadata, and re-encode as JPEG.
  • Depends on: Result (via MMCA.Common.Shared.Abstractions, IImageProcessor.cs:1); BCL Stream.
  • Concept introduced, re-encoding as an image trust boundary. [Rubric §11, Security] assesses whether untrusted binary input is neutralized rather than merely inspected; [Rubric §30, Compliance, Privacy & Data Governance] assesses PII handling, and EXIF GPS coordinates are PII that a naive avatar pipeline would happily publish to a public blob URL. The doc comment (IImageProcessor.cs:5-10, ADR-045) makes both points: metadata removal deletes location data, and re-encoding is the defense against polyglot or malformed payloads because only pixels survive the decode and re-encode round trip. This is the processor half of the pair that ImageContentSniffer opens.
  • Walkthrough: one method, NormalizeToSquareJpegAsync(Stream content, int size, CancellationToken cancellationToken = default) (IImageProcessor.cs:18), returning Result<byte[]> of the normalized JPEG or a validation failure for undecodable content (IImageProcessor.cs:17). size is the output square edge length in pixels (IImageProcessor.cs:15), so the caller (not the framework) owns the avatar dimension.
  • Why it's built this way: returning bytes rather than writing straight to storage keeps the processor a pure transform, so a handler can sniff, then normalize, then hand the result to IFileStorageService; and a Result failure on undecodable input stops a bad upload before it ever reaches storage.
  • Where it's used: called by SetUserAvatarHandler between the sniffer and the upload (SetUserAvatarHandler.cs:29 for the injection, :71 for the call). Implemented by ImageSharpImageProcessor, registered with TryAddSingleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:337); note this is the one member of the avatar trio with a real default implementation rather than a null object.

EntityConfigurationOptions

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EntityConfigurationOptions.cs:10 · Level 0 · class (sealed)

  • What it is: an options bag that carries extra assemblies whose EF Core entity type configurations should be applied on top of the ones auto-discovered by name. A host or module pushes an Assembly into it during DI so its configurations are picked up without the discovery scan having to match it by naming convention.
  • Depends on: System.Reflection.Assembly (BCL); nothing first-party. It is read by DefaultEntityConfigurationAssemblyProvider through IOptions<EntityConfigurationOptions> (DefaultEntityConfigurationAssemblyProvider.cs:12-13).
  • Concept introduced, options-object supplementation of convention discovery. [Rubric §3, Clean Architecture] (assesses whether infrastructure discovers its collaborators rather than hardcoding references to them): the persistence layer does not reference every module's Infrastructure project, so a module that does not follow the .Infrastructure naming rule (for example a Common feature like Notification that lives inside Common.Infrastructure itself, which the auto-scan deliberately excludes) still gets its configurations applied by adding its assembly here. The doc comment names exactly that case (EntityConfigurationOptions.cs:5-9).
  • Walkthrough: one member, List<Assembly> AdditionalAssemblies { get; } = [] (EntityConfigurationOptions.cs:16). It is get-only and initialized to an empty list, so registration code mutates the list rather than replacing it.
  • Why it's built this way: an options object keeps the supplemental-assembly list open for extension without the provider (or the context) taking a compile-time dependency on any specific module. The provider merges these with the name-scanned set and de-duplicates.
  • Where it's used: written through the AddEntityConfigurationAssembly(Assembly) extension (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:349-359), which calls services.Configure<EntityConfigurationOptions> with a contains-check so an assembly is added at most once (DependencyInjection.cs:351-357); AddNotificationInfrastructure() (DependencyInjection.Notifications.cs:26-31) is the one in-framework caller. It is read by DefaultEntityConfigurationAssemblyProvider.

PersistenceSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/PersistenceSettings.cs:10 · Level 0 · class (sealed)

  • What it is: the bound Persistence configuration section, currently a single knob: the SQL command timeout applied to every command the SQL Server context issues.
  • Depends on: System.ComponentModel.DataAnnotations (for [Range], PersistenceSettings.cs:1); nothing first-party.
  • Concept introduced, the defaults-preserve-history rule for a new settings section. [Rubric §15, Best Practices & Code Quality] assesses whether change is additive; the class doc states the policy directly, every property defaults to the value the framework applied implicitly before the section existed, so the section is optional in appsettings.json (PersistenceSettings.cs:5-9). [Rubric §12, Performance & Scalability]: the 30-second default is the previous implicit ADO.NET behavior, and the doc names the case for raising it (reporting-style workloads whose queries legitimately run longer than half a minute, PersistenceSettings.cs:15-20). Making that value configurable rather than constant is the difference between tuning an environment and cutting a release.
  • Walkthrough: SectionName = "Persistence" (PersistenceSettings.cs:13); CommandTimeoutSeconds, [Range(1, 600)]-validated (PersistenceSettings.cs:21) and defaulting to 30 (PersistenceSettings.cs:22), is the only property. The consuming side is defensive in a way worth copying: SQLServerDbContext resolves the options with GetService<IOptions<PersistenceSettings>>()?.Value ?? new PersistenceSettings() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:35-36), so a context built outside the full DI graph (the design-time provider behind dotnet ef, a test) still gets the documented default rather than a null reference. It then applies it with sql.CommandTimeout(_persistenceSettings.CommandTimeoutSeconds) (SQLServerDbContext.cs:55). A second property, EnableSensitiveDataLogging, defaults to false (PersistenceSettings.cs:44) and asks EF to render parameter VALUES into its logs and exception messages (EnableSensitiveDataLogging, PersistenceSettings.cs:25-26); SensitiveDataLoggingGate is the class that decides whether the request is actually honored.
  • Why it's built this way: a [Range]-validated option bound with ValidateDataAnnotations() and ValidateOnStart() turns a typo into a startup failure rather than a per-query surprise (ADR-070). EnableSensitiveDataLogging is deliberately a request, not a switch: the doc comment is explicit that setting it true matters only when the host also reports the Development environment, and a host with no IHostEnvironment registered (design time, a directly-constructed test context) counts as not Development (PersistenceSettings.cs:29-34), so the flag cannot by itself put parameter values (customer data, tokens, password hashes) into a log sink in Staging or Production (ADR-122). The flag also does not by itself make EF log statements: the doc comment pairs it with setting Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command to Information in appsettings.Development.json only (PersistenceSettings.cs:37-41).
  • Where it's used: bound in the infrastructure registration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:144-147) and read by SQLServerDbContext for the command timeout; no other engine context reads that property today. EnableSensitiveDataLogging is read by SensitiveDataLoggingGate, which ApplicationDbContext calls from ConfigureSensitiveDataLogging (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:367-375) so the guarantee applies on the shared base rather than per engine.

ProfilingHelper

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/ProfilingHelper.cs:9 · Level 0 · class (internal static)

  • What it is: an internal static helper that wraps repository operations in a MiniProfiler timing step when profiling is active and is a no-op when it is not.
  • Depends on: StackExchange.Profiling (the MiniProfiler NuGet package); nothing first-party.
  • Concept introduced, opt-in per-operation timing via a null-conditional. [Rubric §13, Observability & Operability] (assesses granular timing and instrumentation of persistence): every helper routes through MiniProfiler.Current?.Step(...) (ProfilingHelper.cs:11-12). When MiniProfiler is not registered, MiniProfiler.Current is null, the ?. short-circuits, and the returned Timing? is null, so using var step = ... disposes nothing. The instrumentation can therefore live permanently in the decorators without a build-time toggle; the runtime cost when disabled is a single field read.
  • Walkthrough:
    • BeginStep(className, methodName) (ProfilingHelper.cs:11-12): returns a Timing? named MMCA.Common.Infrastructure.{className}: {methodName}, so every step in the profiler tree is self-identifying.
    • Profile(className, methodName, Func<int>) (ProfilingHelper.cs:14-18): opens a step and runs a synchronous delegate returning int (the shape of a sync Save).
    • ProfileAsync(...) non-generic and ProfileAsync<T>(...) (ProfilingHelper.cs:20-24,26-30): the async equivalents, each awaiting the delegate under the step with ConfigureAwait(false).
  • Why it's built this way: internal hides the profiling concern from callers outside Infrastructure; the null-conditional pattern means the same wrapper is safe in hot paths whether or not profiling is on.
  • Where it's used: the EF repository decorators wrap every call through it, for example EFRepositoryDecorator<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepositoryDecorator.cs:24-62, covering AddAsync through ExecuteUpdateAsync) and EFReadRepositoryDecorator<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepositoryDecorator.cs:33-111, covering GetAllAsync through CountAsync). ApplicationDbContext opens its own MiniProfiler step directly rather than through this helper (ApplicationDbContext.cs:196). Behavior is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/ProfilingHelperTests.cs.

DefaultEntityConfigurationAssemblyProvider

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DefaultEntityConfigurationAssemblyProvider.cs:12 · Level 1 · class (sealed)

  • What it is: the default implementation of IEntityConfigurationAssemblyProvider. It returns the set of assemblies whose EF entity configurations should be applied: every loaded assembly whose name contains .Infrastructure (excluding Common.Infrastructure itself), plus any assemblies explicitly registered through EntityConfigurationOptions.
  • Depends on: IEntityConfigurationAssemblyProvider (the contract it implements), EntityConfigurationOptions via IOptions<> (DefaultEntityConfigurationAssemblyProvider.cs:12-13), and System.AppDomain plus System.Reflection (BCL).
  • Concept introduced, name-convention assembly discovery with an explicit escape hatch. [Rubric §3, Clean Architecture] (infrastructure finds module configurations without referencing modules) and [Rubric §7, Microservices Readiness] (each extracted service loads only its own modules' configuration assemblies): scanning AppDomain.CurrentDomain.GetAssemblies() means a host applies exactly the module infrastructure it has loaded, so a monolith gets all modules and an extracted service gets its subset, with no per-host registration list.
  • Walkthrough: GetConfigurationAssemblies() (DefaultEntityConfigurationAssemblyProvider.cs:16) builds a collection expression from two spreads: the loaded assemblies whose FullName is non-null, contains .Infrastructure, and does not contain Common.Infrastructure (both matched OrdinalIgnoreCase, DefaultEntityConfigurationAssemblyProvider.cs:17-20), and the distinct options.Value.AdditionalAssemblies (DefaultEntityConfigurationAssemblyProvider.cs:21). The Common.Infrastructure exclusion is why Common-internal feature modules must opt in through EntityConfigurationOptions.
  • Why it's built this way: sealed; convention scanning keeps hosts declarative, and the additional-assemblies list covers the one case the convention deliberately excludes. Depending only on the abstraction plus options keeps the persistence layer free of module references.
  • Where it's used: registered as the singleton IEntityConfigurationAssemblyProvider via TryAddSingleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:59) and resolved by ApplicationDbContext, which iterates its assemblies inside ApplyConfigurationsForEntitiesInContext (ApplicationDbContext.cs:954,973). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DefaultEntityConfigurationAssemblyProviderTests.cs.

EfCoreConcurrencyConflictDetector

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EfCoreConcurrencyConflictDetector.cs:18 · Level 1 · class (sealed)

  • What it is: the shipped implementation of IConcurrencyConflictDetector. Given the exception a failed save threw, it answers whether the failure was an optimistic-concurrency conflict (a row-version mismatch), the same role SqlServerUniqueConstraintViolationDetector plays for unique-constraint violations, but for a condition EF itself already classifies rather than one that has to be inferred from a provider error number.
  • Depends on: IConcurrencyConflictDetector (the Application-layer contract it implements) and Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException.
  • Concept introduced, classifying a framework exception without leaking it past Application. [Rubric §3, Clean Architecture] (provider and framework vocabulary stays out of Application) and [Rubric §29, Resilience & Business Continuity] (a lost optimistic-concurrency race becomes a deterministic conflict answer rather than an unhandled exception): DbUpdateConcurrencyException is an EF Core type, so a handler cannot catch it directly without referencing EF; this detector is the one place that does, behind the same abstraction the unique-constraint detector uses.
  • Walkthrough: one method, IsConcurrencyConflict(Exception exception) (EfCoreConcurrencyConflictDetector.cs:20-31). A for loop walks the exception chain (current = exception, then current.InnerException) and returns true on the first link that is a DbUpdateConcurrencyException (:22-28), false once the chain is exhausted (:30). Unlike SqlServerUniqueConstraintViolationDetector there is no provider-number or message fallback: EF raises DbUpdateConcurrencyException directly when a SaveChanges call affects zero rows for a concurrency-token-checked entity, so no wrapping or translation step needs covering.
  • Why it's built this way: sealed and stateless, so it can be registered as a singleton. The chain walk exists because SaveChangesAsync can be called from inside a transaction helper that wraps the original exception; checking only the outermost exception would miss that case.
  • Where it's used: registered as the singleton IConcurrencyConflictDetector via TryAddSingleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:126). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/EfCoreConcurrencyConflictDetectorTests.cs.

EFQueryableExecutor

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EFQueryableExecutor.cs:11 · Level 1 · class (internal sealed)

  • What it is: the EF Core bridge for the Application layer's IQueryableExecutor. It exposes Include, AsSplitQuery, ToListAsync, and CountAsync over an IQueryable<T>, guarding each so the same code path works against a real EF queryable and against a plain in-memory IQueryable.
  • Depends on: IQueryableExecutor (the Application-layer contract it implements) and Microsoft.EntityFrameworkCore (EntityFrameworkQueryableExtensions).
  • Concept introduced, provider-agnostic query execution. [Rubric §14, Testability] (assesses whether query logic can run without a database) and [Rubric §3, Clean Architecture] (keeps EF's async extension methods behind an Application abstraction): the Application layer builds specifications and calls IQueryableExecutor rather than EF directly, so the same handlers execute against a LINQ-to-Objects list in a unit test and against a SQL provider in production.
  • Walkthrough:
    • Include<T> (EFQueryableExecutor.cs:14-18): calls EF's string-based Include on an EF queryable, otherwise returns the query unchanged (in-memory queries are already fully loaded).
    • AsSplitQuery<T> (EFQueryableExecutor.cs:21-25): applies EF's split-query behavior only on EF queryables, otherwise a pass-through.
    • ToListAsync<T> (EFQueryableExecutor.cs:28-31): uses EF's async materialization when available, otherwise the synchronous collection expression [.. query].
    • CountAsync<T> (EFQueryableExecutor.cs:34-37): EF async count when available, otherwise Task.FromResult(query.Count()).
    • IsEfQuery<T> (EFQueryableExecutor.cs:43): the discriminator. An EF provider's queryable implements IAsyncEnumerable<T>, a plain LINQ-to-Objects queryable does not, so a single is IAsyncEnumerable<T> test routes every call above.
  • Why it's built this way: internal sealed; centralizing the EF versus in-memory branch in one class means every consumer gets the fallback for free and no handler references EF's static extension methods.
  • Where it's used: registered as the singleton IQueryableExecutor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:111) and injected into Application-layer query code: EntityQueryPipeline (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:13), the Common notification handlers (GetMyNotificationsHandler.cs:18, GetUnreadNotificationCountHandler.cs:15, GetNotificationHistoryHandler.cs:17, MarkNotificationReadHandler.cs:14, MarkAllNotificationsReadHandler.cs:14) and app handlers such as MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:19 and MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/Services/SessionQuestionViewBuilder.cs:12. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/EFQueryableExecutorTests.cs.

NamespaceConventions

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/NamespaceConventions.cs:11 · Level 1 · class (internal static)

  • What it is: the persistence layer's one-method entry point for deriving a module name from an entity type's namespace. It is a thin forwarder: the parsing itself lives in ModuleNameConventions in MMCA.Common.Shared, so SQL schema names, logical database names and the module label the CQRS logging decorators enrich their scopes with all come from one rule.
  • Depends on: ModuleNameConventions (NamespaceConventions.cs:1,20-21); nothing else.
  • Concept introduced, convention-over-configuration naming with a single shared parser. [Rubric §8, Data Architecture] (assesses schema and database organization), [Rubric §7, Microservices Readiness] (assesses whether the model splits cleanly per module) and [Rubric §15, Best Practices & Code Quality] (one rule, one implementation): MMCA.Store.Sales.Domain.Orders yields "Sales", which becomes both the [Sales] SQL schema and the Sales logical database name (the worked examples are in the doc comment, NamespaceConventions.cs:14-16). A new module that follows the namespace pattern gets a schema and a data-source name with zero configuration; an explicit [UseDatabase("X")] attribute on a configuration overrides it when the pattern does not fit. The parse was lifted into Shared because Application may not reference Infrastructure and the logging decorators need the same answer (NamespaceConventions.cs:6-9).
  • Walkthrough: GetModuleName(Type entityType) (NamespaceConventions.cs:20-21) delegates straight to ModuleNameConventions.GetModuleName (MMCA.Common/Source/Core/MMCA.Common.Shared/Conventions/ModuleNameConventions.cs:38-51). That method splits the namespace on ., defaulting to an empty array when Namespace is null (ModuleNameConventions.cs:40), and applies two rules in order:
    • The Domain rule (ModuleNameConventions.cs:41-46): the case-insensitive index of a Domain segment, and when that index is >= 1 the preceding segment wins. This is the original persistence rule, kept byte for byte so schema and data-source naming never move.
    • The other layer segments (ModuleNameConventions.cs:17,48-50): Application, Infrastructure, API and UI match only at the fourth segment or later (layerIndex >= 3), so a framework namespace such as MMCA.Common.Application.* resolves to null rather than to a phantom "Common" module. Shared is deliberately absent from that list.
  • Why it's built this way: a single authority for both derivations means the schema name and the database name are computed identically, so they cannot diverge; keeping the parser in Shared lets Application reuse it without a layer violation. NamespaceConventions stays internal because callers inside Infrastructure should consume the resolved name, not re-derive it.
  • Where it's used: EntityDataSourceRegistry falls back to it when no [UseDatabase] is present and then to DataSourceKey.DefaultName (EntityDataSourceRegistry.cs:181), and EntityTypeConfiguration<TEntity, TIdentifierType> hands mapping to the engine (EntityTypeConfiguration.cs:77), which uses it for the SQL table schema (SQLServerDataSourceEngine.cs:101, PostgreSQLDataSourceEngine.cs:100, falling back to dbo) and the Cosmos container name (CosmosDataSourceEngine.cs:95, falling back to the entity type name). The Shared parser is separately exercised by MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Conventions/ModuleNameConventionsTests.cs, and the persistence forwarder by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/EntityDataSourceRegistryTests.cs:54,58.

SensitiveDataLoggingGate

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SensitiveDataLoggingGate.cs:24 · Level 1 · class (static)

  • What it is: the one-method static class that answers whether EF Core is allowed to render parameter VALUES into its logs and exception messages. It is the enforcement point for the and-gate PersistenceSettings.EnableSensitiveDataLogging documents: the setting alone is a request, this gate is what turns it into an actual true.
  • Depends on: PersistenceSettings (the setting it reads) and Microsoft.Extensions.Hosting.IHostEnvironment (IsDevelopment()).
  • Concept introduced, fail-closed AND-gating of a dev-only relaxation. [Rubric §17, Security & Compliance] and [Rubric §29, Resilience & Business Continuity]: a configuration value that leaks into a Staging or Production file, container image, or environment variable must not by itself enable logging of parameter values (customer data, tokens, password hashes). Requiring both the setting AND IHostEnvironment.IsDevelopment() means either absence, including a host that registers no IHostEnvironment at all (design time, a directly-constructed test context), reads as "off" (ADR-122).
  • Walkthrough: IsEnabled(PersistenceSettings? settings, IHostEnvironment? environment) (SensitiveDataLoggingGate.cs:33-34) is one expression: settings?.EnableSensitiveDataLogging == true && environment?.IsDevelopment() == true. Both parameters are nullable and both null-conditional comparisons resolve to false rather than throwing, so a caller that could not resolve either dependency from DI still gets a safe answer instead of a NullReferenceException.
  • Why it's built this way: static because the method is a pure function of its two inputs with no state to hold; comparing with == true rather than ?? false keeps the short-circuit reading left to right as "did the host ask, AND is it Development".
  • Where it's used: called from ApplicationDbContext's ConfigureSensitiveDataLogging (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:369-371), which calls optionsBuilder.EnableSensitiveDataLogging() only when the gate returns true (ApplicationDbContext.cs:373); the method sits on the shared base rather than in each engine's context class so the guarantee holds identically for every engine. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/SensitiveDataLoggingGateTests.cs.

SqlServerUniqueConstraintViolationDetector

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SqlServerUniqueConstraintViolationDetector.cs:31 · Level 1 · class (sealed)

  • What it is: the shipped implementation of IUniqueConstraintViolationDetector. Given the exception a failed save threw, it answers whether the database rejected the write for violating a unique constraint, so a handler whose pre-check lost a race against a concurrent insert can return the same conflict result the pre-check would have produced.
  • Depends on: IUniqueConstraintViolationDetector (the Application-layer contract it implements) and Microsoft.Data.SqlClient (SqlException). Nothing else: it holds no state and takes no constructor dependencies.
  • Concept introduced, classifying a provider error without leaking the provider. [Rubric §3, Clean Architecture] (assesses whether provider vocabulary stays in Infrastructure): the Application layer references MMCA.Common.Domain and no data provider at all, so neither SqlException nor the EF Core DbUpdateException wrapping it is reachable from a handler (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUniqueConstraintViolationDetector.cs:9-25). Asking a handler to match on the exception message is what that constraint used to force, and that is wrong twice over: the wording is a provider and locale detail, and matching on it drags provider vocabulary into the layer whose purpose is to stay persistence neutral. [Rubric §29, Resilience & Business Continuity] also applies: the pattern turns a lost insert race into a deterministic conflict answer rather than an unhandled 500, and a miss is safe rather than silent, because an unclassified exception simply keeps propagating.
  • Walkthrough: one method, IsUniqueConstraintViolation(Exception exception) (SqlServerUniqueConstraintViolationDetector.cs:46-71).
    • Chain walk (:48): a for loop over current = exception then current.InnerException. EF surfaces a rejected insert as a DbUpdateException wrapping the provider exception, so testing only the outermost exception would never match.
    • Number check (:50-54): the link classifies when it is a SqlException whose Number is 2601 (duplicate key rejected by a unique INDEX, :34) or 2627 (duplicate key violating a PRIMARY KEY or UNIQUE constraint, :37). Every other SQL Server error (a foreign-key failure, a deadlock victim, a timeout) is deliberately not a unique violation and must keep propagating (:13-17).
    • Message fallback (:56-67): a link that is not a SqlException still classifies when its message contains duplicate key (:40, the wording SQL Server and PostgreSQL share) or UNIQUE constraint failed (:43, SQLite), both matched OrdinalIgnoreCase. The comment is explicit that this is a fallback only, for a wrapper that captured the provider failure as text (a retry decorator re-throwing its own type, another engine's provider, a test double), and that the error numbers themselves are never matched as text: they do not appear in the message, so searching for them would match nothing real while happily matching an unrelated exception that quoted those digits.
    • Default (:70): false once the chain is exhausted.
  • Why it's built this way: sealed and stateless by construction (:26-29), which is what lets the container register it as a singleton. It is also the default registration for hosts running another engine, not just for SQL Server: the number check never matches there and the message fallback carries them, and a dedicated implementation can replace it whenever an engine deserves its own number check (:19-25).
  • Where it's used: registered as the singleton IUniqueConstraintViolationDetector via TryAddSingleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:121), which the module registrations rely on rather than shipping their own pair (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:28). The consumers are the handlers whose uniqueness pre-check can lose a race: MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:27, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:20, .../CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:20, .../Points/Services/PointsAwarder.cs:32 and .../Points/UseCases/SetLeaderboardParticipation/SetLeaderboardParticipationHandler.cs:33. Behavior is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/SqlServerUniqueConstraintViolationDetectorTests.cs and the registration by DependencyInjectionInfrastructureTests.cs:263-266.
  • Caveats / not-in-source: the message fallback is evaluated for every link in the chain, including a SqlException whose number did not match, so any exception whose message happens to contain duplicate key classifies as a collision. That is the deliberate trade the comment describes; whether a real provider emits that wording for a non-unique failure is Not determinable from source.

SoftDeleteFilterSql

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:18 · Level 11 · class (internal static)

  • What it is: the internal static class that owns the soft-delete index predicate for unique and opted-in indexes. It resolves which column carries the flag, hands that column to the target engine to spell the predicate, recognises when an index filter already carries that predicate, and quotes a single column name the way the engine expects. It is the single authority both the automatic convention and the hand-authored opt-in call, so the two can never disagree (SoftDeleteFilterSql.cs:9-17).
  • Depends on: DataSource (the engine enum), DataSourceEngines and IDataSourceEngine (the per-engine strategy that owns the quoting and the predicate spelling), IAuditableEntity (for the nameof(IAuditableEntity.IsDeleted) property name), and EF Core's Microsoft.EntityFrameworkCore.Metadata.IReadOnlyEntityType plus the GetColumnName() relational metadata extension.
  • Concept introduced, one predicate entry point shared by a convention and an explicit extension. [Rubric §8, Data Architecture] (assesses whether soft-delete is honored by the schema, not just by query filters) and [Rubric §15, Best Practices & Code Quality] (assesses whether one rule has one implementation): a filtered unique index that hardcodes the literal "[IsDeleted] = 0" breaks in two ways, on a model that renames the column with HasColumnName and on a provider that quotes identifiers with double quotes rather than brackets. Reading the column name from the EF model and asking the engine strategy for the spelling fixes both, and putting that entry point in one place means the automatic path (SoftDeleteUniqueIndexConvention) and the opt-in path (IndexBuilderExtensions) emit byte-identical SQL. The engine-specific text itself no longer lives in this class: it is one member per engine on IDataSourceEngine, so this class carries no switch over DataSource.
  • Walkthrough:
    • Build(DataSource engine, IReadOnlyEntityType entityType) (SoftDeleteFilterSql.cs:35-36) is one expression: DataSourceEngines.For(engine).BuildSoftDeleteFilter(ColumnName(entityType)). The private ColumnName helper (:72-74) looks the IsDeleted property up in the entity type and takes its mapped column name, falling back to the CLR property name when the property is not in the model, so a HasColumnName rename follows automatically. The engine then answers the spelling: SQL Server [Column] = 0 (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:117), PostgreSQL "Column" = false (PostgreSQLDataSourceEngine.cs:116), because it maps the flag to a real boolean column and refuses to compare one with an integer, the only place the predicate differs by engine beyond quoting (SoftDeleteFilterSql.cs:29-34), Sqlite "Column" = 0 (SqliteDataSourceEngine.cs:115), and Cosmos null (CosmosDataSourceEngine.cs:117), since it has no filtered-index support. null is the contract for "leave the index untouched", and both callers check it before doing anything (SoftDeleteUniqueIndexConvention.cs:58-59, IndexBuilderExtensions.cs:59-60).
    • ContainsPredicate(string existingFilter, IReadOnlyEntityType entityType) (SoftDeleteFilterSql.cs:53-60) answers the idempotence question: does a filter already declared on an index constrain the soft-delete column? Both the integer spelling ({column} = 0) and the PostgreSQL boolean spelling ({column} = false) are checked, each pushed through Normalize (:77-78), which strips whitespace and the three identifier quoting styles ([, ], " and a backtick), so a hand-authored [IsDeleted] = 0, a "IsDeleted"=0, the PostgreSQL "IsDeleted" = false and the predicate Build produces all count as the same clause. Without that normalization, and without checking both spellings, the convention could append its own predicate to a filter that already had one, or append the integer and boolean spellings of the same predicate to one another.
    • QuoteColumn(DataSource engine, string column) (SoftDeleteFilterSql.cs:70-71) forwards to DataSourceEngines.For(engine).QuoteColumn(column): brackets on SQL Server, the SQL-standard double-quoted form on PostgreSQL (which rejects brackets) and SQLite (:61-68). It exists for filtered-index predicates over columns other than IsDeleted, such as an IS NOT NULL clause.
  • Why it's built this way: internal static with a nullable Build return keeps the engine-capability decision out of the call sites; delegating the spelling to the engine strategy keeps the per-engine text in one place per engine rather than in a switch here. The Cosmos null is a deliberate signal instead of an exception, because both callers run over whole models where Cosmos entities are simply skipped. Comparing on a normalized form rather than on the exact string is what lets the two entry points, which do not agree on quoting, still recognise each other's output.
  • Where it's used: SoftDeleteUniqueIndexConvention calls Build at model finalization and ContainsPredicate before combining (SoftDeleteUniqueIndexConvention.cs:57,75), and IndexBuilderExtensions calls Build for a hand-authored index (IndexBuilderExtensions.cs:58). QuoteColumn is called by PushNotificationConfiguration for its DedupKey IS NOT NULL filter (PushNotificationConfiguration.cs:73) and by ApplicationDbContext through a private wrapper (ApplicationDbContext.cs:619-620) for the outbox and internal-command partial-index predicates (ApplicationDbContext.cs:665-666,755-756).
  • Caveats / not-in-source: a fifth engine would need its own IDataSourceEngine implementation to answer BuildSoftDeleteFilter and QuoteColumn; how DataSourceEngines.For (DataSourceEngines.cs:40) treats an unmapped value is not covered by this section.

EFRawSqlQueryExecutor

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EFRawSqlQueryExecutor.cs:29 · Level 13 · class (internal sealed)

  • What it is: the EF Core implementation of IRawSqlQueryExecutor, the Application-layer escape hatch for a query that is easier to express as literal SQL than as a specification. It builds a parameterized IQueryable<T> from an interpolated FormattableString over the host's default physical data source, then either lists or single-or-defaults it (EFRawSqlQueryExecutor.cs:8-26).
  • Depends on: IRawSqlQueryExecutor (the Application-layer contract it implements), IDbContextFactory, IDataSourceResolver, and Microsoft.EntityFrameworkCore (Database.SqlQuery<T>, EntityFrameworkQueryableExtensions).
  • Concept introduced, an escape hatch that is absent, not refused, on the wrong engine. [Rubric §3, Clean Architecture] (the raw-SQL surface is still an abstraction, not a direct DbContext reference from Application) and [Rubric §8, Data Architecture] (a relational-only capability is never silently mis-executed on Cosmos): the class itself carries no engine check. The engine gate lives at registration: AddRawSqlQueryExecutor resolves the framework default engine and registers the executor only when DataSourceEngines.For(defaultEngine).Capabilities.IsRelational (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:402-415). A Cosmos-default host therefore has no IRawSqlQueryExecutor at all, so a service that injects it fails when the container validates rather than on its first statement (EFRawSqlQueryExecutor.cs:19-25).
  • Walkthrough: a primary constructor takes IDbContextFactory and IDataSourceResolver (EFRawSqlQueryExecutor.cs:29-31). QueryAsync<T>(FormattableString sql, CancellationToken) (:33-34) and QuerySingleOrDefaultAsync<T>(...) (:37-38) both call the private SqlQueryable<T>(sql) and materialize it with ToListAsync/SingleOrDefaultAsync. SqlQueryable<T> (:46-52) null-guards sql (:48), resolves the default source with dataSourceResolver.ResolveLogical(DataSource.SQLServer, DataSourceKey.DefaultName) (:50), the same way the framework's own tables resolve theirs, letting the resolver substitute the engine a single-engine host actually configured (:13-15), and returns dbContextFactory.GetDbContext(dataSourceKey).Database.SqlQuery<T>(sql) (:51), EF's own parameterized raw-SQL queryable, composable with further LINQ before materialization. Because the context comes from the scoped factory, the statement shares the caller's connection and any transaction an ITransactional command opened (:16-17).
  • Why it's built this way: internal sealed, with no engine branching in the body, so the relational-only constraint is decided once, at composition, from the engine's declared capabilities rather than re-checked on every call. The FormattableString parameter is the parameterization guarantee: interpolated values become SQL parameters through EF's own handling, not string-concatenated SQL (ADR-125).
  • Where it's used: registered as the scoped IRawSqlQueryExecutor via TryAddScoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:413), the helper being invoked from the infrastructure registration at DependencyInjection.cs:116. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/EFRawSqlQueryExecutorTests.cs, and named by MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/DataSourceBranchingFitnessTests.cs.

UnitOfWork

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:20 · Level 13 · class (internal sealed)

  • What it is: the concrete implementation of IUnitOfWork, the scoped coordinator every handler injects to obtain repositories and to save. It caches repositories per entity type so all operations in a scope share one change tracker and context.
  • Depends on: IDbContextFactory (implemented by DbContextFactory), IDataSourceService, IRepositoryFactory, AuditableAggregateRootEntity<TIdentifierType>, and AuditableBaseEntity<TIdentifierType>.
  • Concept introduced, the Unit of Work over a database-per-service topology. [Rubric §2, Design Patterns] (Unit of Work plus Repository) and [Rubric §8, Data Architecture] (transactions and change-tracker scoping): a handler never knows which database an entity lives in. GetRepository resolves the entity's physical source, obtains the matching context, and builds a repository bound to it; caching that repository per scope guarantees one change tracker per database, which is what makes "load aggregate, mutate, one save" correct.
  • Walkthrough:
    • Primary constructor (UnitOfWork.cs:20-24): takes the context factory, the data-source service, the repository factory, and an optional IUniqueConstraintViolationDetector (default null), null-guarding the context factory and the repository factory into readonly fields (UnitOfWork.cs:26-27; the data-source service and the detector are used directly from the primary-constructor parameters).
    • IsUniqueConstraintViolation (UnitOfWork.cs:39-40): the type also implements IUniqueConstraintViolationDetector and forwards to the host's registered detector, returning false when none is registered. This lets a framework handler base that only holds the unit of work recognise a save that lost an insert race without taking a new constructor dependency (which would break every subclass); with no detector the exception propagates unchanged.
    • _repositories (UnitOfWork.cs:34): a Dictionary<Type, object> keyed by the closed generic repository interface (for example IRepository<Order, int>), so a repository is created at most once per entity type per scope.
    • GetRepository<TEntity, TIdentifierType>() (UnitOfWork.cs:48-61): on a cache miss, resolves the entity's DataSourceKey via dataSourceService.GetDataSourceKey(typeof(TEntity)) (UnitOfWork.cs:55), asks the context factory for the matching context (:56), and builds a read-write IRepository<TEntity, TIdentifierType> through IRepositoryFactory (:57); constrained to AuditableAggregateRootEntity<TIdentifierType> so only aggregate roots get a mutable repository.
    • GetReadRepository<TEntity, TIdentifierType>() (UnitOfWork.cs:68-81): the same resolution but calls CreateReadOnly (:77) and accepts any AuditableBaseEntity<TIdentifierType>, returning IReadRepository<TEntity, TIdentifierType> for query handlers.
    • Save and transaction methods (UnitOfWork.cs:84-94): SaveChangesAsync (:84-85), RequestExplicitKeyInsert (:88), and ExecuteInTransactionAsync (:91-94) all delegate straight to the context factory, because in a multi-database scope the factory is what coordinates saving and transacting across every context the scope touched. RequestExplicitKeyInsert forwards to IDbContextFactory.RequestExplicitKeyInsert(), the engine-neutral signal that the next save may carry explicit values for store-generated keys: each context's engine decides whether those inserts need a per-table toggle (its IExplicitKeyInsertDialect), an engine with none saves unchanged, and the flag clears after the save (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IDbContextFactory.cs:34-40). There is no synchronous Save and no manual begin/commit/rollback on this type.
    • Disposal (UnitOfWork.cs:96-122): implements both Dispose (:96-100) and DisposeAsync (:102-110) over a volatile bool _disposed flag (UnitOfWork.cs:36), disposing the context factory exactly once and suppressing finalization on both paths.
  • Why it's built this way: the unit of work plus the factory hide the physical topology from handlers (ADR-006), and per-scope repository caching guarantees a single change tracker per database. It is internal sealed because consumers only ever see the IUnitOfWork abstraction (dependency inversion).
  • Where it's used: registered as the scoped IUnitOfWork via TryAddScoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:130) and injected into virtually every command and query handler in Common and in both apps, and into the module seeders.

AuditTrailEntry

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.AuditTrail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailEntry.cs:25 · Level 0 · class (sealed)

  • What it is: one recorded change to an entity that opted into change history, written in the same transaction as the change it describes. It is the row shape of the AuditTrailEntries table: what changed, on which entity, from what to what, by whom, when, under which trace and which tenant.
  • Depends on: the UserIdentifierType alias for ChangedBy (AuditTrailEntry.cs:82, the solution-wide identifier alias described in the primer) and nothing else first-party. It is written by AuditTrailSaveChangesInterceptor, purged by AuditTrailCleanupJob, and projected by AuditTrailReader.
  • Concept introduced, framework bookkeeping rows versus domain state. [Rubric §8, Data Architecture] (assesses whether persistence mechanics are deliberate: keys, soft-delete, concurrency, retention) and [Rubric §30, Compliance, Privacy & Data Governance] (assesses whether history and personal data are governed rather than accumulated). The class comment states the category decision outright (AuditTrailEntry.cs:9-13): this is deliberately not an IAuditableEntity, exactly like OutboxMessage and ScheduledJobEntry. It carries no soft-delete flag (so no global query filter reaches it), no audit stamps of its own (stamping a stamp record is circular), and no concurrency token. Rows are append-only: nothing in the framework updates one, and the only deletion is the retention sweep.
  • Walkthrough: every member is init-only except one.
    • Id (AuditTrailEntry.cs:28): a Guid defaulted to Guid.NewGuid(), so a row is addressable the moment it is constructed, before the database sees it. That matters for the key fix-up below, which needs to find the row by id after the save.
    • EntityType (AuditTrailEntry.cs:35): required, the full CLR type name as a string, not a foreign key. The comment gives the two reasons: the trail outlives the row it describes (a deleted entity keeps its history) and one table spans every audited type.
    • EntityKey (AuditTrailEntry.cs:48): required, the invariant string form of the primary key, with a composite key joined by | in the model's key order. This is the one settable property on the class, and the comment at AuditTrailEntry.cs:42-47 records why: an entity with a store-generated key has no key yet when the change is captured, so the interceptor rewrites this single column once the insert has assigned one.
    • PropertyName, OldValue, NewValue (AuditTrailEntry.cs:54,61,69): all nullable. A property carrying PiiAttribute stores the redaction placeholder on both sides instead of its value (AuditTrailEntry.cs:59,67).
    • Operation (AuditTrailEntry.cs:76): required, one of Added, Modified, Deleted. The comment names the consequence that surprises people: a soft delete arrives as Modified on IsDeleted, which is exactly what it is at the database level.
    • ChangedBy, ChangedOn, CorrelationId, TenantId (AuditTrailEntry.cs:82,85,93,101): the provenance block. ChangedBy is null for a save that carried no identity (a background service, a seeder), CorrelationId is the ambient trace id, and TenantId comes from ApplicationDbContext.CurrentTenantId at capture (ApplicationDbContext.cs:154).
    • Row shape (AuditTrailEntry.cs:17-23): a Modified save produces one row per property that actually changed, so a trail reads as a field-level history; an Added or Deleted save produces a single summary row with a null PropertyName, because the interesting fact there is the lifecycle event and one row per column at insert time would multiply the table for no extra information.
  • Why it's built this way: ADR-075 chose a same-transaction, field-level trail over a post-commit log or a database trigger, and this entity is the shape that follows from it. Redacting personal data at capture rather than at read keeps the trail from becoming a second copy of a data subject's data that erasure would have to chase (ADR-005).
  • Where it's used: mapped by ApplicationDbContext.ConfigureAuditTrail (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:848-877) to dbo.AuditTrailEntries (ApplicationDbContext.cs:857) with EntityType at 256 non-unicode characters, EntityKey at 128, PropertyName at 128 non-unicode, Operation at 16 non-unicode and both CorrelationId and TenantId at 64 (ApplicationDbContext.cs:859-865), plus two indexes: IX_AuditTrailEntries_Entity over (EntityType, EntityKey, ChangedOn) for the read path and IX_AuditTrailEntries_ChangedOn for the retention sweep (ApplicationDbContext.cs:866-875). The mapping only happens when AuditTrail:Enabled is true: the context resolves that flag once in OnConfiguring (ApplicationDbContext.cs:332) and ConfigureAuditTrail returns immediately when it is false (ApplicationDbContext.cs:850-853), so a host that never opted in has exactly the model it had before the trail shipped (ApplicationDbContext.cs:84-88).

CaptureContext

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.AuditTrail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:663 · Level 0 · record struct (private readonly, nested)

  • What it is: a four-field value carrying the provenance every trail row of one save shares: the acting user, the capture instant, the trace id, and the tenant.
  • Depends on: the UserIdentifierType alias and System.DateTime; nothing else. It is nested inside AuditTrailSaveChangesInterceptor and private to it.
  • Concept introduced, resolve-once-per-save provenance. [Rubric §12, Performance & Scalability] (assesses whether per-item work is hoisted out of loops on a hot path): a save can produce dozens of trail rows, and every one of them wants the same four values. Reading Activity.Current, calling TimeProvider.GetUtcNow() and truncating strings once per save rather than once per row is the whole point, and it also guarantees that every row of one save carries an identical ChangedOn, which is what lets a reader group a save back together.
  • Walkthrough: the positional members are ChangedBy (nullable user id), ChangedOn (UTC instant), CorrelationId (nullable trace id) and TenantId (nullable), declared at AuditTrailSaveChangesInterceptor.cs:663-667. It is constructed exactly once per capture, in CaptureChanges (AuditTrailSaveChangesInterceptor.cs:191-195), where the trace id and tenant are already truncated to their column widths through ColumnWidth.Truncate (MaxCorrelationIdLength 64 and MaxTenantIdLength 64, AuditTrailSaveChangesInterceptor.cs:82,85). It is then passed by value into CaptureEntry and CaptureModifiedProperties, which copy its fields straight onto each new AuditTrailEntry (AuditTrailSaveChangesInterceptor.cs:270-273,327-330).
  • Why it's built this way: readonly record struct means no allocation and no defensive copying concerns for a value that exists only for the duration of one SavingChanges call; private keeps it invisible outside the interceptor. Positional syntax gives it structural equality and a useful ToString for free, neither of which the interceptor needs but neither of which costs anything.
  • Where it's used: only inside AuditTrailSaveChangesInterceptor.

PermissionGrantModelGate

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/PermissionGrantModelGate.cs:19 · Level 0 · class (internal sealed)

  • What it is: an empty marker class registered as a singleton whose sole purpose is its own presence in the service provider. ApplicationDbContext asks whether one is registered to decide whether to map the PermissionGrants table on this instance (PermissionGrantModelGate.cs:19).
  • Depends on: nothing; it carries no members.
  • Concept introduced, presence-as-opt-in, the refresh-session precedent applied to a second table. [Rubric §8, Data Architecture] (assesses whether the mapped model is a deliberate decision) and [Rubric §11, Security] (whether stored permission grants are an explicit choice rather than an always-on table). ApplicationDbContext.ResolvePermissionGrantGate reads serviceProvider.GetService<PermissionGrantModelGate>() is not null (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:915) and ANDs it with a check that this context's physical source name matches Authentication:PermissionGrants:DataSourceName (ApplicationDbContext.cs:916-919). Registering the class IS the opt-in the context reads: no flag, no boolean setting, just whether the type was registered at all. This is the same mechanic RefreshSessionModelBuilderExtensions's gate uses for the RefreshSessions table, applied to a second framework-owned table.
  • Walkthrough: internal sealed class PermissionGrantModelGate; (PermissionGrantModelGate.cs:19), a single empty declaration with no members, no constructor logic, and nothing to walk through beyond its own existence.
  • Why it's built this way: an empty class is the cheapest possible presence check: no state to get out of sync, no boolean to forget to flip back, and TryAddSingleton makes registering it twice free. A dedicated marker type (rather than reusing, say, an IOptions<PermissionGrantSettings> presence check) keeps "the table is mapped" and "the settings are bound" as two independently answerable questions, since a host could bind the settings section without ever calling AddStoredPermissionGrants.
  • Where it's used: registered as a singleton by AddStoredPermissionGrants (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:147), read by ApplicationDbContext.ResolvePermissionGrantGate (ApplicationDbContext.cs:914-919) and by ConfigurePermissionGrants which calls PermissionGrantModelBuilderExtensions.ApplyPermissionGrantConfiguration behind that gate. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/PermissionGrantModelGateTests.cs and referenced by design-time tooling in MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs.

AuditTrailSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.AuditTrail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSettings.cs:16 · Level 1 · class (sealed)

  • What it is: the AuditTrail configuration section: whether entity change history is recorded at all, how long a row is kept, and which engine's Default database the read surface queries. Every property has a default, so a host that opts in only needs AuditTrail:Enabled (AuditTrailSettings.cs:6-9).

  • Depends on: DataSource from MMCA.Common.Application.Interfaces.Infrastructure.Persistence (AuditTrailSettings.cs:2), which is what puts it at Level 1, plus System.ComponentModel.DataAnnotations for [Range] (AuditTrailSettings.cs:1).

  • Concept introduced, a settings flag that gates the MODEL, not just behavior. [Rubric §8, Data Architecture] (assesses whether the mapped model and its tables are a deliberate decision) and [Rubric §30, Compliance, Privacy & Data Governance] (assesses whether the system can answer "who changed this, and when"). Most feature flags in this codebase decide whether code runs; this one also decides whether a table exists. Enabled is read in ApplicationDbContext with GetService, not GetRequiredService, and cached in a field (ApplicationDbContext.cs:94, :291) that the model builder consults before mapping the entity (ApplicationDbContext.cs:848-853), so a host that leaves it false has exactly the model it had before the trail existed and its migrations never see an AuditTrailEntries table (AuditTrailSettings.cs:10-15). That is what makes an opt-in feature genuinely free for a host that does not want it: a mapped-but-empty table would still have to be migrated. The trail is off by default because recording history is a data-governance decision an application makes deliberately (ADR-075).

    [Rubric §31, Cost & FinOps]: the doc on Enabled names the cost being avoided outright, a table plus a write per change (AuditTrailSettings.cs:21-25). Marking entities with IAuditedEntity is the second gate and, as AddAuditTrail's remarks put it, that is where the write volume is actually decided (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:104-106).

  • Walkthrough: one static field and three init properties.

    • SectionName = "AuditTrail" (AuditTrailSettings.cs:19), the binding key.
    • Enabled (AuditTrailSettings.cs:26), defaulting to false. Read by ApplicationDbContext for the model gate (ApplicationDbContext.cs:332) and re-checked by the cleanup job before it does any work (AuditTrailCleanupJob.cs:68-71).
    • RetentionDays (AuditTrailSettings.cs:38), [Range(1, 3650)] (:37), default 90. AuditTrailCleanupJob turns it into a cutoff instant (AuditTrailCleanupJob.cs:73) and purges from every relational source in use, skipping Cosmos (:79-81) and expanding each source across the declared tenants (:83-86).
    • DataSource (AuditTrailSettings.cs:46), default DataSource.SQLServer. Note the asymmetry the doc calls out (AuditTrailSettings.cs:40-45): rows are WRITTEN to every relational source alongside the data they describe, and this value only says which source the v1 reader looks in. AuditTrailReader resolves it against the Default database of that engine (AuditTrailReader.cs:52-53) and returns an empty result when the entity type is not in that model (AuditTrailReader.cs:55-58).
  • Why it's built this way: retention is deliberately NOT self-driving. AddAuditTrail registers AuditTrailCleanupJob (DependencyInjection.Jobs.cs:124) but a job only runs when the host also calls AddScheduledJobs(configuration) and sets Scheduler:Enabled (DependencyInjection.Jobs.cs:97-101). Without the scheduler the trail still records everything and RetentionDays is inert, which the settings doc states as a supported state rather than leaving it as a surprise (AuditTrailSettings.cs:32-36). Registering the job in AddAuditTrail rather than in AddScheduledJobs keeps the two features independent (DependencyInjection.Jobs.cs:121-123).

  • Where it's used: bound with .ValidateDataAnnotations().ValidateOnStart() at DependencyInjection.Jobs.cs:110-113, so a bad RetentionDays fails the host at startup rather than at the first sweep. Read by ApplicationDbContext, AuditTrailReader (AuditTrailReader.cs:34-39) and AuditTrailCleanupJob (AuditTrailCleanupJob.cs:47-54, snapshotted at :60). Design-time tooling supplies the options object by hand so a migration can be generated with the table on or off (DesignTimeDbContextHelper.cs:165-166). The recording itself is AuditTrailSaveChangesInterceptor, registered as a singleton at DependencyInjection.Jobs.cs:117 and resolved by the context with GetService so its absence reads as "not registered" (ApplicationDbContext.cs:319). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/AuditTrail/AuditTrailModelGateTests.cs and AddAuditTrailTests.cs.

PendingEntityKey

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.AuditTrail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:672 · Level 1 · record (private sealed, nested)

  • What it is: a two-field pairing of a staged trail row with the entity entry whose primary key the database has not assigned yet. It exists only between SavingChanges and SavedChanges.
  • Depends on: AuditTrailEntry and EF Core's Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry. It is nested inside and private to AuditTrailSaveChangesInterceptor.
  • Concept introduced, the store-generated-key fix-up. [Rubric §8, Data Architecture] (assesses whether key strategy and its consequences are handled deliberately): the trail is captured before the save, which is exactly what makes it transactional, but an entity whose key is an identity column has no key at that moment. EF holds a temporary sentinel value that would make the row unfindable by key (AuditTrailSaveChangesInterceptor.cs:276-279). Remembering the pair, then rewriting the one column after the insert, is the price of capturing early; the alternative (capturing after the save) would put the trail outside the transaction and lose the guarantee the whole design exists for.
  • Walkthrough: the positional members are Row (the tracked trail row whose EntityKey must be rewritten) and Entry (the audited entry whose key the database assigns), declared at AuditTrailSaveChangesInterceptor.cs:672. Instances are produced by CaptureEntry only when the entry is Added and HasTemporaryKey(entry) returns true (AuditTrailSaveChangesInterceptor.cs:280-282, with the temporary-key test walking every primary key property at :449-466). They are accumulated into a list and parked in a ConditionalWeakTable<DbContext, List<PendingEntityKey>> keyed by the context (AuditTrailSaveChangesInterceptor.cs:94,215-218), then drained in ResolveGeneratedKeysAsync or ResolveGeneratedKeys (:368-396,402-429).
  • Why it's built this way: a record gives value semantics and a readable two-name shape for something that is pure bookkeeping; private sealed keeps it invisible. Holding the list in a ConditionalWeakTable rather than in an instance field is what lets the interceptor stay a singleton shared by every context without leaking state across them, and the weak keying means a context that is dropped without a save takes its pending list with it.
  • Where it's used: only inside AuditTrailSaveChangesInterceptor.

PermissionGrantRefreshService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/PermissionGrantRefreshService.cs:31 · Level 1 · class (internal sealed partial, PeriodicBackgroundService)

  • What it is: the background loop that keeps PermissionGrantCache warm. It calls RefreshAsync on an interval for the lifetime of the host, so the cache never goes stale by more than one PermissionGrants:CacheSeconds window (PermissionGrantRefreshService.cs:31-63).
  • Depends on: IPermissionGrantCache (the interface it refreshes through, not the concrete PermissionGrantCache directly), IOptions<PermissionGrantSettings>, ILogger<PermissionGrantRefreshService>, an optional TimeProvider, and PeriodicBackgroundService it extends (PermissionGrantRefreshService.cs:31-36).
  • Concept introduced, a cache that survives its own reload failing. [Rubric §29, Resilience & Business Continuity] (assesses whether a background loop degrades rather than crashes the host) and [Rubric §13, Observability & Operability] (a failed reload is logged, not silent). The class remarks state the direction deliberately (PermissionGrantRefreshService.cs:13-17): a failed load is logged and retried, never fatal, because a database blip must degrade authorization to "no stored grants" rather than take the host down.
  • Walkthrough: the whole interval loop, its cancellation handling and its catch-log-continue behavior live once in PeriodicBackgroundService; this type supplies only four overrides. Interval returns _settings.CacheSeconds (PermissionGrantRefreshService.cs:41). StartupDelay returns TimeSpan.Zero (:44-47): the snapshot is primed immediately at startup, because until the first load authorization sees no stored grants at all, unlike RefreshSessionCleanupService, whose first sweep waits a full interval. ExecuteCycleAsync is one line, cache.RefreshAsync(stoppingToken) (:54), and LogCycleFailure routes a cycle's exception to the source-generated LogRefreshFailed message (:57, :59-62); the base class is what turns a thrown exception there into "log and keep looping" rather than a host crash.
  • Why it's built this way: registering it through TryAddEnumerable rather than AddHostedService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:161-164) is what keeps two modules calling AddStoredPermissionGrants from starting two refresh loops in one process, the same idempotent-registration discipline RefreshSessionCleanupService and AuditTrailCleanupJob use for their own recurring work. Catching broadly and logging rather than crashing follows ADR-116: a stored-grant feature that takes the host down on a transient database fault would be worse than the fallback it exists to layer over.
  • Where it's used: registered as a singleton IHostedService by AddStoredPermissionGrants (DependencyInjection.Auth.cs:163-164), started and stopped by the generic host alongside every other BackgroundService. Its only collaborator is PermissionGrantCache through IPermissionGrantCache.RefreshAsync.

RefreshSessionModelBuilderExtensions

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/RefreshSessionModelBuilderExtensions.cs:16 · Level 4 · class (public static)

  • What it is: the single place the RefreshSessions table is mapped. One extension method, ApplyRefreshSessionConfiguration(this ModelBuilder, string? schema = "dbo"), configures the RefreshSession entity: table, key, column widths and the two indexes the auth flows read through (RefreshSessionModelBuilderExtensions.cs:16-75).
  • Depends on: RefreshSession from MMCA.Common.Domain.Auth (including its length constants) and EF Core's ModelBuilder.
  • Concept introduced, the opt-in framework table. [Rubric §8, Data Architecture] assesses whether each database holds exactly the data it owns, and [Rubric §11, Security] assesses whether credential storage is designed rather than incidental. The framework owns two kinds of table. The outbox is cross-cutting: every relational source needs one, so it is configured on ApplicationDbContext unconditionally and every module's database gets it. Refresh sessions are the other kind: they are Identity-module data, exactly one database owns them, and mapping them everywhere would put an empty RefreshSessions table in every other module's migrations. The class doc draws that contrast explicitly (RefreshSessionModelBuilderExtensions.cs:8-14). Keeping the mapping in a callable extension method rather than inlining it in a context is what makes "one database, chosen by configuration" expressible at all.
  • Walkthrough
    • Three public constants name the objects so tests and callers do not restate strings: TableName = "RefreshSessions" (RefreshSessionModelBuilderExtensions.cs:19), TokenHashIndexName (:22) and UserIndexName (:25).
    • The method null-guards, maps the table with the caller's schema defaulting to dbo (RefreshSessionModelBuilderExtensions.cs:36-40), and keys on Id (:41).
    • TokenHash is IsRequired, HasMaxLength(RefreshSession.TokenHashLength), IsUnicode(false), IsFixedLength() (RefreshSessionModelBuilderExtensions.cs:45-49). The constant is 64 (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/RefreshSession.cs:46), the length of a SHA-256 digest rendered as hex. The comment at :43-44 gives the reason for the three facets: the value is always a 64-character hex digest, so a Unicode or variable-width column would double the index it has to fit in for nothing.
    • ReplacedByTokenHash gets the same three facets minus IsRequired (RefreshSessionModelBuilderExtensions.cs:51-54), because it is null until the session is rotated.
    • ReasonRevoked, IpAddress and UserAgent take their lengths from the domain constants (64, 45 and 512 at RefreshSession.cs:55, :37, :40), the first two non-Unicode (RefreshSessionModelBuilderExtensions.cs:56-58). 45 is the length of a full IPv6 text form; UserAgent stays Unicode because a user-agent string is arbitrary client text.
    • The unique index over TokenHash (RefreshSessionModelBuilderExtensions.cs:64-66) is the validation path: every refresh presents a token and must be answered by exactly one row. The comment at :60-63 gives two reasons for the uniqueness, and both are security reasons rather than performance ones: a hash collision across users would let one account's token validate against another's session, and a double-insert of the same token becomes a database error instead of an ambiguity the reuse check has to resolve.
    • The composite index on (UserId, RevokedAt) (RefreshSessionModelBuilderExtensions.cs:70-71) serves the family question, "every live session for this user", which is asked on the per-user session cap, on reuse detection and on sign-out-everywhere. Without it each of those scans the table (:68-69).
    • The builder is returned for chaining (RefreshSessionModelBuilderExtensions.cs:74).
  • Why it's built this way: hashing the token rather than storing it, and rotating per device, is ADR-097, which supersedes the single-plaintext-column storage model of ADR-050 while keeping its rotation and reuse-detection policy. The column shapes here are what make that model cheap: a fixed-width non-Unicode digest keeps the unique index narrow, and the two indexes are exactly the two questions the auth service asks. Nothing here is soft-deletable and nothing is audit-stamped, which is deliberate and is why the table needs its own mapping method rather than riding the module entity-configuration mechanism.
  • Where it's used: called by ApplicationDbContext from ConfigureRefreshSessions (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:893-901) behind the two-part gate computed in the constructor: RefreshSessions:Enabled is true and this context's physical source name equals RefreshSessions:DataSourceName (ApplicationDbContext.cs:336-339). At design time the same gate is fed by DesignTimeDbContextOptions.EnableRefreshSessions. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Auth/RefreshSessionModelBuilderExtensionsTests.cs and, for the gate itself, .../Persistence/DbContexts/RefreshSessionModelGateTests.cs:89.
  • Caveats / not-in-source: the class doc says the consumer's Identity context calls this from its own OnModelCreating (RefreshSessionModelBuilderExtensions.cs:12-13), which describes an earlier arrangement. The base context now calls it directly behind the gate, and the doc on ConfigureRefreshSessions explains why (ApplicationDbContext.cs:884-891): downstream apps run on the sealed engine contexts and have no context class to override (ADR-006). Calling it by hand remains supported for a host that does have its own context class; trust the context, not the older sentence.

PermissionGrantModelBuilderExtensions

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/PermissionGrantModelBuilderExtensions.cs:15 · Level 4 · class (public static)

  • What it is: the single place the PermissionGrants table is mapped. One extension method, ApplyPermissionGrantConfiguration(this ModelBuilder, string? schema = "dbo"), configures the PermissionGrant entity: table, key, column widths, and the unique index that makes a grant idempotent (PermissionGrantModelBuilderExtensions.cs:15-64).
  • Depends on: PermissionGrant from MMCA.Common.Domain.Auth (including its RoleMaxLength/PermissionMaxLength constants) and EF Core's ModelBuilder.
  • Concept introduced, the same opt-in-table mapping shape as refresh sessions, applied to a second framework table. [Rubric §8, Data Architecture] and [Rubric §11, Security] (assesses whether stored authorization data is deliberately keyed and constrained). The class sits beside RefreshSessionModelBuilderExtensions in Persistence.Auth and follows the identical pattern: a callable extension method rather than an inline mapping in a context, so the table only appears in a host's model when something calls it.
  • Walkthrough
    • Two public constants: TableName = "PermissionGrants" (PermissionGrantModelBuilderExtensions.cs:18) and RolePermissionIndexName = "IX_PermissionGrants_Role_Permission" (:117).
    • The method null-guards, maps the table with the caller's schema defaulting to dbo (:126-132), and keys on Id (:133).
    • Role and Permission are both IsRequired, length-bounded by the domain's own RoleMaxLength/PermissionMaxLength, and IsUnicode(false) (:137-145): the comment gives the reason, role and permission names are ASCII identifiers by convention, so a Unicode column would double the index width for nothing.
    • GrantedBy gets HasMaxLength(256) and nothing else (:147), since it is nullable provenance, not a validated identifier.
    • The unique index over (Role, Permission) (:153-155) is what makes granting the same permission to the same role twice a no-op rather than a duplicate row: the comment frames a grant as set membership, not a log, so two rows saying the same thing mean nothing extra and would turn "revoke" into a question of how many rows to delete (:149-152). It is also what lets EFPermissionGrantStore's idempotent grant rely on the database rather than a read-then-write race.
    • The builder is returned for chaining (:158).
  • Why it's built this way: ADR-116 layers stored grants over the compiled IPermissionRegistry as a union-only decorator, and the column shapes here are what make that layer cheap to query: a non-Unicode fixed-shape pair with one unique index answers both "does this role have this permission" reads and idempotent writes without a second lookup.
  • Where it's used: called by ApplicationDbContext.ConfigurePermissionGrants behind PermissionGrantModelGate's presence check plus the physical-source match (ApplicationDbContext.cs:914-929). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Auth/PermissionGrantModelBuilderExtensionsTests.cs and .../Persistence/DbContexts/PermissionGrantModelGateTests.cs, and consumed by EFPermissionGrantStore through the DbSet<PermissionGrant> the mapping creates.

PermissionGrantCache

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/PermissionGrantCache.cs:35 · Level 5 · class (internal sealed)

  • What it is: the in-memory snapshot of every role's stored permissions, keyed per role so a read never scans the whole grant table. It implements IPermissionGrantCache (the read side) and IPermissionGrantCacheInvalidator (the write-triggered refresh), and both resolve to the same singleton instance (PermissionGrantCache.cs:35-131).
  • Depends on: IMemoryCache, IServiceScopeFactory (to resolve the scoped IPermissionGrantStore for a rebuild), IOptions<PermissionGrantSettings>, and BCL FrozenSet/SemaphoreSlim.
  • Concept introduced, replace-wholesale rather than mutate-in-place for a cache read from many threads. [Rubric §12, Performance & Scalability] (assesses whether a hot concurrent read path is lock-free) and [Rubric §11, Security] (a reader must never observe a half-built permission set). A SemaphoreSlim (_rebuildLock) serializes rebuilds so the interval refresh and a concurrent administration edit cannot interleave a write from one with the eviction pass of the other (PermissionGrantCache.cs:50, doc at :46-49); _cachedRoles is volatile and replaced wholesale on every rebuild rather than mutated, so a reader never sees a half-built set (:56, doc at :52-55).
  • Walkthrough
    • GetPermissions(role) (PermissionGrantCache.cs:59-62): a plain IMemoryCache.TryGetValue keyed by CacheKey(role), returning Empty (a static FrozenSet<string>, :41) for a blank role or a cache miss. No lock, no allocation on a miss.
    • RefreshAsync (:65-107): takes the rebuild lock, opens a DI scope, resolves IPermissionGrantStore, reads every grant with GetAllAsync, groups by role (StringComparer.OrdinalIgnoreCase) into per-role FrozenSet<string>s (StringComparer.Ordinal for the permission names themselves), writes each role's entry (:89-92), then evicts roles that were cached a moment ago but carry no grant now, in that order, so a role that still has grants is never briefly absent (:96-99, comment at :94-95). _cachedRoles is replaced last (:101).
    • Entry lifetime is three refresh intervals, not one (:87, CacheSeconds * 3), for the two reasons the comment gives (:82-86). The next rebuild starts one interval AFTER the previous one finishes, so an entry living exactly one interval would expire before it is rewritten and every cycle would open a window with no stored grants. And when the refresh keeps failing, the snapshot must still stop answering eventually, so a revoke cannot grant forever on a dead database: three intervals is that bound.
    • InvalidateAsync(role, ct) (:110-118): the role argument narrows what a caller MEANS, not what is reloaded; RefreshAsync always reloads everything, and the comment explains why (:112-114): a single query either way, and reloading everything keeps one code path that cannot leave a second role stale because a caller named only the first.
    • Dispose (:126) disposes only the semaphore; the cache entries belong to the host's IMemoryCache and are deliberately left alone since this is a singleton whose disposal happens at shutdown (doc at :121-125).
    • CacheKey(role) (:130) upper-cases the role (ToUpperInvariant) before building permgrant:role:{ROLE}. Role names compare case-insensitively everywhere they are read, while IMemoryCache compares keys ordinally, so without the normalization a role stored as Admin and read as admin would miss (comment at :128-129).
  • Why it's built this way: one instance answering both the read interface and the invalidator interface, rather than two collaborating types, is what guarantees an edit and the reads that follow it cannot end up looking at two different snapshots (the DI registration comment states this directly, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:151-152).
  • Where it's used: registered as a singleton for all three roles (concrete type, and the two interfaces resolved from the same instance) by AddStoredPermissionGrants (DependencyInjection.Auth.cs:153-157). Refreshed on an interval by PermissionGrantRefreshService and read by LayeredPermissionRegistry and the stored-grant administration surface.

AuditTrailSaveChangesInterceptor

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.AuditTrail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:64 · Level 11 · class (sealed)

  • What it is: the EF Core save interceptor that records a field-level change history for every entity marked IAuditedEntity, writing AuditTrailEntry rows into the same transaction as the change they describe.
  • Depends on: AuditTrailEntry, CaptureContext, PendingEntityKey, ApplicationDbContext, ColumnWidth (the shared truncate-to-column-width helper), IAuditedEntity, PiiAttribute, PiiRedactor, OutboxMessage, InboxMessage, ScheduledJobEntry, and the BCL TimeProvider, Activity, ConditionalWeakTable and ConcurrentDictionary.
  • Concept introduced, the transactional change trail. [Rubric §6, CQRS & Event-Driven] (the same interceptor-pipeline mechanic the outbox uses, applied to a different payload), [Rubric §8, Data Architecture], [Rubric §13, Observability & Operability] (each row carries the ambient trace id, so a change ties back to the request or job that made it) and [Rubric §30, Compliance, Privacy & Data Governance] (personal data is redacted at capture, never at read). The class comment states the guiding rule (AuditTrailSaveChangesInterceptor.cs:20-25): a trail that can be committed without its data, or the other way round, is worse than no trail. Four mechanics are worth learning here as a set.
    • Opt in twice over (AuditTrailSaveChangesInterceptor.cs:34-38): the host must call AddAuditTrail (the context resolves this interceptor with GetService, so its absence is a silent no-op, ApplicationDbContext.cs:316-321) and set AuditTrail:Enabled so the table is mapped. Then the entity must carry the marker. Nothing about the feature is on by default.
    • Position in the pipeline (AuditTrailSaveChangesInterceptor.cs:27-33): registered last, after AuditSaveChangesInterceptor, the optional TenantSaveChangesInterceptor and DomainEventSaveChangesInterceptor (ApplicationDbContext.cs:297-321), so the values it diffs are final: the audit stamps are already written when it runs.
    • Correlation is the trace id, not the scoped correlation context (AuditTrailSaveChangesInterceptor.cs:45-56): this interceptor is a singleton and the only service provider a context carries is the root one, so a scoped ICorrelationContext is not reachable from here and capturing one would be a lifetime bug. The trace id is ambient and is the same value CorrelationIdMiddleware falls back to when a request carries no X-Correlation-ID header. A caller-supplied header value is therefore NOT recorded today.
    • Tenant is read off the context (AuditTrailSaveChangesInterceptor.cs:57-61), through the live accessor the scoped context factory assigns (ApplicationDbContext.cs:154), so it does reach the interceptor where a scoped service would not.
  • Walkthrough:
    • Constants (AuditTrailSaveChangesInterceptor.cs:66-88): the three operation names, the four column widths (MaxEntityTypeLength 256, MaxKeyLength 128, MaxCorrelationIdLength 64, MaxTenantIdLength 64) and the | composite-key separator. They match the model mapping in ApplicationDbContext.ConfigureAuditTrail exactly (ApplicationDbContext.cs:859-865).
    • Static state (AuditTrailSaveChangesInterceptor.cs:90-121): two ConditionalWeakTables keyed by context (pending key fix-ups, and a marker for "a capture staged rows but the save has not finished"), a ConcurrentDictionary caching the PII verdict per (declaring type, property name), and a HashSet<Type> of the framework's own bookkeeping entities. That last set is guarded by CLR type, not by the absence of the marker, which is what keeps the trail from recording its own rows in an unbounded feedback loop and keeps the outbox, inbox and job tables out of a history nobody asked for (AuditTrailSaveChangesInterceptor.cs:109-121). IsFrameworkEntity(Type) (:172) is the internal window onto that set, which is both how ShouldAudit consults it and how a test can assert the exclusion list directly.
    • The four overrides (AuditTrailSaveChangesInterceptor.cs:124-165): SavingChangesAsync and SavingChanges both call CaptureChanges; SavedChangesAsync and SavedChanges both run the key fix-up. Each pattern-matches eventData.Context is ApplicationDbContext first, so a foreign context passes straight through.
    • CaptureChanges (AuditTrailSaveChangesInterceptor.cs:179-221): the first statement is the cheap double gate, context.Model.FindEntityType(typeof(AuditTrailEntry)) is null (:181-184), which covers both a host that never opted in and Cosmos (which skips relational tables), because Set<AuditTrailEntry>() would throw for both. Then it discards an abandoned capture, builds one CaptureContext, snapshots ChangeTracker.Entries().Where(ShouldAudit).ToArray() (:198, materialized before adding, because enumerating the tracker lazily while adding to it would throw), and captures each entry.
    • ShouldAudit and IsBeingWritten (AuditTrailSaveChangesInterceptor.cs:228-240): the entity carries the marker, is not a framework bookkeeping type, and the save actually writes its row. IsBeingWritten is true for Added, Modified and Deleted, and also for an Unchanged owner whose owned value object changed (it asks OwnedDependents.HaveChanges(entry), :237), because EF tracks an owned edit on the owned entry and leaves the owner itself Unchanged.
    • CaptureEntry (AuditTrailSaveChangesInterceptor.cs:250-283): a Modified or Unchanged entry (:258) delegates to the per-property diff and then to the owned-value diff; an Added or Deleted entry writes one summary row and then, only when the insert's key is still temporary, returns a PendingEntityKey.
    • CaptureModifiedProperties (AuditTrailSaveChangesInterceptor.cs:292-333): one row per property that is IsModified and whose value actually differs. A property EF flagged as modified but whose value is unchanged (the whole-entity Update idiom) writes nothing (:312-315). A concurrency token is skipped outright (:305): on PostgreSQL and SQLite the audit interceptor writes a fresh RowVersion on every update, so it would otherwise add a bookkeeping row to every change. PiiRedactor.HasPii(entry.Metadata.ClrType) is checked once per entity so a type with no personal data skips the per-property attribute lookup entirely (:301), and a PII property records PiiRedactor.RedactedToken on both sides (:324-325).
    • CaptureOwnedChanges and CaptureOwnedReference (AuditTrailSaveChangesInterceptor.cs:346-361, :366-404): one row per changed column of each owned value object the owner carries (an OwnsOne address, an OwnsMoney amount), named Navigation.Property (:394) and recorded under the owner's type and key. It walks entry.References, keeps owned targets in state Added or Modified (:355-356), and diffs each value column with the same ValuesEqual, PII and FormatValue rules as the plain path. A replaced instance arrives as a new Added owned entry beside the Deleted one it replaces, so FindReplacedOwnedEntry (:417-436) matches on owned entity type and shared key and the previous value is read from that Deleted entry. IsOwnedValueColumn (:410) excludes the shadow key, the foreign key to the owner and concurrency tokens. Clearing an optional owned reference to null leaves no owned entry and is not recorded (the remarks at :343-345).
    • DiscardAbandonedCapture (AuditTrailSaveChangesInterceptor.cs:457-478): the retry-safety mechanic. An execution strategy that retries a failed save re-runs SavingChanges against a tracker that still holds the previous attempt's Added rows, so without this one transient SQL fault writes the trail twice. It only fires when the marker is present (a completed save leaves none Added), because discarding unconditionally would also throw away trail rows a caller added deliberately.
    • ResolveGeneratedKeysAsync and ResolveGeneratedKeys (AuditTrailSaveChangesInterceptor.cs:486-514,520-547): rebuild the key now that the insert has assigned one, skip rows whose key did not change, and rewrite the one column with a set-based ExecuteUpdate per row, which bypasses the change tracker and the interceptor pipeline and joins the ambient transaction when one is open (the same technique as OutboxFinalizer). SyncTrackedKey (:555-561) then brings the tracked instance and its snapshot in line so a later save does not re-issue the update, and the ordering there is load-bearing: writing OriginalValue must precede clearing IsModified, because clearing the flag reverts the current value to the original.
    • Helpers: AddRow (:443-449, mutation is Add only, because the save runs with automatic change detection off), HasTemporaryKey (:567-584), BuildEntityKey (:591-603, a keyless entity yields an empty string rather than throwing), IsPiiProperty (:610-624, a shadow property has no PropertyInfo so it can never be personal data; the verdict uses the static Attribute.IsDefined(propertyInfo, typeof(PiiAttribute), inherit: true) at :622, because the PropertyInfo.IsDefined instance method ignores inherit for properties, and only the static form walks an override back to its base declaration, matching how PiiRedactor decides, comment at :620-621), FormatValue (:632-637, CultureInfo.InvariantCulture on purpose, so a trail read years later or on a differently localized replica shows the value that was written; a byte[] such as a hash or salt renders as hexadecimal via Convert.ToHexString at :635, because the invariant Convert.ToString would record its type name on both sides and lose the change) and ValuesEqual (:643-656, byte arrays such as row versions are compared by content, since reference equality would report every save as a change). Column-width truncation itself is no longer a private method on this type: every call site (the correlation id and tenant id in CaptureChanges at :194-195, the entity type in CaptureEntry at :255, the property name in CaptureModifiedProperties at :323, the owned column name in CaptureOwnedReference at :394, and the composite key in BuildEntityKey at :602) now goes through the shared ColumnWidth.Truncate helper instead.
  • Why it's built this way: ADR-075 chose the interceptor over a repository decorator (which sees intent, not the tracker's diff), an application handler (which sees the command, not the properties EF marked modified) and a database trigger (which sees the diff and nothing else: no user, no correlation id, no PII knowledge). Committing in the caller's transaction is the outbox mechanic of ADR-003 applied to a different payload, and redacting at capture is what keeps the trail compatible with ADR-005.
  • Where it's used: registered as a singleton by AddAuditTrail (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:115-117, singleton for the same reason as the other two save interceptors: stateless, with per-save state in a ConditionalWeakTable keyed by context), added to the EF pipeline in ApplicationDbContext.OnConfiguring (ApplicationDbContext.cs:316-321), and registered in the design-time pipeline too so dotnet ef matches runtime (DesignTimeDbContextHelper, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:165-167). Behavior is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/AuditTrail/AuditTrailSaveChangesInterceptorTests.cs and AuditTrailModelGateTests.cs. Hosts opting in today include MMCA.Helpdesk (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:88), all three MMCA.Store services (Identity.Service/Program.cs:188, Sales.Service/Program.cs:206, Catalog.Service/Program.cs:210) and all three MMCA.ADC services (Engagement.Service/Program.cs:197, Identity.Service/Program.cs:232, Conference.Service/Program.cs:296).
  • Caveats / not-in-source: a caller-supplied X-Correlation-ID is not recorded; honoring it would need a live accessor on the context assigned by the scoped factory, the shape multi-tenancy introduced for TenantId (AuditTrailSaveChangesInterceptor.cs:53-55). Whether that will be done is Not determinable from source.

AuditTrailReader

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.AuditTrail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailReader.cs:34 · Level 13 · class (internal sealed)

  • What it is: the read side of the trail, and the only implementation of IAuditTrailReader. It returns one entity's change history, newest first, as a paged list of AuditTrailEntryDTO.
  • Depends on: IAuditTrailReader, IDbContextFactory, IDataSourceResolver, DataSourceKey, AuditTrailEntry, AuditTrailEntryDTO and AuditTrailSettings via IOptions<> (AuditTrailReader.cs:34-39).
  • Concept introduced, projecting to a DTO inside the query rather than after it. [Rubric §9, API & Contract Design] (the Application-layer contract returns a DTO, never the persistence entity) and [Rubric §12, Performance & Scalability] (the Select is part of the SQL, so only the projected columns cross the wire, and AsNoTracking keeps the rows out of the change tracker). The ordering is ChangedOn descending with Id as the tie-break (AuditTrailReader.cs:62-63), which matters because every row of one save shares an identical ChangedOn: without the second key, paging over a save's worth of rows would not be stable.
  • Walkthrough: GetForEntityAsync(entityType, entityKey, page = 1, pageSize = 50, ct) (AuditTrailReader.cs:42-47).
    • Paging guards (AuditTrailReader.cs:49-50): a page or page size below 1 is coerced to 1 rather than rejected, so a bad caller gets the first page instead of an exception.
    • Source resolution (AuditTrailReader.cs:52-53): resolves the Default database of the engine named by AuditTrail:DataSource (defaulting to SQL Server, AuditTrailSettings.cs:46) and asks the context factory for it.
    • Model gate (AuditTrailReader.cs:55-58): returns an empty list when the trail entity is not in the model. The comment gives the reason (AuditTrailReader.cs:25-29): the read surface is registered by AddAuditTrail, which a host may call before flipping AuditTrail:Enabled per environment, so "not enabled" must read as "no history", not as an exception.
    • The query (AuditTrailReader.cs:60-80): AsNoTracking, filtered on EntityType and EntityKey (exactly the leading columns of IX_AuditTrailEntries_Entity, ApplicationDbContext.cs:868-869), ordered, skipped and taken, then projected column by column into the DTO (AuditTrailReader.cs:66-78). TenantId is not projected, because the DTO has no such member: it declares Id through CorrelationId and stops there (MMCA.Common/Source/Core/MMCA.Common.Application/Auditing/AuditTrailEntryDTO.cs:12-48).
  • Why it's built this way: the DTO stops the persistence entity from leaking into the Application contract, and matching the query's predicate and sort to the shipped index is why that index carries the whole predicate plus the sort rather than just the key (ApplicationDbContext.cs:866-869).
  • Where it's used: registered as the scoped IAuditTrailReader by AddAuditTrail (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:119). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/AuditTrail/AuditTrailReaderTests.cs and the registration by AddAuditTrailTests.cs:64,89. A source search across the workspace finds no application or UI consumer of the interface today: it is a shipped read surface that hosts can inject, not a wired-up screen.
  • Caveats / not-in-source: the class documents its own v1 limitation (AuditTrailReader.cs:15-24). Trail rows are written to whichever database holds the entity that changed (that is what makes the write atomic), so a host that splits its modules across several databases has several trail tables, and this reader queries exactly one of them: the Default database of the configured engine. For a monolith, where every source collapses onto Default, that is the whole trail. Reading another module's history would need a per-source overload, which the comment says was deliberately deferred rather than guessed at.

EFRefreshSessionStore

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:31 · Level 13 · class (internal sealed)

  • What it is: the EF Core implementation of IRefreshSessionStore, the persistence side of multi-device refresh tokens. It reads and writes RefreshSession rows in whichever physical database holds them, and it owns the one operation the auth flow cannot express as a plain tracked mutation: the rotation claim (EFRefreshSessionStore.cs:31-175).
  • Depends on: IDbContextFactory (context access, saving and transactions), IEntityDataSourceRegistry and IDataSourceResolver (source resolution), RefreshSessionSettings via IOptions<T>, RefreshSession, and the UserIdentifierType alias. All four dependencies are primary-constructor parameters (EFRefreshSessionStore.cs:31-35).
  • Concept introduced, letting the database arbitrate a race instead of the process. [Rubric §11, Security] assesses whether credential handling is correct under concurrency, and [Rubric §12, Performance and Scalability] assesses whether the concurrency strategy holds with more than one instance running. Refresh-token rotation is a check-then-act: read the presented session, confirm it is live, revoke it, insert the successor. Two concurrent refreshes of the same token each read their own copy with RevokedAt null, and both would happily save, which is two valid successors from one token. The row deliberately carries no concurrency token, so the fix is to make the revocation itself the arbitration: a conditional UPDATE ... WHERE Id = @id AND RevokedAt IS NULL. Exactly one caller affects one row and wins; the loser affects zero rows and writes nothing. The remarks state this in full (EFRefreshSessionStore.cs:105-112).
  • Walkthrough
    • Context (EFRefreshSessionStore.cs:37) resolves the context per access through the factory keyed on ResolveDataSourceKey(), and Sessions (:38) is the DbSet<RefreshSession> over it.
    • ResolveDataSourceKey (EFRefreshSessionStore.cs:169-174) is the routing decision, and it has two legs. First the entity registry, so a consumer that ships a real entity configuration for RefreshSession routes it like any other entity. Otherwise a key built from the resolver's engine for Default plus the configured DataSourceName. The comment at :166-168 is precise about the split: the configured NAME is used verbatim, and only the ENGINE goes through the resolver, so a host that configures no SQL Server connection string gets the engine it does configure rather than a context over an empty connection string.
    • AddAsync (EFRefreshSessionStore.cs:42-46) stages the insert; it does not save. SaveChangesAsync (:100-101) delegates to the factory, which is what lets a login and its session insert commit in the same unit of work.
    • FindByTokenHashAsync (EFRefreshSessionStore.cs:49-56) is the validation lookup, backed by the unique TokenHash index.
    • GetUnrevokedByUserAsync (EFRefreshSessionStore.cs:63-71) is the family query, ordered by CreatedAt then Id. The tie-break is not cosmetic: the per-user cap evicts "the oldest", and two sessions opened in the same clock tick would otherwise evict in an arbitrary order (:58-61).
    • FindByIdAsync (EFRefreshSessionStore.cs:79-85) filters on both Id and UserId. The remarks make the security argument (:75-78): the id arrives from a client, so putting the user in the predicate is what makes another account's session unreadable rather than merely rejected after being read.
    • FindByIdUntrackedAsync (EFRefreshSessionStore.cs:93-97) is the one no-tracking read in the store, via AsNoTracking(), and it filters on Id alone. It exists to see past the tracked copy (which the identity map would otherwise hand back unchanged) to the row the database holds after a concurrent rotation, so a rotation loser can re-read the winner's result. Nothing revokes through the instance it returns (remarks at :88-92). The caller is AuthSessionIssuer (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:428).
    • TryRotateAsync (EFRefreshSessionStore.cs:121-164) is the claim. The tracked entry for the presented session is captured before the transaction opens so this context is one of the contexts the factory enlists rather than a late arrival (:130-132). Inside ExecuteInTransactionAsync (:134) it issues the conditional ExecuteUpdateAsync setting RevokedAt, ReasonRevoked = RefreshSession.ReasonRotated and ReplacedByTokenHash, and treats "one row affected" as the claim (:137-145). Losing returns false and writes nothing (:147-150).
    • Having won, it mirrors the claim onto the tracked instance with presented.Revoke(...) and then copies current values over original values (EFRefreshSessionStore.cs:155-156). That second line is easy to miss and load-bearing: without it, the next SaveChanges would re-issue the same UPDATE as a tracked modification. Then the successor is added and saved inside the same transaction (:158-159).
    • Sharing one transaction between the claim and the successor insert is what stops a loser observing a half-finished rotation: its UPDATE blocks on the winner's row lock, re-evaluates the predicate after the winner commits, and the family revocation it then performs sees the committed successor. A nested call joins the ambient transaction, so an ITransactional caller is unaffected (EFRefreshSessionStore.cs:113-119).
  • Why it's built this way: every read here except one is tracked on purpose, and the class doc says why (EFRefreshSessionStore.cs:24-29): the caller revokes by mutating the instances this returns, so a no-tracking query would take those revocations and silently drop them at save time. That is the opposite of the usual read-path default in this codebase and worth remembering. The one exception, FindByIdUntrackedAsync, is named in the class doc (:27-28) and is read-only by contract. Resolving the database through the registry first and the setting second means a single-database host needs no configuration at all (RefreshSessions:DataSourceName defaults to Default at MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:52) while a multi-database host names the Identity source once. Pointing it at a database that does not map the table fails loudly on the first query rather than reading the wrong rows (EFRefreshSessionStore.cs:21-22). The policy is ADR-097, building on ADR-050.
  • Where it's used: registered as the scoped IRefreshSessionStore (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:172), scoped deliberately, like the unit of work it shares a DbContext with, so a login and its session insert commit together (:143-144). The consumer is AuthenticationServiceBase<TUser>, which holds it as RefreshSessions (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:82, :88) and drives rotation, per-session revoke and sign-out-everywhere through it. DeleteUserHandlerBase<TUser, TCommand> revokes a deleted user's sessions through the same store from its soft-delete tail (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:110).

EFPermissionGrantStore

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFPermissionGrantStore.cs:35 · Level 13 · class (internal sealed)

  • What it is: the EF Core implementation of IPermissionGrantStore, the persistence side of stored permission grants (EFPermissionGrantStore.cs:35-151). It lists, grants and revokes rows in the PermissionGrants table.
  • Depends on: IDbContextFactory (context access and saving), IEntityDataSourceRegistry and IDataSourceResolver (source resolution), IOptions<PermissionGrantSettings>, TimeProvider, and IUniqueConstraintViolationDetector (classifies the unique-index failure a concurrent duplicate grant raises), all primary-constructor parameters (EFPermissionGrantStore.cs:35-41).
  • Concept introduced, check-then-act made harmless by a unique index, rather than a database-level conditional update. [Rubric §11, Security] (assesses whether a duplicate grant is handled deliberately) and [Rubric §8, Data Architecture]. Unlike EFRefreshSessionStore's rotation claim, granting a permission is idempotent by nature (a role either has a permission or it does not), so GrantAsync uses an ordinary check-then-act: query for an existing row, and only insert when none is found (EFPermissionGrantStore.cs:96-117). The comment names why that race is harmless here (:92-95): the unique index on (Role, Permission) makes a concurrent duplicate insert fail at the database, the loser catches that failure and answers success, because the row the winner wrote says exactly what the loser was trying to say. The check exists to make the ordinary duplicate free, not to be the guarantee.
  • Walkthrough
    • Context (EFPermissionGrantStore.cs:43) resolves the context per access through the factory, keyed on ResolveDataSourceKey(); Grants (:45) is the DbSet<PermissionGrant> over it.
    • GetAllAsync (:48-54) and GetPermissionsAsync(role, ct) (:57-70) are both AsNoTracking reads, the first ordered by Role then Permission for the administration listing, the second filtered to one role and projected to bare permission strings for PermissionGrantCache's rebuild.
    • GrantAsync(role, permission, grantedBy, ct) (:73-120) constructs a PermissionGrant through its Create factory, returns the factory's failure untouched on invalid input (:85-88), then runs the check-then-act described above: an existing row returns success without writing anything (:101-104), otherwise the grant is added to the resolved context and saved (:106-111). A DbUpdateException that the detector classifies as a unique-constraint violation is caught (:112): the insert lost the race to a concurrent duplicate, so its entry is set to EntityState.Detached (:116, comment at :114-115) so the scoped context does not retry the failed insert on its next save, and the method still returns success. Any other database failure propagates.
    • RevokeAsync(role, permission, ct) (:123-140) is a set-based ExecuteDeleteAsync filtered on both columns (:134-137), never a load-then-remove: there is at most one row by the unique index, nothing about it needs to be read, and removing zero rows is exactly the success an idempotent revoke promises (comment at :131-133).
    • ResolveDataSourceKey (:145-150) is the same two-leg routing shape as EFRefreshSessionStore's: the entity registry first (so a consumer entity configuration for PermissionGrant routes it like any other entity), otherwise a key built from the resolver's engine for Default plus the configured DataSourceName. Only the engine goes through the resolver; the configured name is used verbatim, so a host that configures no SQL Server connection string gets the engine it does configure rather than a context over an empty connection string (comment at :142-144).
  • Why it's built this way: the store is intentionally the thinnest possible layer over the model PermissionGrantModelBuilderExtensions maps: every method is a direct translation of one IPermissionGrantStore operation into one query or one set-based statement, with no caching or batching of its own, because PermissionGrantCache is the layer that owns performance and this one owns correctness against the database.
  • Where it's used: registered as the scoped IPermissionGrantStore by AddStoredPermissionGrants (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:149). Listed as a sanctioned soft-delete exception alongside the other framework bookkeeping stores in both MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Domain/SoftDeleteEnforcementTests.cs and MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/SoftDeleteEnforcementTests.cs, since PermissionGrant is deleted with ExecuteDeleteAsync rather than soft-deleted, the same reasoning ADR-119 applies to framework bookkeeping rows generally.

RefreshSessionCleanupService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/RefreshSessionCleanupService.cs:48 · Level 13 · class (public sealed partial, PeriodicBackgroundService)

  • What it is: the retention sweep for the RefreshSessions table. On an interval it hard-deletes every session that stopped being usable more than RetentionDays ago (RefreshSessionCleanupService.cs:48-150).
  • Depends on: IServiceScopeFactory, ILogger<RefreshSessionCleanupService>, IOptions<RefreshSessionSettings> and TimeProvider, all primary-constructor parameters passed straight to the PeriodicBackgroundService base (RefreshSessionCleanupService.cs:48-53); inside a sweep it resolves IEntityDataSourceRegistry, IDataSourceResolver and IDbContextFactory from the scope (:109-112).
  • Concept introduced, hard delete inside a soft-delete framework. [Rubric §30, Compliance, Privacy and Data Governance] assesses whether personal data has a bounded lifetime, and [Rubric §8, Data Architecture] assesses whether operational tables have a retention story. Everything modelled as an aggregate here is soft-deleted (ADR-005), so a hard DELETE is conspicuous and the class doc justifies it (RefreshSessionCleanupService.cs:18-22): a session is framework bookkeeping, not an aggregate. The row has no soft-delete flag and no audit stamps, and its content is a credential digest plus the IP and user-agent of the device that signed in. Keeping it past its usefulness is a growing table and a growing set of records describing a data subject's devices, so the sweep removes the row rather than flagging it. All three applications' soft-delete fitness tests list this type by name as a sanctioned exception (for example MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/SoftDeleteEnforcementTests.cs:53).
  • Walkthrough
    • _settings is captured once; _timeProvider re-captures the same constructor parameter into a field rather than reusing it directly, because the base constructor already consumes it and doing so would be CS9107 (RefreshSessionCleanupService.cs:55-59).
    • The loop mechanics, the wait-then-sweep cycle and the catch-log-continue behavior live once in PeriodicBackgroundService; this type supplies five overrides. Interval returns CleanupIntervalHours as hours (:62). StartupDelay returns Interval itself (:64-68), so the first sweep waits one full interval, unlike PermissionGrantRefreshService, which primes immediately. IsEnabled (:70-91) replaces the two early-exit checks a hand-rolled loop would need: RefreshSessions:Enabled false logs and returns false (:77-81, the comment at :75-76 notes registration is already gated on the flag and this is for a host that registers the service by hand), and RetentionDays <= 0 logs and returns false too (:83-87), the documented way to keep sessions forever; the base class skips the cycle entirely when this getter answers false. ExecuteCycleAsync delegates to PurgeAsync (:94), and LogCycleFailure routes an uncaught exception from a cycle to the source-generated LogCleanupError message (:97), so one bad sweep is logged and the loop carries on rather than ending the service.
    • PurgeAsync (RefreshSessionCleanupService.cs:105-133) computes the cutoff from the injected clock (:107), opens a scope and resolves the registry, resolver and context factory from it (:109-112).
    • Before deleting anything it checks context.Model.FindEntityType(typeof(RefreshSession)) and, on a miss, logs a warning naming the configured data source and returns (RefreshSessionCleanupService.cs:118-122). The comment at :115-117 gives the operator-experience reason: a source can be reachable and still not map the table, and saying so once per sweep beats a translation error the operator has to decode.
    • The delete itself is one ExecuteDeleteAsync over a two-armed predicate (RefreshSessionCleanupService.cs:124-128): revoked before the cutoff, or not revoked and expired before the cutoff. The method doc calls this "died" (:99-103), and both arms matter. A live session is never a candidate however old it is, and a session revoked minutes ago survives even if it expired long before, because that recent revocation is exactly what reuse detection reads.
    • The per-sweep count is logged unconditionally, zero included (RefreshSessionCleanupService.cs:132), because a "only when it deleted something" log cannot tell an operator that retention is running at all (:130-131). [Rubric §13, Observability and Operability] assesses precisely that distinction.
    • ResolveDataSourceKey (RefreshSessionCleanupService.cs:140-145) duplicates EFRefreshSessionStore's resolution exactly, and the class doc says why (:34-39): the sweep must never visit a different database than the store reads.
    • The five log messages are source-generated [LoggerMessage] partials (RefreshSessionCleanupService.cs:147-160), which is why the class is partial.
  • Why it's built this way: retention bounds reuse detection, and the class doc is explicit about the trade (RefreshSessionCleanupService.cs:24-32). BR-206 catches a replayed refresh token by finding its revoked row and revoking the whole family; a rotation chain older than the window is gone, so a replay of a token that old reads as an unknown token and fails alone instead of signalling reuse. The default window of 30 days (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:72) is far past the default refresh-token lifetime, so every token still capable of being replayed still has its row, and a host that shortens RefreshSessions:RetentionDays below Jwt:RefreshTokenExpirationDays is choosing to lose that signal. Unlike the outbox, sessions live in exactly one physical source, which is why the sweep resolves one database rather than iterating them (:33-39). The sweep is the retention half of the 2026-08-27 revision of ADR-097.
  • Where it's used: registered as a hosted service only when RefreshSessions:Enabled is true, read straight off configuration at registration time (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:182-185). The comment there gives the reason for the conditional (:151-153): registering it unconditionally would start a sweep in every service of a modular host, all but one of which has no RefreshSessions table to sweep. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Auth/RefreshSessionCleanupServiceTests.cs, which exercises both disabled paths, the retention predicate, the unmapped-table warning and the registration gate (:189, :201, :218).
  • Caveats / not-in-source: the default interval is 6 hours (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:80, constrained to 1 through 168), and because the loop waits one full interval before its first sweep, a host that restarts more often than the configured interval never sweeps. Nothing in the service compensates for that.

AuditTrailCleanupJob

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.AuditTrail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailCleanupJob.cs:45 · Level 16 · class (internal sealed partial)

  • What it is: the framework's own recurring job. It purges AuditTrailEntry rows older than the configured retention window from every relational database that holds a trail table, in batches.
  • Depends on: IScheduledJob (the contract it implements), IDbContextFactory, FrameworkTableTargets (which databases hold the table), AuditTrailSettings via IOptions<>, TenantDataSourceTargets (for CreateTenantScope) plus TenantDataSourceTarget, TimeProvider, IServiceScopeFactory and ILogger<T> (AuditTrailCleanupJob.cs:45-51).
  • Concept introduced, retention as a first-class part of a history feature. [Rubric §30, Compliance, Privacy & Data Governance] (assesses whether retained data has a bounded lifetime), [Rubric §31, Cost & FinOps] (assesses whether unbounded growth is designed out) and [Rubric §29, Resilience & Business Continuity] (a first sweep over a neglected table must not become one enormous transaction). The class comment makes the argument (AuditTrailCleanupJob.cs:16-20): a change trail grows monotonically and stores values that may describe a data subject, so a retention window is not housekeeping, it is what keeps the table from being both a cost centre and a data-protection liability. It also records the honest limitation (AuditTrailCleanupJob.cs:21-26): AddAuditTrail registers the job, but a job with no runner never executes, so the host must also call AddScheduledJobs and set Scheduler:Enabled. A host that records the trail without the scheduler is fully supported and keeps recording; pruning is then the operator's job.
  • Walkthrough:
    • Constructor (AuditTrailCleanupJob.cs:45-51): the last parameter, the scope factory, is optional and defaults to null, because a host without tenancy never leaves the job's own scope. Tenancy settings are no longer a parameter here: tenant discovery moved into FrameworkTableTargets. _settings is snapshotted from options.Value (:56), and RetentionDays defaults to 90 with a [Range(1, 3650)] guard (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSettings.cs:36-37).
    • Name and CronExpression (AuditTrailCleanupJob.cs:59,63): "audit-trail-cleanup", daily at 0 3 * * * (03:00 UTC), chosen to sit off the daily peak for every time zone the framework runs in (:62).
    • ExecuteAsync (AuditTrailCleanupJob.cs:66-79): returns immediately when AuditTrail:Enabled is false (:68-71), computes the cutoff as now minus RetentionDays (:73), then sweeps every target tableTargets.Relational() returns (:75-78). That call (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/FrameworkTableTargets.cs:43-44) takes every relational physical source backing a registered entity, deduplicates it and expands it per tenant that keeps its own copy, skipping Cosmos; it is the one answer the outbox processor, the internal-command processor, both cleanup services, both operator surfaces and this sweep share, rather than each re-deriving it from the registry, the resolver and the tenancy settings (FrameworkTableTargets.cs:16-18).
    • PurgeTargetAsync (AuditTrailCleanupJob.cs:86-103): the shared database is swept on the job's own scope; a tenant that keeps its own database gets a fresh scope from TenantDataSourceTargets.CreateTenantScope (:97), the shared extension that sets ITenantContext before the context is asked for, because the scoped context factory binds one database per scope and the job's scope is already bound to the shared one (doc at :81-85).
    • PurgeWithFactoryAsync (AuditTrailCleanupJob.cs:106-127): resolves the target's context and re-checks the model for the trail entity (:116-119, comment at :114-115). Cosmos is already excluded, but a relational source can still lack the table when the host disabled the trail after writing it, so the model, not the configuration, is the authority.
    • PurgeSourceAsync (AuditTrailCleanupJob.cs:133-170): the batching loop. It reads up to BatchSize (1000, :54) ids with AsNoTracking, ordered by ChangedOn, then deletes those ids with ExecuteDeleteAsync. The comment at :142-144 explains the two-step shape: every relational provider translates an IN list, while a limited DELETE is not universally supported. The loop ends when a page comes back empty (:153-156) or short (:163-166), and it re-checks the cancellation token each turn (:140). No row is ever materialized as an entity.
    • Logging (AuditTrailCleanupJob.cs:172-173): a source-generated LoggerMessage emitted only when a sweep actually removed rows (:122-126), so a quiet night logs nothing.
  • Why it's built this way: registering the job in AddAuditTrail rather than in AddScheduledJobs keeps the two features independent, which the DI comment states outright (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:121-123): the trail can be enabled without the scheduler and the scheduler without the trail. Set-based batched deletion is the same discipline the outbox retention sweep uses, for the same reason, and taking its target list from FrameworkTableTargets means this sweep visits exactly the databases the other framework-table sweeps visit.
  • Where it's used: registered through AddScheduledJob<AuditTrailCleanupJob>() inside AddAuditTrail (DependencyInjection.Jobs.cs:124, and that helper does a TryAddScoped of the concrete type plus a TryAddEnumerable of IScheduledJob at :75-76), and executed by ScheduledJobRunner when the host runs the scheduler. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/AuditTrail/AuditTrailCleanupJobTests.cs.

ColumnWidth

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/ColumnWidth.cs:4 · Level 0 · class (internal static)

  • What it is: an internal static helper with one method, Truncate(string? value, int maxLength), that cuts a string to a column's maximum length while preserving null (ColumnWidth.cs:4-12).
  • Depends on: nothing beyond the BCL string indexer and range operator.
  • Concept introduced, a defensive width guard for audit and command persistence rather than a value-object mapping. Both call sites write free-form diagnostic text (a trace ID, a CLR type name, a property key, a joined key string) into fixed-width columns where a value produced at runtime could exceed the column's declared size. Rather than let SQL reject the write or truncate silently at the provider, Truncate clips the string before it reaches EF: value is null || value.Length <= maxLength ? value : value[..maxLength] (ColumnWidth.cs:10-11). A null input passes straight through, so an absent value is never coerced into an empty string.
  • Walkthrough
    • Truncate (ColumnWidth.cs:10-11): a single expression-bodied method; no allocation happens when the value already fits.
  • Why it's built this way: the callers (an audit interceptor and internal command processing) run outside a validated domain type, so nothing upstream already guarantees the string fits its column; ColumnWidth.Truncate is the one place that guarantee is enforced before the row is written.
  • Where it's used: AuditTrailSaveChangesInterceptor truncates a correlation ID, tenant ID, entity type name, property name, and joined key string through it (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:194-195,255,323,602); ScheduledJobRunner and InternalCommandProcessor call it for the same reason at their own persistence boundaries.

StronglyTypedIdValueComparer<TSelf>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/StronglyTypedIdValueConverter.cs:80 · Level 0 · class (public sealed)

  • What it is: the EF Core ValueComparer<TSelf> paired with StronglyTypedIdValueConverter<TSelf, TValue> whenever a host opts a strongly typed identifier into persistence, so EF's change tracker compares two identifier instances by their wrapped value instead of by reference (StronglyTypedIdValueConverter.cs:80-93).
  • Depends on: TSelf's own Equals/GetHashCode via EqualityComparer<TSelf>.Default (:33); EF Core's ValueComparer<T> from Microsoft.EntityFrameworkCore.ChangeTracking (NuGet, :1).
  • Concept introduced, telling EF how to compare a wrapper type for change detection. ValueComparer<T> takes three delegates: an equality check, a hash function, and a snapshot function. Because TSelf here is constrained to struct (:26) and identifier wrappers are readonly record structs by convention, value equality is already correct and cheap, so the comparer forwards straight to EqualityComparer<TSelf>.Default for both equality and hashing, and returns the identifier itself as its own snapshot (identifier => identifier, :35). Without an explicit comparer EF Core would fall back to its own default, which for a non-primitive CLR property type would not know how to unbox and compare the wrapped value the way StronglyTypedIdValueConverter expects on the provider side. [Rubric §8, Data Architecture] covers this: a strongly typed identifier is only transparent to EF's change tracking because the comparer is registered alongside the converter, not instead of it.
  • Walkthrough
    • Constructor (:31-37): passes three lambdas to the ValueComparer<TSelf> base: (left, right) => EqualityComparer<TSelf>.Default.Equals(left, right) for equality, identifier => identifier.GetHashCode() for hashing, and identifier => identifier for the snapshot, since a value-type identifier needs no deep copy to snapshot safely.
  • Why it's built this way: StronglyTypedIdModelConfiguration.Apply always registers this comparer together with the converter through HaveConversion(converterType, comparerType) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/StronglyTypedIdModelConfiguration.cs:38-45), so a consumer adopting a strongly typed identifier (ADR-115) never has to write or register this class directly.
  • Where it's used: StronglyTypedIdModelConfiguration.Apply constructs it via typeof(StronglyTypedIdValueComparer<>).MakeGenericType(identifierType) for every identifier a host declares (StronglyTypedIdModelConfiguration.cs:40-41); its round trip is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/StronglyTypedIdPersistenceTests.cs.

UtcDateTimeConverter

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/UtcDateTimeConverter.cs:25 · Level 0 · class (internal sealed)

  • What it is: an internal ValueConverter<DateTime, DateTime> that normalizes every DateTime written to PostgreSQL to DateTimeKind.Utc, and stamps the same kind back onto values read from the column (UtcDateTimeConverter.cs:25-40).
  • Depends on: only the BCL DateTime/DateTimeKind and EF Core's ValueConverter<TModel, TProvider> (UtcDateTimeConverter.cs:1, implied by the file's imports).
  • Concept introduced, a provider-specific kind fixup that is not a value-object mapping. Unlike the converters above it, this one maps DateTime to itself: the model and provider CLR types are identical, and the transformation is purely about the Kind flag Npgsql checks before it will accept a timestamp with time zone write. [Rubric §8, Data Architecture] covers exactly this: the framework's committed answer to "every DateTime is mapped timestamp with time zone... never the process-wide Npgsql.EnableLegacyTimestampBehavior switch" (see MMCA.Common/CLAUDE.md, Multi-Database Strategy) is a per-property converter rather than a global legacy-behavior opt-out, so the fix is scoped to PostgreSQL alone and does not alter how SQL Server, SQLite, or Cosmos treat DateTime.
  • Walkthrough
    • Public constructor on an internal type (:58-65): the class doc comment states the reason directly (:60-64): EF instantiates a named converter through Activator.CreateInstance, which requires a PUBLIC parameterless constructor even when the enclosing type is internal.
    • Write leg (:67-69): value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc). A Local value is genuinely converted; anything else (Utc or the default Unspecified) is relabeled as Utc without shifting the clock value, on the assumption that an Unspecified value already represents UTC wall-clock time.
    • Read leg (:70): value => DateTime.SpecifyKind(value, DateTimeKind.Utc), stamping Utc back onto whatever Npgsql returns, since a round-tripped value otherwise comes back as Unspecified.
  • Why it's built this way: PostgreSQL's timestamp with time zone column type requires the .NET value to carry DateTimeKind.Utc (or Npgsql rejects it) once EnableLegacyTimestampBehavior is off; wrapping every DateTime property in this converter keeps that requirement satisfied without touching call sites in entity configurations.
  • Where it's used: PostgreSQLDbContext applies it (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/PostgreSQLDbContext.cs:1); MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/PostgreSQLDbContextModelTests.cs pins its round trip across five assertions. [113-postgresql-as-a-first-class-engine.md] records PostgreSQL's adoption as a first-class engine.

NullableStronglyTypedIdValueConverter<TSelf, TValue>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/StronglyTypedIdValueConverter.cs:56 · Level 3 · class (public sealed)

  • What it is: the optional-property counterpart of StronglyTypedIdValueConverter<TSelf, TValue>, a ValueConverter<TSelf?, TValue?> for an entity property typed as a nullable strongly typed identifier (StronglyTypedIdValueConverter.cs:56-71).
  • Depends on: IStronglyTypedId<TSelf, TValue> (the constraint on TSelf, :57-58) and its static From factory (:107); EF Core's ValueConverter<TModel, TProvider> (NuGet).
  • Concept reinforced, keeping "absent" absent through a static-abstract interface member. The null pass-through pattern is the same one taught at NullableEmailValueConverter: both legs short-circuit through private static helpers rather than inline conditional expressions (Unwrap/Wrap, :105-107). The reason is specific to this converter and worth naming: TSelf.From(value) is a call to a static abstract interface member, and a static abstract member cannot be invoked inside the expression tree an inline lambda argument to the base constructor would otherwise need; routing both legs through ordinary static methods sidesteps that restriction (the sibling class doc states this for the non-nullable converter at :22-23). [Rubric §8, Data Architecture] covers nullability as a modeled fact: a TSelf? property whose value is absent stays a NULL column rather than a default-valued wrapper.
  • Walkthrough
    • Constructor (:100-103): base(id => Unwrap(id), value => Wrap(value)).
    • Unwrap (:105): identifier?.Value, the null-conditional access to the wrapped primitive, itself a ValueType, IEquatable<TValue>-constrained provider value.
    • Wrap (:107): value is null ? null : TSelf.From(value.Value), calling the static-abstract factory only when a value is present.
  • Why it's built this way: EF resolves a converter against the declared CLR property type, so a nullable identifier property needs its own converter distinct from the non-nullable one; StronglyTypedIdModelConfiguration.Apply currently wires only the non-nullable StronglyTypedIdValueConverter<TSelf, TValue> for every registered identifier (StronglyTypedIdModelConfiguration.cs:38-45), so a host adopting a nullable strongly typed identifier property would need to register this converter itself.
  • Where it's used: its unit tests exercise the round trip (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/StronglyTypedIdPersistenceTests.cs); no production entity configuration in MMCA.Common, MMCA.ADC, or MMCA.Store maps a property through it today. [115-strongly-typed-identifiers-opt-in.md] documents the opt-in identifier feature this converter belongs to.

StronglyTypedIdValueConverter<TSelf, TValue>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/StronglyTypedIdValueConverter.cs:28 · Level 3 · class (public sealed)

  • What it is: the EF Core ValueConverter<TSelf, TValue> that stores a strongly typed identifier as the primitive it wraps and rebuilds the wrapper on read, so adopting the wrapper leaves the backing column (an int, bigint, uniqueidentifier, or nvarchar) unchanged (StronglyTypedIdValueConverter.cs:28-30, class doc :7-11).
  • Depends on: IStronglyTypedId<TSelf, TValue> (the constraint on TSelf, :29-30) and its static From factory (:142); EF Core's ValueConverter<TModel, TProvider> (NuGet, :2).
  • Concept introduced, a pre-convention identifier mapping that never becomes a migration. A host does not apply this converter property by property: services.AddStronglyTypedIds(typeof(OrderId).Assembly) registers every declared identifier as a pre-convention type mapping on ApplicationDbContext.ConfigureConventions (class doc :13-19), which StronglyTypedIdModelConfiguration.Apply implements by constructing this converter generically per identifier type (StronglyTypedIdModelConfiguration.cs:38-45) and pairing it with StronglyTypedIdValueComparer<TSelf>. Pre-convention is the load-bearing detail (class doc :14-19): the provider CLR type is known before EF picks a value-generation strategy, so a wrapped int key still maps to a SQL Server IDENTITY column and a wrapped Guid key still takes its client-side generator. [Rubric §4, Domain-Driven Design] assesses whether identifiers carry domain meaning rather than being bare primitives; wrapping is the recorded opt-in alternative to the identifier-type aliases (ADR-115, see MMCA.Common/CLAUDE.md, Entity Model), on the same footing smart enumerations sit on. [Rubric §8, Data Architecture] covers the storage-shape guarantee: nothing in the schema changes when a consumer adopts the wrapper.
  • Walkthrough
    • Type parameters and constraints (:28-30): ValueConverter<TSelf, TValue> with where TSelf : struct, IStronglyTypedId<TSelf, TValue> and where TValue : notnull, IEquatable<TValue>.
    • Constructor (:135-138): base(id => Unwrap(id), value => Wrap(value)), routed through static helpers rather than inline lambda bodies because, per the class doc (:21-24), a static abstract interface member (TSelf.From) cannot be invoked inside an expression tree.
    • Unwrap (:140): identifier.Value, reading the wrapped primitive straight off the identifier.
    • Wrap (:142): TSelf.From(value), the static-abstract factory every strongly typed identifier implements through IStronglyTypedId<TSelf, TValue>.
  • Why it's built this way: the domain wants identifiers that cannot be confused across aggregate boundaries even though they share the same wrapped primitive type; the database wants the same column it already had. The pre-convention ConfigureConventions hook satisfies both without a per-property HasConversion call at every entity configuration, and shipping the pair (converter + comparer) from the framework means the value-generation strategy for a wrapped key is never silently lost.
  • Where it's used: StronglyTypedIdModelConfiguration.Apply constructs it via typeof(StronglyTypedIdValueConverter<,>).MakeGenericType(identifierType, valueType) for every identifier a host declares (StronglyTypedIdModelConfiguration.cs:38-45); its round trip is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/StronglyTypedIdPersistenceTests.cs. [115-strongly-typed-identifiers-opt-in.md] documents the feature; nothing in Source/ adopts a wrapper today (MMCA.Common/CLAUDE.md, Entity Model), so production usage remains ahead of demand.

EnumerationValueConverter<TEnumeration>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EnumerationValueConverter.cs:33 · Level 4 · class (public sealed)

  • What it is: the shipped EF Core ValueConverter<TEnumeration, int> that stores a smart-enumeration member as its integer Value and rebuilds the member when a row is read back (EnumerationValueConverter.cs:33). It is the packaged replacement for the two lambdas every entity configuration would otherwise hand-roll to map an Enumeration<TEnumeration> property.
  • Depends on: Enumeration<TEnumeration> (the generic constraint, EnumerationValueConverter.cs:34), its Value property (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:105) and its static FromValue factory (Enumeration.cs:121); Result<T> indirectly, because FromValue returns one; EF Core's ValueConverter<TModel, TProvider> from Microsoft.EntityFrameworkCore.Storage.ValueConversion (NuGet, EnumerationValueConverter.cs:1).
  • Concept introduced, mapping a smart enumeration onto the column a CLR enum already used. A smart enumeration is a class, not a language enum, so the naive EF answer is OwnsOne: the member becomes an owned entity type with its own columns, which turns "replace the enum property with a richer type" into a schema change and a migration. HasConversion takes the other route. It keeps a single flat column and supplies a pair of expression trees: a write leg that turns the model value into the provider value, and a read leg that turns the provider value back. Because this converter's provider type is int (EnumerationValueConverter.cs:33), the backing column stays exactly the plain integer column a CLR enum was already persisted into, so adopting the smart enumeration in the domain changes nothing in the database. The class doc states this as the reason for the choice (EnumerationValueConverter.cs:6-11). [Rubric §4, Domain-Driven Design] assesses whether the domain models concepts as behavior-carrying types rather than primitives or bare enums; a framework-shipped converter removes the storage-cost argument against doing so. [Rubric §8, Data Architecture] assesses how deliberately the storage shape is chosen; the flat int column keeps indexes, existing rows, and prior migrations untouched.
  • Walkthrough
    • Type parameters and constraint (EnumerationValueConverter.cs:33-34): ValueConverter<TEnumeration, int> with where TEnumeration : Enumeration<TEnumeration>, the curiously-recurring constraint that makes FromValue resolve to the concrete member type rather than to the base.
    • Constructor, write leg (EnumerationValueConverter.cs:41): member => member.Value, the member's declared stable integer (Enumeration.cs:105). No validation happens here: a member reference is valid by construction, since the only members that exist are the ones the type declares.
    • Constructor, read leg (EnumerationValueConverter.cs:42): value => Enumeration<TEnumeration>.FromValue(value).Value!. FromValue looks the value up in the type's interned member dictionary and returns a success result (Enumeration.cs:123-124) or an invariant failure coded Enumeration.UnknownValue (Enumeration.cs:126-130). Because Result<T>.Value is declared nullable and simply returns null on failure rather than throwing (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Result.cs:206-207), the null-forgiving ! means a row carrying a value no member declares materializes a null reference for that property instead of failing materialization.
    • The read-leg contract, spelled out (EnumerationValueConverter.cs:23-30): the read leg trusts the column. Every value the write leg can produce round-trips, because the write leg can only persist an already-declared member; the contract can only be broken by a value written outside EF (a manual script, a data fix, or a member deleted from the enumeration after rows were written).
    • What it deliberately does not do (EnumerationValueConverter.cs:19-21): requiredness, default value, and precision stay at the call site, because they differ per entity. The documented usage chains .IsRequired() next to the HasConversion call (EnumerationValueConverter.cs:13-18).
  • Why it's built this way: the domain wants a type that can carry names, ordering, and behavior; the database wants the same integer column it already has. HasConversion satisfies both, and shipping the converter from the framework means every consumer gets the same interning behavior on read rather than a per-configuration lambda that might allocate a fresh instance.
  • Where it's used: no entity configuration in MMCA.Common, MMCA.ADC, or MMCA.Store maps a property through it today; the only callers are its unit tests, which pin the round trip (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/EnumerationValueConverterTests.cs:17-26), the column types (EnumerationValueConverterTests.cs:29-35), and the fact that the read leg resolves the interned singleton for every declared member rather than a copy (EnumerationValueConverterTests.cs:38-45, asserted with BeSameAs). It is shipped ahead of demand, like the phone-number pair below.

NullableEnumerationValueConverter<TEnumeration>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EnumerationValueConverter.cs:62 · Level 4 · class (public sealed)

  • What it is: the optional-property counterpart of EnumerationValueConverter<TEnumeration>, a ValueConverter<TEnumeration?, int?> for an entity property whose enumeration member may be absent (EnumerationValueConverter.cs:62).
  • Depends on: the same Enumeration<TEnumeration> constraint and FromValue factory (EnumerationValueConverter.cs:63, :71); EF Core's ValueConverter<TModel, TProvider>.
  • Concept reinforced, keeping "absent" absent, with a sharper edge than the string converters. Both legs short-circuit on null (EnumerationValueConverter.cs:70-71), so "no member selected" stays a NULL column value. The failure mode this prevents is specific to integer-valued enumerations and worth naming: without the guards, an absent member would be written as the default int and read back as whichever member happens to declare the value zero. The doc says exactly that (EnumerationValueConverter.cs:49-51), and the test asserting it carries the same reasoning in its message (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/EnumerationValueConverterTests.cs:76-77). [Rubric §8, Data Architecture] covers nullability as a modeled fact rather than an accident.
  • Walkthrough
    • Write leg (EnumerationValueConverter.cs:70): member => member == null ? null : member.Value.
    • Read leg (EnumerationValueConverter.cs:71): value => value == null ? null : Enumeration<TEnumeration>.FromValue(value.Value).Value. Note the plain .Value here rather than the null-forgiving .Value! of the non-nullable sibling: the model type is already nullable, so an unknown stored value simply yields null.
    • Usage shape (EnumerationValueConverter.cs:52-59): the documented pattern pairs it with .IsRequired(false).
  • Why it's built this way: EF resolves a converter against the declared CLR property type, so one class cannot serve both TEnumeration and TEnumeration?. Shipping the pair keeps the nullable choice explicit at the configuration instead of hidden in a per-entity lambda.
  • Where it's used: like its sibling, only its unit tests today (EnumerationValueConverterTests.cs:58-68 for the value round trip, :70-78 for the null pass-through, :80-87 for the int? provider type). The tests declare a private Priority enumeration with members valued 1, 2 and 3 (EnumerationValueConverterTests.cs:89-93).

EmailValueConverter

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:33 · Level 5 · class (public sealed)

  • What it is: the shipped EF Core ValueConverter<Email, string> that stores an Email value object as its normalized string and rebuilds the value object when a row is read back (EmailValueConverter.cs:33). It exists so no entity configuration has to hand-roll the same two lambdas.
  • Depends on: Email and its Create factory (EmailValueConverter.cs:2, MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/Email.cs:30); Result<T> indirectly, since Create returns one; EF Core's ValueConverter<TModel, TProvider> (NuGet, EmailValueConverter.cs:1).
  • Concept reinforced, HasConversion over OwnsOne for a value object. The mechanism is the one taught at EnumerationValueConverter<TEnumeration>; what changes is the provider type. Here the column stays a plain string column (EmailValueConverter.cs:33), so a codebase can upgrade string Email to Email Email in the domain and change nothing in the database. That is the whole point of shipping the converter from the framework: the domain gets an invariant-protected type (Email.Create validates through EmailInvariants at Email.cs:34 and lowercases at Email.cs:39), while storage keeps the simplest possible shape. [Rubric §4, Domain-Driven Design] assesses whether the domain expresses concepts as rich types rather than primitives; a shipped converter removes the usual excuse for keeping an email as a bare string. [Rubric §15, Best Practices & Code Quality] assesses whether a change of this kind ripples: the converter is written once in Infrastructure and reused across the repos rather than copy-pasted per configuration.
  • Walkthrough
    • Type parameters (EmailValueConverter.cs:33): ValueConverter<Email, string>, so EF knows the CLR (model) type is Email and the provider (column) type is string.
    • Constructor, write leg (EmailValueConverter.cs:40): email => email.Value persists the already-normalized lowercase string that Email.Create produced (Email.cs:39). No validation happens here because a constructed Email is valid by construction.
    • Constructor, read leg (EmailValueConverter.cs:41): value => Email.Create(value).Value!. The read leg deliberately trusts the column. Result<T>.Value is nullable and returns null on failure rather than throwing (Result.cs:206-207), so the null-forgiving ! means a row whose stored text does not validate materializes a null reference for that property instead of blowing up model materialization. The class doc states the contract explicitly (EmailValueConverter.cs:24-31): every value the write leg can produce round-trips, because the write leg can only persist an already-validated Email; only a value written outside EF (a manual script, a data fix) can break it.
    • What the converter deliberately does not do (EmailValueConverter.cs:20-22): column facets (max length, IsUnicode, requiredness) stay at the call site, because they differ per entity. The documented usage pattern chains them next to the HasConversion call (EmailValueConverter.cs:14-19).
  • Why it's built this way: two forces meet here. The domain wants a type that cannot hold an invalid address; the database wants a column that indexes and migrates like the nvarchar it already was. HasConversion satisfies both, and shipping the converter pair from Common (rather than documenting the lambdas) means every consumer gets the same normalization and the same read-leg contract. Email itself names this class and its nullable sibling in its own doc comment (Email.cs:7-14), so the intended mapping is discoverable from the domain type.
  • Where it's used: ADC's Identity UserConfiguration maps User.Email through it (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:21), and Store uses it for both User.Email (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:24) and Customer.Email (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/EntityConfiguration/CustomerConfiguration.cs:36). The Store call sites carry an inline comment recording that the mapping is HasConversion and not OwnsOne precisely so the column shape stays a plain nvarchar (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:21-22). Round-trip behavior is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/EmailValueConverterTests.cs:16.

NullableEmailValueConverter

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:60 · Level 5 · class (public sealed)

  • What it is: the optional-property counterpart of EmailValueConverter, a ValueConverter<Email?, string?> for an entity property typed Email? (EmailValueConverter.cs:60).
  • Depends on: the same Email value object and EF Core's ValueConverter<TModel, TProvider>; see the sibling above for the shared shape.
  • Concept reinforced, keeping "absent" absent. The interesting part of a nullable converter is what it refuses to do. Both legs short-circuit on null (EmailValueConverter.cs:67-68): the write leg emits null rather than an empty string, and the read leg returns null rather than calling Email.Create(null) and producing a failed result. Without those guards, "no email" would silently become either an empty-string column value (which then fails validation on the way back) or a null reference produced by a failed factory call. Note the read leg here uses plain .Value (EmailValueConverter.cs:68), not the null-forgiving .Value! of the non-nullable sibling, because the model type is already nullable. [Rubric §8, Data Architecture] covers nullability as a modeled fact rather than an accident: NULL stays NULL end to end.
  • Walkthrough
    • Write leg (EmailValueConverter.cs:67): email => email == null ? null : email.Value.
    • Read leg (EmailValueConverter.cs:68): value => value == null ? null : Email.Create(value).Value.
    • Usage shape (EmailValueConverter.cs:52-57): the doc comment pairs it with .IsRequired(false) and a call-site HasMaxLength, exactly as the non-nullable sibling keeps facets outside the converter.
  • Why it's built this way: a single converter cannot serve both Email and Email?, because EF resolves the converter against the declared CLR property type. Shipping the pair keeps the choice explicit at the configuration and avoids per-entity nullable lambdas.
  • Where it's used: ADC's SpeakerConfiguration maps the optional Speaker.Email through it (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerConfiguration.cs:43), with the length facet coming from SpeakerInvariants.EmailMaxLength at the call site (SpeakerConfiguration.cs:44). Null pass-through is covered at MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/EmailValueConverterTests.cs:63.

NullablePhoneNumberValueConverter

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/PhoneNumberValueConverter.cs:61 · Level 5 · class (public sealed)

  • What it is: a ValueConverter<PhoneNumber?, string?> for an optional PhoneNumber property (PhoneNumberValueConverter.cs:61). It is the same shape as NullableEmailValueConverter with a different value object.
  • Depends on: PhoneNumber and its Create factory, which validates through PhoneNumberInvariants and trims (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/PhoneNumber.cs:30, :36); EF Core's ValueConverter<TModel, TProvider> (PhoneNumberValueConverter.cs:1).
  • Concept reinforced: null pass-through on both legs, taught at NullableEmailValueConverter. The doc comment states the same rule in the phone vocabulary: "no phone number" stays a NULL column value rather than becoming an empty string or a failed PhoneNumber.Create call (PhoneNumberValueConverter.cs:46-50).
  • Walkthrough
    • Write leg (PhoneNumberValueConverter.cs:68): phoneNumber => phoneNumber == null ? null : phoneNumber.Value, persisting the trimmed string the factory produced (PhoneNumber.cs:36).
    • Read leg (PhoneNumberValueConverter.cs:69): value => value == null ? null : PhoneNumber.Create(value).Value.
  • Where it's used: no entity configuration in MMCA.Common, MMCA.ADC, or MMCA.Store maps a property through it today; the only current callers are its unit tests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/PhoneNumberValueConverterTests.cs:40 for the value round trip, :53 for the null pass-through). It is shipped ahead of demand so that a consumer adopting the PhoneNumber value object does not have to write the lambdas.

PhoneNumberValueConverter

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conversions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/PhoneNumberValueConverter.cs:33 · Level 5 · class (public sealed)

  • What it is: the non-nullable ValueConverter<PhoneNumber, string>, storing a PhoneNumber as its trimmed string value and rebuilding the value object on read (PhoneNumberValueConverter.cs:33).
  • Depends on: PhoneNumber (PhoneNumberValueConverter.cs:2); EF Core's ValueConverter<TModel, TProvider> (PhoneNumberValueConverter.cs:1).
  • Concept reinforced: the HasConversion-over-OwnsOne mapping and the trust-the-column read leg, both taught at EmailValueConverter. The read-leg contract paragraph is repeated in this file's doc comment (PhoneNumberValueConverter.cs:24-31), including the note that only a value written outside EF can break the round trip.
  • Walkthrough
    • Write leg (PhoneNumberValueConverter.cs:40): phoneNumber => phoneNumber.Value.
    • Read leg (PhoneNumberValueConverter.cs:41): value => PhoneNumber.Create(value).Value!, null-forgiving for the same reason as the email converter: Result<T>.Value is null on failure (Result.cs:206-207), so an unparseable stored value materializes as null instead of throwing.
    • Facets stay at the call site (PhoneNumberValueConverter.cs:20-22): the documented usage chains HasMaxLength(PhoneNumberInvariants.MaxLength), IsUnicode(false), and IsRequired() next to HasConversion (PhoneNumberValueConverter.cs:13-19).
  • Where it's used: like its nullable sibling, it has no production call site in the three repos today; MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conversions/PhoneNumberValueConverterTests.cs:17 exercises the round trip and :30 pins the string provider type.

ConnectionStringSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/ConnectionStringSettings.cs:12 · Level 0 · class (sealed)

  • What it is: the class bound from the top-level ConnectionStrings section, one connection string per supported engine (SQL Server, PostgreSQL, SQLite, Cosmos) plus the Cosmos database name and a migrations assembly for each of the two migrated relational engines. It describes the Default physical data source, the one every unmapped logical name collapses onto.

  • Depends on: nothing first-party and nothing beyond string from the BCL, which is what puts it at Level 0. It is read by DataSourceResolver and cross-checked by ConnectionStringSettingsValidator.

  • Concept introduced, fail-fast configuration where the rule spans two sections. [Rubric §15, Best Practices & Code Quality] assesses whether misconfiguration is caught early. AddOptions<T>().Bind(...).ValidateDataAnnotations().ValidateOnStart() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:72-75) is the pattern every validated settings class uses, and ADR-070 makes it the required form: annotations run during host startup rather than lazily on first resolution. ValidateOnStart() is the load-bearing call.

    What this type teaches on top of that is the limit of the annotation approach. No property here is [Required], and the class doc says why (ConnectionStringSettings.cs:5-10): SQL Server is the default engine, but a host may run entirely on SQLite, PostgreSQL, or Cosmos and may declare its databases under DataSources instead of this section. A [Required] attribute can only see one property on one class, so the real invariant ("the host can reach SOME database") is expressed as an IValidateOptions<T> implementation registered alongside the binding, ConnectionStringSettingsValidator (DependencyInjection.cs:82-83). [Rubric §8, Data Architecture]: the rule spans both configuration shapes precisely because the physical topology is a deployment decision, not a compile-time one.

  • Walkthrough: one static field and seven { get; init; } strings.

    • SectionName = "ConnectionStrings" (ConnectionStringSettings.cs:15), reusing ASP.NET Core's own conventional section so GetConnectionString(...) and this class read the same data.
    • CosmosConnectionString (ConnectionStringSettings.cs:18), empty by default.
    • CosmosDatabaseName (ConnectionStringSettings.cs:25), the one property with a non-empty default, "MMCA". Its doc comment spells out the override path explicitly: a Cosmos host that needs a different database sets ConnectionStrings:CosmosDatabaseName, or a per-source DataSources:<Name>:CosmosDatabaseName, explicitly (ConnectionStringSettings.cs:19-24).
    • PostgreSQLConnectionString (ConnectionStringSettings.cs:28), empty by default.
    • PostgreSQLMigrationsAssembly (ConnectionStringSettings.cs:37), empty by default. Its doc states the same rule PhysicalDataSource.UsesMigrations implements: when empty the PostgreSQL source is created outright instead of migrated, the SQLite rule rather than the SQL Server one, because PostgreSQL support ships with no host that already depends on being migrated (ConnectionStringSettings.cs:29-36).
    • SqliteConnectionString (ConnectionStringSettings.cs:40), documented as typically a file path.
    • SQLServerConnectionString (ConnectionStringSettings.cs:43), the production engine's connection string.
    • SQLServerMigrationsAssembly (ConnectionStringSettings.cs:49), empty by default, which makes EF fall back to the DbContext assembly (ConnectionStringSettings.cs:45-48).
    • The registered validator is the interesting half. ConnectionStringSettingsValidator.Validate succeeds when either the top-level section names a database on any of the four engines (ConnectionStringSettingsValidator.cs:57-61) or any named DataSources entry does (ConnectionStringSettingsValidator.cs:68-74), and otherwise fails with a message that lists all four top-level settings plus the named-entry form because which one is missing depends on the host (ConnectionStringSettingsValidator.cs:38-44, ConnectionStringSettingsValidator.cs:47-54). Its remarks record the change of rule directly: this replaced a [Required] on SQLServerConnectionString that failed a legitimate SQLite-only host at startup (ConnectionStringSettingsValidator.cs:11-24).
  • Why it's built this way: init-only properties make the bound settings immutable after startup, which DataSourceResolver relies on, since it classifies every physical source once in its constructor (DataSourceResolver.cs:65-78). Keeping the "some database" check at boot rather than at first query is what stops a host from reporting healthy while unable to serve a request (ConnectionStringSettingsValidator.cs:20-24).

  • Where it's used: bound and validated in AddInfrastructure (DependencyInjection, DependencyInjection.cs:72-83); consumed by DataSourceResolver through IOptions<ConnectionStringSettings> (DataSourceResolver.cs:66, DataSourceResolver.cs:73), which reads it while seeding each engine's Default source (DataSourceResolver.cs:241-276) and while applying the Cosmos database-name fallback (DataSourceResolver.cs:290-291).

  • Caveats: the Cosmos database default was renamed from the application-specific "AtlDevCon" (the ADC conference database name) to the neutral "MMCA" (ConnectionStringSettings.cs:25); every default on this class is now framework-neutral.

DataSourceEntrySettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceEntrySettings.cs:19 · Level 0 · class (sealed)

  • What it is: the shape of ONE named entry under the DataSources configuration section, the per-logical-source counterpart to the top-level ConnectionStrings block. It carries a connection string per engine plus four per-source overrides (Cosmos database name and a migrations assembly for each of the two migrated relational engines).
  • Depends on: nothing first-party and nothing beyond string from the BCL, which is why it sits at Level 0. It is aggregated by DataSourcesSettings and read by DataSourceResolver.
  • Concept introduced, configuration as the physical-topology dial. [Rubric §8, Data Architecture] assesses how the logical model maps onto physical stores; [Rubric §7, Microservices Readiness] assesses whether a module can be lifted out with its own database. The declarative half of that story is UseDatabaseAttribute, which names a logical source on an entity configuration. This class is the other half: it says what that logical name physically means in a given deployment. Nothing in the code decides the topology; the same compiled assemblies run as a one-database monolith or as N separate databases depending on how many entries exist here. [Rubric §15, Best Practices & Code Quality]: because every property defaults to string.Empty (DataSourceEntrySettings.cs:22-64), a partially filled entry is legal and each empty value falls back to the corresponding top-level value, so a host adds a database by adding one JSON object and nothing else.
  • Walkthrough: eight { get; init; } properties, all defaulting to string.Empty.
    • CosmosConnectionString (DataSourceEntrySettings.cs:22) and CosmosDatabaseName (DataSourceEntrySettings.cs:25), the Cosmos pair; the database name falls back to the top-level CosmosDatabaseName when empty (DataSourceResolver applies that fallback at DataSourceResolver.cs:290-291 and again at DataSourceResolver.cs:318-320).
    • PostgreSQLConnectionString (DataSourceEntrySettings.cs:28), the PostgreSQL connection string for this source (ADR-113).
    • PostgreSQLMigrationsAssembly (DataSourceEntrySettings.cs:37), with a fallback that is narrower than the SQL Server one (DataSourceEntrySettings.cs:30-36): when empty, a source that collapses onto Default takes the top-level PostgreSQLMigrationsAssembly, but a separate named PostgreSQL source gets no migrations assembly unless its entry declares one, whereas SQLServerMigrationsAssembly falls back to the Default value with a warning.
    • SqliteConnectionString (DataSourceEntrySettings.cs:40), the SQLite path (ADR-018 polyglot persistence).
    • SqliteMigrationsAssembly (DataSourceEntrySettings.cs:55), and its doc is worth reading in full (DataSourceEntrySettings.cs:42-54): there is deliberately NO top-level fallback for it. The top-level ConnectionStrings section carries SQL Server and PostgreSQL migrations assemblies but no SQLite one, so a SQLite host declares its own here through an entry that collapses onto Default. Without that asymmetry a mixed-engine host would silently hand a relational-engine migrations assembly to a SQLite database.
    • SQLServerConnectionString (DataSourceEntrySettings.cs:58), the production engine's connection string.
    • SQLServerMigrationsAssembly (DataSourceEntrySettings.cs:64), the EF Core migrations assembly for THIS source, documented as falling back to the top-level value when empty (DataSourceEntrySettings.cs:60-63).
    • The resolver reads the relational pair through one engine-keyed switch, GetMigrationsAssembly (DataSourceResolver.cs:425-432), stamps the SQLite or PostgreSQL value onto the PhysicalDataSource via its object initializer, only for the matching engine (DataSourceResolver.cs:455-457), and names the offending setting per engine when two logical names collapse onto one database with conflicting values (DataSourceResolver.cs:451-458).
    • The XML doc carries a worked appsettings.json example for a Conference source (DataSourceEntrySettings.cs:9-18), which is the fastest way to see the intended shape.
  • Why it's built this way: init-only properties make a bound entry immutable after startup, and the "empty means inherit" rule is what keeps the single-database default intact: an app that configures no DataSources section behaves exactly as it did before the section existed (ADR-006).
  • Where it's used: bound as the value type of the dictionary that AddInfrastructure reads with configuration.GetSection(DataSourcesSettings.SectionName).Get<Dictionary<string, DataSourceEntrySettings>>() (DependencyInjection.cs:86-88); consumed by DataSourceResolver when it classifies logical names into physical sources (DataSourceResolver.cs:307-321) and when it tests whether any entry names a database for an engine (DataSourceResolver.cs:175-180), and by ConnectionStringSettingsValidator when it looks for any configured database (ConnectionStringSettingsValidator.cs:68-74).

DefaultSeed

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceResolver.cs:217 · Level 0 · record (sealed, private nested)

  • What it is: a three-field carrier describing what the Default physical source of one engine is built from: ConnectionString, MigrationsAssembly, and CosmosDatabaseName (DataSourceResolver.cs:217). It exists only inside DataSourceResolver, where it is computed once per engine and then threaded through the four private methods that build that engine's map.
  • Depends on: nothing first-party. It holds three string values, all of which may legitimately be empty. It is produced from ConnectionStringSettings and DataSourcesSettings by ResolveDefaultSeed (DataSourceResolver.cs:242-277).
  • Concept introduced, the two-branch answer to "which database is Default". [Rubric §8, Data Architecture] assesses whether the mapping from configuration to physical storage is explicit and has one owner; [Rubric §15, Best Practices & Code Quality] assesses whether a rule that several methods depend on is stated once instead of recomputed at each use. Default is the logical name every framework-owned table resolves to (outbox, inbox, scheduled jobs, audit trail), so "what is Default" has to have an answer even in a host that never wrote a top-level ConnectionStrings entry. The seed is that answer, and computing it into a record rather than passing three loose strings is what lets ClassifyEntries, RegisterDefaultSource, and RegisterNamedSource all agree on it (DataSourceResolver.cs:201-208).
  • Walkthrough
    • ConnectionString (DataSourceResolver.cs:214, DataSourceResolver.cs:217) is the connection the Default source uses, and is empty when the engine is unconfigured. Branch one of ResolveDefaultSeed takes it straight from the top-level section when that section names a connection for the engine (DataSourceResolver.cs:247-257). Branch two applies only when the top-level value is absent: the named DataSources entries are filtered down to the ones carrying a connection for this engine (DataSourceResolver.cs:259-261), their connection identities are counted distinctly (DataSourceResolver.cs:263-266), and when exactly ONE distinct database is named that database becomes Default (DataSourceResolver.cs:271-275). With several distinct databases and no top-level value there is no single answer, so the seed stays empty (DataSourceResolver.cs:276) and a genuinely multi-database host names the shared one by adding a DataSources:Default entry.
    • MigrationsAssembly (DataSourceResolver.cs:215) is populated only on the top-level branch, and only for SQL Server: ConnectionStrings carries no SQLite equivalent, and handing a SQLite Default source the SQL Server value in a mixed-engine host would scaffold the wrong schema (DataSourceResolver.cs:247-250). Branch two deliberately leaves it empty (DataSourceResolver.cs:268-270): every entry it considered has the seed's own connection identity, so those entries all collapse onto Default and contribute their declared assemblies through AddExplicitMigrationsAssemblies, conflicts included. A test pins that the single named entry's assembly still reaches the Default source that way (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/DataSourceResolverTests.cs:374).
    • CosmosDatabaseName (DataSourceResolver.cs:216) is the database name entries fall back to when they declare none of their own, applied by CosmosDatabaseNameOf (DataSourceResolver.cs:291-292) and again inline while classifying entries (DataSourceResolver.cs:318-320).
  • Why it's built this way: branch two cannot change an existing host's routing, because it fires only where the top-level value is absent (DataSourceResolver.cs:224-230). That is the additive-by-construction property the whole data-source layer is built on (ADR-006): a host that declares its database only under DataSources gets a working Default, and a host that declares it the old way resolves exactly as it always did.
  • Where it's used: only within DataSourceResolver. BuildEngineMap computes it (DataSourceResolver.cs:201) and passes it to ClassifyEntries (DataSourceResolver.cs:202), RegisterDefaultSource (DataSourceResolver.cs:203), and RegisterNamedSource (DataSourceResolver.cs:207). The two behaviors it decides are pinned by DataSourceResolverTests.cs:374 and DataSourceResolverTests.cs:395.
  • Caveats / not-in-source: a private nested type. It is inventoried because private nested types are, but nothing outside the resolver can name it, and it never leaves the constructor's call graph.

DataSourcesSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourcesSettings.cs:13 · Level 1 · class (sealed)

  • What it is: the bound DataSources section as a whole, a read-only dictionary of logical source name to DataSourceEntrySettings. It is the configuration input to database-per-microservice routing.
  • Depends on: DataSourceEntrySettings and DataSourceKey.DefaultName from the Application layer (DataSourceKey), referenced by its fully qualified name Application.Interfaces.Infrastructure.Persistence.DataSourceKey (DataSourcesSettings.cs:34).
  • Concept introduced, a settings class that validates in its own constructor. Most settings types here are inert bags validated by data annotations. This one is different, and the reason is stated in its own doc: a root-level dictionary section does not bind through the options pipeline, so AddInfrastructure builds the instance by hand from configuration.GetSection(...).Get<Dictionary<string, DataSourceEntrySettings>>() and registers it as a singleton (DataSourcesSettings.cs:8-11, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:84-88). With no options pipeline there is no ValidateOnStart, so the constructor becomes the fail-fast gate. [Rubric §15, Best Practices & Code Quality]: rejecting a reserved name at construction is the same guarantee ValidateOnStart would have given, implemented where the type can enforce it itself. [Rubric §8, Data Architecture]: the reserved-name rule protects a real invariant, Default is configured through the top-level ConnectionStrings section, and letting someone also declare a DataSources:Default entry would create two competing definitions of the same physical source.
  • Walkthrough
    • SectionName = "DataSources" (DataSourcesSettings.cs:16).
    • The constructor takes an optional IReadOnlyDictionary<string, DataSourceEntrySettings>? (DataSourcesSettings.cs:23) and substitutes an empty ordinal-comparer dictionary when it is null (DataSourcesSettings.cs:25), so "no DataSources section" is a first-class state rather than a null check at every call site.
    • It then walks every key (DataSourcesSettings.cs:27-40) and throws InvalidOperationException twice: on a blank or whitespace name (DataSourcesSettings.cs:29-32), and on any name equal to DataSourceKey.DefaultName under OrdinalIgnoreCase (DataSourcesSettings.cs:34-39). The second message is unusually good operator guidance: it names the offending entry and tells the reader that the Default source is configured via the top-level ConnectionStrings section, so remove or rename the entry.
    • Sources (DataSourcesSettings.cs:44), the get-only dictionary the resolver reads.
  • Why it's built this way: throwing during host construction is the earliest possible point at which a duplicate Default definition can be caught; the alternative is a routing bug that surfaces as data landing in the wrong database (ADR-006).
  • Where it's used: constructed and registered as a singleton in AddInfrastructure (DependencyInjection.cs:86-88), immediately before DataSourceResolver and EntityDataSourceRegistry (DependencyInjection.cs:88-89). It is consumed by the resolver's constructor (DataSourceResolver.cs:67, DataSourceResolver.cs:69-77), by its "is any database configured for this engine" test (DataSourceResolver.cs:175-180) and its classification pass (DataSourceResolver.cs:307-321), and by ConnectionStringSettingsValidator, which takes it as an optional constructor dependency so that a container binding the settings without AddInfrastructure still validates (ConnectionStringSettingsValidator.cs:26-30, ConnectionStringSettingsValidator.cs:68-74).
  • Caveats: the key comparison for reservation is OrdinalIgnoreCase (DataSourcesSettings.cs:34) while the default backing dictionary uses StringComparer.Ordinal (DataSourcesSettings.cs:25). The dictionary that configuration binding actually supplies is created by the binder, so its comparer is not determined by this file.

ConnectionStringSettingsValidator

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/ConnectionStringSettingsValidator.cs:30 · Level 2 · class (sealed, internal)

  • What it is: the startup validator for ConnectionStringSettings. It enforces exactly one rule: the host must be able to reach at least one database, declared either in the top-level ConnectionStrings section or on a named DataSources entry (ConnectionStringSettingsValidator.cs:5-9).

  • Depends on: DataSourcesSettings as an OPTIONAL primary-constructor parameter (ConnectionStringSettingsValidator.cs:30) and ConnectionStringSettings as the validated type (ConnectionStringSettingsValidator.cs:31). Externals: Microsoft.Extensions.Options for IValidateOptions<T> and ValidateOptionsResult.

  • Concept introduced, the rule that cannot be an attribute. A data annotation can only see the object it decorates. This rule spans two independently bound configuration sections, and that is the whole reason the class exists. The class remarks say what it replaced: a [Required] on ConnectionStringSettings.SQLServerConnectionString, which encoded "SQL Server is the only engine a host can boot on" (ConnectionStringSettingsValidator.cs:11-18). That stopped being true once a small application could run entirely on SQLite or Cosmos, declaring its databases through the DataSources section and leaving the top-level section empty; the annotation failed such a host at startup even though every one of its entities resolved to a configured database. The registration comment in the composition root makes the same point at the call site (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:77-81).

    [Rubric §17, DevOps & Deployment] assesses whether configuration is validated centrally rather than defensively at each read. The check runs once, at boot, under ValidateOnStart, so no downstream component has to ask "did anyone configure a database".

    [Rubric §13, Observability and Operability] assesses whether a failure tells the operator what to do. NoDatabaseConfiguredMessage is an internal const (ConnectionStringSettingsValidator.cs:38-44) rather than an inline string, and it lists all four top-level shapes (ConnectionStrings:SQLServerConnectionString, ConnectionStrings:PostgreSQLConnectionString, ConnectionStrings:SqliteConnectionString, ConnectionStrings:CosmosConnectionString), plus the named-entry form (DataSources:Tickets:SqliteConnectionString), because which one is missing depends on whether the host is a single-database monolith or a database-per-module one. The final sentence states the design decision itself: a host with no database at all cannot serve a request, so it fails here rather than on its first query.

    [Rubric §8, Data Architecture] shows up in what the rule deliberately does NOT weaken. A host with no connection string anywhere still fails to start (ConnectionStringSettingsValidator.cs:20-24). Silently booting one would trade a clear startup failure for an InvalidOperationException on the first query, or worse, a service reporting healthy while unable to serve a single request.

  • Walkthrough: one constant, the interface method, and two predicates.

    • The primary constructor takes DataSourcesSettings? dataSources = null (ConnectionStringSettingsValidator.cs:30). The default is what makes the validator constructible in a container that binds the settings without calling AddInfrastructure (ConnectionStringSettingsValidator.cs:26-29); such a container still gets the top-level check.
    • NoDatabaseConfiguredMessage (ConnectionStringSettingsValidator.cs:38-44): internal const, so tests assert against the same string the host emits.
    • Validate(string? name, ConnectionStringSettings options) (ConnectionStringSettingsValidator.cs:47-54): null-guards the options, then returns Success if either predicate holds and Fail(NoDatabaseConfiguredMessage) otherwise. Note the short-circuit ||: one database anywhere is enough.
    • HasTopLevelConnection (ConnectionStringSettingsValidator.cs:57-61): static, and uses IsNullOrWhiteSpace rather than IsNullOrEmpty across all four engine properties, which matters because ConnectionStringSettings initialises each of them to string.Empty rather than leaving them null (ConnectionStringSettings.cs:18, ConnectionStringSettings.cs:28, ConnectionStringSettings.cs:40, ConnectionStringSettings.cs:43).
    • HasNamedConnection (ConnectionStringSettingsValidator.cs:68-74): instance, because it reads the injected sources. It passes when ANY entry under DataSources names a database on any of the four engines. The doc explains why one is enough: an entry carrying a connection string is a physical source the resolver registers, so the host has somewhere to read and write even with the top-level section empty (ConnectionStringSettingsValidator.cs:63-67).
  • Why it's built this way: this is one of the two custom IValidateOptions<T> implementations the fail-fast configuration contract (ADR-070) records, the other being TenancySettingsValidator. Relaxing the SQL Server requirement into a cross-section reachability rule is what made the polyglot persistence story (ADR-018) usable by a host that never touches SQL Server, without giving up the boot-time guarantee for the hosts that do.

  • Where it's used: registered by AddInfrastructure with TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<ConnectionStringSettings>, ConnectionStringSettingsValidator>()) (DependencyInjection.cs:82-83), immediately after the ValidateOnStart chain that binds the section (DependencyInjection.cs:72-75); TryAddEnumerable, like the tenancy validator, because two modules calling AddInfrastructure must not run the same validation twice. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Settings/ConnectionStringSettingsValidatorTests.cs, which drives the validator directly for each engine and for the named-source shapes, and end to end through the real ValidateOnStart chain including the SQLite-only host.

  • Caveats: the type is internal, so a consumer cannot subclass or replace it; a host needing extra connection-string rules registers its own additional IValidateOptions<ConnectionStringSettings>.

IEntityDataSourceRegistry

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/IEntityDataSourceRegistry.cs:11 · Level 2 · interface

  • What it is: the contract for the eagerly-built registry that maps every configured entity type to the physical database it lives in. Four members: GetDataSourceKey(Type) (IEntityDataSourceRegistry.cs:17), GetDataSourceKey(string entityFullName) (IEntityDataSourceRegistry.cs:23), TryGetDataSourceKey(string, out DataSourceKey) (IEntityDataSourceRegistry.cs:29), and GetPhysicalSourcesInUse() (IEntityDataSourceRegistry.cs:35).
  • Depends on: DataSourceKey, the (Engine, Name) pair every member trades in.
  • Concept introduced, eager entity-to-database mapping. [Rubric §8, Data Architecture] assesses whether database routing is a deliberate, discoverable design rather than an accident of query order; [Rubric §7, Microservices Readiness] assesses whether a module can be lifted into its own service without rewriting application code. The doc comment (IEntityDataSourceRegistry.cs:5-10) states why the interface exists at all: it replaces a legacy lazy cache that was populated as a side effect of EF model building, so routing decisions (unit of work, navigation classification, outbox enumeration) no longer depend on a model having been built first. GetPhysicalSourcesInUse() returns the distinct databases this host actually uses, which is how migrations, EnsureCreated, and the outbox processor know which databases to touch (IEntityDataSourceRegistry.cs:31-35). The two strict GetDataSourceKey overloads are documented to throw InvalidOperationException for an unregistered entity (IEntityDataSourceRegistry.cs:16, IEntityDataSourceRegistry.cs:22), while TryGetDataSourceKey is the non-throwing probe used where a miss is legitimate, for example when ApplicationDbContext decides whether an entity belongs in the model it is currently building (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:979-981).
  • Why it's built this way: database-per-service (ADR-006) needs every entity to resolve to exactly one physical source; deriving that map from configuration classes instead of from a built model turns a misconfiguration into a loud startup failure instead of a silent wrong-database query.
  • Where it's used: implemented by EntityDataSourceRegistry and registered as a singleton in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:92). Consumers include DbContextFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:51), CrossDataSourceDegradeConvention (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/CrossDataSourceDegradeConvention.cs:36), DataSourceService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceService.cs:12), the OutboxProcessor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:64), the OutboxCleanupService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxCleanupService.cs:50), the AuditTrailCleanupJob (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailCleanupJob.cs:49), both model-building passes of ApplicationDbContext (ApplicationDbContext.cs:386, ApplicationDbContext.cs:963), and the startup database-initialization path (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:70).

Snapshot

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/EntityDataSourceRegistry.cs:25 · Level 2 · record (sealed, private nested)

  • What it is: the immutable point-in-time view of EntityDataSourceRegistry's state, with three members: FrozenDictionary<string, (DataSourceKey Key, Type ConfigurationType)> Entities, FrozenSet<Assembly> ScannedAssemblies, and the precomputed IReadOnlyCollection<DataSourceKey> PhysicalSources (EntityDataSourceRegistry.cs:25-28).
  • Depends on: DataSourceKey; System.Collections.Frozen and System.Reflection.Assembly (BCL).
  • Concept introduced, the lock-free volatile-snapshot pattern. [Rubric §12, Performance & Scalability] assesses whether hot-path reads avoid contention. The registry holds private volatile Snapshot? _snapshot (EntityDataSourceRegistry.cs:31) and reads it without a lock, relying on volatile for the store/load barrier so every thread sees a fully-published reference. Writes (the initial build and any rescan) take Lock _rebuildLock (EntityDataSourceRegistry.cs:30) for mutual exclusion and then swap in a brand-new Snapshot. Because FrozenDictionary and FrozenSet cannot change once built, any number of readers share one snapshot with zero synchronization, and a rescan never mutates the instance a reader is holding.
  • Walkthrough
    • Entities maps an entity's full CLR type name to a tuple of the resolved DataSourceKey and the configuration type that produced it (EntityDataSourceRegistry.cs:26). The configuration type plays no part in routing; it exists so the duplicate-registration failure can name both conflicting configuration classes in its message (EntityDataSourceRegistry.cs:145-148).
    • ScannedAssemblies records which assemblies the snapshot covered, so the registry can tell a genuine lookup miss from a merely stale scan (EntityDataSourceRegistry.cs:104).
    • PhysicalSources is the distinct-key list computed once at build time (EntityDataSourceRegistry.cs:160). The remark on GetPhysicalSourcesInUse explains why it is materialized rather than projected per call (EntityDataSourceRegistry.cs:74-80): the outbox processor and the outbox cleanup service both ask for it on every poll cycle, and re-running Select().Distinct() over every registered entity allocated a fresh list each time, forever, on a loop that usually finds nothing to do.
  • Where it's used: exclusively inside EntityDataSourceRegistry: built by BuildSnapshot (EntityDataSourceRegistry.cs:157-160), published by GetOrBuildSnapshot (EntityDataSourceRegistry.cs:94) and replaced by RescanIfAssembliesChanged (EntityDataSourceRegistry.cs:109-111).
  • Caveats / not-in-source: a private nested type. It appears in this inventory because private nested types are inventoried, but it is not part of the public API and nothing outside the registry can name it.

TenantDataSourceTarget

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:15 · Level 2 · record struct (readonly)

  • What it is: one unit of work for a background sweep, a (DataSourceKey Source, string? TenantId) pair. TenantId is null for the shared database and set when the target is a tenant that keeps its own copy of that source (TenantDataSourceTargets.cs:8-15).
  • Depends on: DataSourceKey.
  • Concept introduced, the sweep unit under database-per-tenant. [Rubric §8, Data Architecture] assesses whether the storage topology is modelled explicitly rather than assumed, and [Rubric §29, Resilience & Business Continuity] assesses whether background work reaches every store it is responsible for. Once multi-tenancy (ADR-073) is in play, "the databases this host owns" is no longer the same set as "the units a background job must visit": a physical source can exist once as the shared database and again as one private database per tenant that overrides it. Making that pair a value type means the sweep loops over a flat list instead of nesting a tenant loop inside a source loop, and readonly record struct gives structural equality for free, which is what the tests assert against (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Tenancy/TenantDataSourceTargetTests.cs:30-32).
  • Walkthrough
    • The positional declaration TenantDataSourceTarget(DataSourceKey Source, string? TenantId) (TenantDataSourceTargets.cs:15); the null-versus-set convention on TenantId is the whole vocabulary of the type (TenantDataSourceTargets.cs:13-14).
    • ToString() (TenantDataSourceTargets.cs:19-22) renders the bare source name for a shared target and appends " (tenant {TenantId})" for a per-tenant one, so a log line says which of two visits to the same source it is describing. That rendering is pinned by a test (TenantDataSourceTargetTests.cs:93-94), which is a small [Rubric §13, Observability & Operability] point: without it, two consecutive log lines for the same source would be indistinguishable.
  • Why it's built this way: consumers branch on TenantId to decide whether they need a fresh DI scope. A null target runs on the caller's own scope; a non-null one creates a scope, calls ITenantContext.SetTenant(...) before asking for the context, because the tenant is what routes the scoped factory to that tenant's connection string. Every sweep now gets that branch for free through TenantDataSourceTargets.CreateTenantScope (OutboxCleanupService.cs:98, AuditTrailCleanupJob.cs:97).
  • Where it's used: produced by TenantDataSourceTargets.Expand (via ExpandRelational) and consumed as the loop variable of every host-owned sweep: OutboxProcessor (OutboxProcessor.cs:150, iterated at OutboxProcessor.cs:160), OutboxCleanupService (OutboxCleanupService.cs:197-198), AuditTrailCleanupJob (AuditTrailCleanupJob.cs:78, and as a parameter at AuditTrailCleanupJob.cs:87 and AuditTrailCleanupJob.cs:108), and the startup initializer (DatabaseInitializationExtensions.cs:209-210).

PhysicalDataSource

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:21 · Level 11 · record (sealed)

  • What it is: the fully-resolved connection information for one physical database. Four positional members, Key (its engine plus name identity), ConnectionString, MigrationsAssembly? and CosmosDatabaseName (PhysicalDataSource.cs:21-25), plus two computed properties: UsesMigrations (PhysicalDataSource.cs:42-48) and IsMigrationTarget (PhysicalDataSource.cs:57-60).
  • Depends on: DataSourceKey and, through it, DataSource; DataSourceEngines, looked up by Key.Engine, whose DataSourceEngineCapabilities supply the MigrationPolicy and the ConnectionStringRequired flag that both computed properties read (PhysicalDataSource.cs:42, PhysicalDataSource.cs:59).
  • Concept, a logical name resolved into a real connection. [Rubric §8, Data Architecture] covers the step from a configured name like DataSources:Conference to an actual database. The record's doc comment (PhysicalDataSource.cs:6-10) explains that it is produced by IDataSourceResolver from the top-level ConnectionStrings section (the Default source) plus the named DataSources entries. MigrationsAssembly is read from the configuration key of the source's own engine, SQLServerMigrationsAssembly, SqliteMigrationsAssembly, or PostgreSQLMigrationsAssembly, and is always null for Cosmos, which migrates nothing (PhysicalDataSource.cs:13-18); CosmosDatabaseName is ignored for relational engines (PhysicalDataSource.cs:20). A physical source belongs to exactly one engine, so one slot is enough, and the resolver never hands one engine's assembly to another. Being a positional record buys two things at once here: value equality, so two resolutions of the same source compare equal, and non-destructive mutation, which is exactly how per-tenant routing is implemented.
  • Walkthrough
    • Key is the identity the rest of the stack routes on, and it is deliberately not recomputed when the connection changes. DbContextFactory.ResolveTenantOverride clones the shared source with shared with { ConnectionString = ..., CosmosDatabaseName = ... } and keeps the original key, because the key is what EF's model cache is keyed on, so swapping only the connection string is what lets one compiled model serve every tenant's database (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:169-194, ADR-073).
    • ConnectionString may legitimately be empty. Both the startup initializer and EnsureCreatedAsync treat an empty connection string as "this source is not configured in this host" and skip it rather than failing (DatabaseInitializationExtensions.cs:94, DbContextFactory.cs:247-248).
    • MigrationsAssembly is a single positional parameter rather than three engine-scoped record-body properties, because at most one is ever populated: BuildPhysicalSource on DataSourceResolver already reads it from the right engine's setting, and passes null for an engine whose migration policy is Never (DataSourceResolver.cs:442-452).
    • UsesMigrations (PhysicalDataSource.cs:42-48) is the switch that decides Migrate versus EnsureCreated for this one database. It names no engine: it switches on the MigrationPolicy that the engine's descriptor declares in its capabilities, DataSourceEngines.For(Key.Engine).Capabilities.Migrations (PhysicalDataSource.cs:42). Always migrates unconditionally (PhysicalDataSource.cs:44), which is SQL Server, including the single-database monolith whose Default source names no migrations assembly at all and lets EF look next to the context. WhenAssemblyConfigured migrates only once MigrationsAssembly is set (PhysicalDataSource.cs:45), which is the rule PostgreSQL and SQLite share: neither ships with a host that already depends on being migrated, and a SQLite source wired by hand before that setting existed has no migrations to apply and must keep being created outright. Never is Cosmos, whose provider has no migrations pipeline (PhysicalDataSource.cs:46), and an unrecognized policy falls to false (PhysicalDataSource.cs:47). The per-engine reasoning is kept in the property's doc comment (PhysicalDataSource.cs:27-41). All branches are pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/PhysicalDataSourceTests.cs:21, PhysicalDataSourceTests.cs:33, PhysicalDataSourceTests.cs:49, PhysicalDataSourceTests.cs:64, PhysicalDataSourceTests.cs:78, and PhysicalDataSourceTests.cs:90.
    • IsMigrationTarget (PhysicalDataSource.cs:57-60) narrows UsesMigrations to the sources startup actually migrates (or, under the None strategy, checks): a source that uses migrations and either belongs to an engine whose capabilities set ConnectionStringRequired or has a non-empty connection string. The doc comment (PhysicalDataSource.cs:50-56) names the consequence: only SQL Server stays a target with an empty connection string, so that misconfiguration fails loudly at startup instead of being skipped, while an optional SQLite source a host leaves unconfigured stays silently absent. One rule, shared by the context factory's GetMigrationTargets (DbContextFactory.cs:804-810) and the startup pending-migrations check (DatabaseInitializationExtensions.cs:371).
  • Where it's used: produced by DataSourceResolver through BuildPhysicalSource (DataSourceResolver.cs:442-452) for both the Default source (DataSourceResolver.cs:364-369) and named ones (DataSourceResolver.cs:409-414), and handed back by GetPhysical (DataSourceResolver.cs:183-188); consumed by PhysicalDbContextFactory, whose Create(DataSourceKey) resolves it and whose Create(DataSourceKey, PhysicalDataSource) overload accepts an already-resolved one, which is the entry point the tenant clone uses (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:25-28). DesignTimeDbContextHelper resolves one for dotnet ef commands and hands the same record to the design-time context (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:140), and the startup initializer branches on ConnectionString, UsesMigrations and IsMigrationTarget (DatabaseInitializationExtensions.cs:94, DatabaseInitializationExtensions.cs:99, DatabaseInitializationExtensions.cs:227, DatabaseInitializationExtensions.cs:371).

DataSourceService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceService.cs:12 · Level 12 · class (public sealed)

  • What it is: the Application-facing facade over IEntityDataSourceRegistry. It answers two questions: which physical data source (engine plus database) does this entity live in, and can these two entities be joined with an EF Include (DataSourceService.cs:6-12).
  • Depends on: IEntityDataSourceRegistry as its single primary-constructor parameter (DataSourceService.cs:12), IDataSourceService as the contract it implements, the DataSourceKey and DataSource types it returns, and DataSourceEngines for the engine capability the include rule reads (DataSourceService.cs:32).
  • Concept introduced, routing as a first-class query. [Rubric §7, Microservices Readiness] assesses whether "where does this entity actually live" is answerable at runtime rather than being baked into code, and [Rubric §3, Clean Architecture] assesses whether the Application layer can ask that question without referencing EF. Database-per-service (ADR-006) means two entities written in the same module's code may or may not share a physical database depending on how the deployment is configured. Every consumer that needs to make a decision about that (which DbContext to open, whether a navigation can be eager-loaded or must go through a populator) asks this service. The class doc records an important property of the current design (DataSourceService.cs:9-10): the registry is built eagerly from configuration assemblies, so resolution no longer depends on an EF model having been built first, which is what makes it safe to consult from the Application layer at any point in startup.
  • Walkthrough
    • The four resolution members (DataSourceService.cs:15-24) are pure forwarders to the registry, in two pairs: GetDataSourceKey by Type and by full type name, and GetDataSource by the same two, each projecting .Engine off the resolved key. The name-based overloads exist because a caller holding only a metadata string (an EF entity type name, a message payload's type name) should not have to resolve a Type first.
    • HaveIncludeSupport(first, second) (DataSourceService.cs:31-32) is the whole classification rule in one expression: the two keys must be equal and the engine's descriptor must declare itself relational, DataSourceEngines.For(first.Engine).Capabilities.IsRelational. The rule names no engine; Cosmos fails it because its capabilities say it is not relational. The remarks at :27-30 state both halves: EF Include requires both entities in the same physical database on a relational engine, and Cosmos has no cross-document include.
    • HaveIncludeSupport(firstEntityFullName, secondEntityFullName) (DataSourceService.cs:35-38) is the name-based overload, and it uses TryGetDataSourceKey rather than the throwing form: an entity the registry does not know about yields false, which degrades to "use the populator path" rather than to an exception during navigation classification.
  • Why it's built this way: putting the include decision here rather than inside a repository is what lets NavigationMetadataProvider classify each navigation once and route it to either an EF Include or an INavigationPopulator (ADR-002) without knowing anything about deployment topology. Reading the relational flag off the engine descriptor keeps the same code correct on a non-relational store (ADR-018, ADR-130). It is registered as a singleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:60), which is safe because both the service and the registry behind it are immutable after construction.
  • Where it's used: UnitOfWork resolves an entity's key before choosing a context (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13, doc at :29); NavigationMetadataProvider uses it for navigation classification (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:20); in MMCA.Store, InventoryAllocationService (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Inventory/InventoryAllocationService.cs:35) and ProductImageStorageService (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Infrastructure/Services/ProductImageStorageService.cs:28) both resolve a key before opening the right context. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/DataSourceServiceTests.cs and .../DataSourceServiceAdditionalTests.cs.

IDataSourceResolver

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/IDataSourceResolver.cs:15 · Level 3 · interface

  • What it is: the contract that maps a logical data source name (from a [UseDatabase] attribute, a module namespace, or a setting such as Outbox:DatabaseName) to a physical DataSourceKey, and hands back the resolved PhysicalDataSource for such a key. Two members: ResolveLogical(DataSource engine, string logicalName) (IDataSourceResolver.cs:34) and GetPhysical(DataSourceKey key) (IDataSourceResolver.cs:42).
  • Depends on: DataSource, DataSourceKey, PhysicalDataSource.
  • Concept introduced, logical-to-physical collapse as the backward-compatibility guarantee. [Rubric §8, Data Architecture] and [Rubric §7, Microservices Readiness] both apply, because routing is reconfigurable purely through settings. The interface comment (IDataSourceResolver.cs:5-14) states the collapse rule precisely: in a host with no DataSources configuration every logical name resolves to Default, yielding one DbContext per engine with an identical change tracker, FK constraints, transactions, and EF model to a plain single-database monolith. ResolveLogical's contract (IDataSourceResolver.cs:17-30) spells out the collapse cases: a name with no DataSources entry, a name with no connection string for the engine, and a name whose connection equals the top-level one all fall to DataSourceKey.Default(engine); entries sharing a connection with each other collapse to one physical source named after the alphabetically-first logical name.
  • Concept introduced, engine substitution. The second paragraph of the same contract (IDataSourceResolver.cs:24-29) adds a rule that is easy to miss and load-bearing for single-engine hosts: a request naming an engine the host configures no connection string for anywhere, neither top-level nor on a named entry, is served from an engine it does configure, preferring SQL Server, then SQLite, then Cosmos DB. That is what lets a SQLite-only host serve the framework's own tables, whose engine settings all default to SQL Server. A host that configures the requested engine, including a polyglot host configuring several (ADR-018), is never redirected. GetPhysical (IDataSourceResolver.cs:36-42) is the reverse lookup and is documented to throw when handed a key that did not come from ResolveLogical.
  • Why it's built this way: the collapse is what makes "build the monolith now, extract a service later" a configuration change rather than a rewrite (ADR-006). One interface owns the rule, so no caller reimplements the defaulting logic and no caller can disagree about what Default means.
  • Where it's used: implemented by DataSourceResolver and registered as a singleton (DependencyInjection.cs:109); injected into EntityDataSourceRegistry (EntityDataSourceRegistry.cs:23) to resolve each entity's derived logical name, into PhysicalDbContextFactory and DbContextFactory to open connections (PhysicalDbContextFactory.cs:21, DbContextFactory.cs:52), into the inbox store and both event buses so each can locate its own database (EfInboxStore MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Inbox/EfInboxStore.cs:43, InProcessEventBus MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/InProcessEventBus.cs:36, BrokerEventBus MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerEventBus.cs:34), into AuditTrailReader (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailReader.cs:36) and ScheduledJobRunner (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobRunner.cs:43), and optionally into TenancySettingsValidator, which uses ResolveLogical to check that a tenant override is keyed by a name that actually round-trips as a physical source (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:24, TenancySettingsValidator.cs:120).

DataSourceResolver

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceResolver.cs:16 · Level 13 · class (sealed, partial)

  • What it is: the singleton implementation of IDataSourceResolver. It builds the logical-to-physical map once at construction from the connection-string settings and the named DataSources entries, validates migrations-assembly conflicts, works out which engine to substitute for engines the host does not configure, and then serves both lookups from in-memory dictionaries. Every engine-specific question it asks (connection string, migrations assembly, setting name, migration policy, connection identity, substitution priority) is a lookup on the engine's descriptor through DataSourceEngines, so the class itself switches on no engine.
  • Depends on: DataSource, DataSourceKey, PhysicalDataSource, IDataSourceResolver, its own nested DefaultSeed, ConnectionStringSettings (taken as IOptions<T>), DataSourcesSettings and DataSourceEntrySettings; DataSourceEngines, IDataSourceEngine and MigrationPolicy for every per-engine answer; ILogger<T> and the [LoggerMessage] source generator, which is why the class is partial.
  • Concept introduced, eager and validated data-source resolution. [Rubric §8, Data Architecture] (deliberate multi-database routing) and [Rubric §7, Microservices Readiness] (ADR-006, database-per-service). The resolver realizes the collapse rule described on IDataSourceResolver. Two guardrails are worth calling out. First, conflicting migrations-assembly declarations on logical names that collapse to the same physical database throw at construction (DataSourceResolver.cs:477-484), a loud fail-fast rather than a silent pick. Second, [Rubric §13, Observability & Operability] shows up in the two source-generated log methods: LogMigrationsAssemblyFallback (DataSourceResolver.cs:509-510) warns when a named source on an engine that always migrates has no dedicated migrations assembly and therefore falls back to another database's, whose snapshot describes a different schema, and LogSubstituteEngine (DataSourceResolver.cs:506-507) states at startup that requests for an unconfigured engine are being served from another one.
  • Walkthrough
    • State (DataSourceResolver.cs:18-52): FrameworkDefaultEngine is DataSource.SQLServer, the engine every framework-owned table asks for by default, documented as a policy constant rather than an engine capability (DataSourceResolver.cs:18-23). EnginePreference is the substitution order, built by ordering DataSourceEngines.All by each engine's SubstitutionPriority, lowest first (DataSourceResolver.cs:35-36); its doc comment records the intended order, relational first because every table the framework owns is relational, SQL Server ahead of the others, and PostgreSQL ahead of SQLite because a host that configures a server engine means it to serve the framework's own tables (DataSourceResolver.cs:25-34). The lookups are _logicalToPhysical, keyed by (engine, logical name) (DataSourceResolver.cs:39), and _physicalSources, keyed by DataSourceKey (DataSourceResolver.cs:42). _configuredEngines (DataSourceResolver.cs:45) and _substituteEngine (DataSourceResolver.cs:52) carry the substitution decision.
    • Constructor (DataSourceResolver.cs:65-90): null-guards its two settings arguments (DataSourceResolver.cs:70-71), then calls BuildEngineMap for every engine registered in DataSourceEngines.All (DataSourceResolver.cs:75-78). It then computes the configured engines and the substitute through the two static helpers ConfiguredEngines and PickSubstituteEngine (DataSourceResolver.cs:80-81), and logs once when the substitute is not FrameworkDefaultEngine (DataSourceResolver.cs:83-89). Every field is written only here, which is what makes the singleton safe to share without locking.
    • ResolveLogical (DataSourceResolver.cs:93-107): substitutes the engine first (DataSourceResolver.cs:97), then short-circuits the Default name case-insensitively (DataSourceResolver.cs:99-102), then does a dictionary lookup whose miss returns DataSourceKey.Default(effectiveEngine) (DataSourceResolver.cs:104-106), which is the monolith default.
    • SubstituteUnconfiguredEngine (DataSourceResolver.cs:128-129) is one line, and its doc comment (DataSourceResolver.cs:109-125) carries the failure it removes: every engine choice the framework makes for its own tables comes from a setting defaulting to SQL Server (Outbox:DataSource, Scheduler:DataSource, AuditTrail:DataSource), and honoring that literally in a SQLite-only host handed those components a source with an empty connection string, so their first query failed with "The ConnectionString property has not been initialized".
    • ResolveFrameworkDefaultEngine (DataSourceResolver.cs:140-151) is an internal static answer to the same question without building a resolver: the engine ResolveLogical(SQLServer, "Default") would land on, which is FrameworkDefaultEngine when it is configured, otherwise the substitute, and FrameworkDefaultEngine again when nothing is configured (DataSourceResolver.cs:147-150). Registration code reads it to decide which services the default engine can back, raw SQL being registered only where that engine is relational (DataSourceResolver.cs:131-136, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:409). It shares ConfiguredEngines (DataSourceResolver.cs:154-159) and PickSubstituteEngine (DataSourceResolver.cs:165-169) with the constructor, so the two paths cannot disagree. HasAnyConnectionString (DataSourceResolver.cs:175-180) is what decides "configured", and it deliberately looks at named entries as well as the top-level section.
    • GetPhysical (DataSourceResolver.cs:183-188): a _physicalSources lookup that throws with an actionable message when the key was not produced by ResolveLogical.
    • BuildEngineMap (DataSourceResolver.cs:195-209): computes the DefaultSeed (DataSourceResolver.cs:201), splits the engine's named entries with ClassifyEntries (DataSourceResolver.cs:202), then registers the Default source and one named source per group (DataSourceResolver.cs:203-208).
    • ClassifyEntries (DataSourceResolver.cs:298-339): computes a per-connection identity string via GetIdentity (DataSourceResolver.cs:495-498), which appends the database name only for an engine whose descriptor sets ConnectionIdentityIncludesDatabaseName (Cosmos, where one account hosts many databases) and otherwise uses the connection string alone; the comparison is ordinal, so semantically-equal-but-textually-different connection strings deliberately do not collapse. Entries with no connection string for the engine are skipped entirely (DataSourceResolver.cs:311-316), because ResolveLogical already defaults on a map miss; entries matching the seed's identity go to the collapsed list (DataSourceResolver.cs:323-327) and the rest are grouped by identity (DataSourceResolver.cs:329-336).
    • RegisterDefaultSource (DataSourceResolver.cs:345-375): registers the Default key for the engine, letting entries that collapsed onto it contribute an explicit migrations assembly alongside the seed's own (DataSourceResolver.cs:352-362), and maps each collapsed logical name onto that key (DataSourceResolver.cs:371-374).
    • RegisterNamedSource (DataSourceResolver.cs:381-420): names the physical key after the alphabetically-first member (Order(...).First(), DataSourceResolver.cs:387) so routing is deterministic regardless of configuration key order, then, for an engine whose migration policy is Always (SQL Server), warns and falls back to the seed's assembly when the source declares none of its own (DataSourceResolver.cs:394-402). The group's Cosmos database name comes from the canonical entry, falling back to the seed (DataSourceResolver.cs:404-407).
    • BuildPhysicalSource (DataSourceResolver.cs:442-452): the migrations assembly was already read from the configuration key of this source's own engine via GetMigrationsAssembly, so building the record is a straight positional construction with the assembly passed through, null only for an engine whose policy is MigrationPolicy.Never (DataSourceResolver.cs:451); which is what stops a SQL Server assembly from being handed to UseSqlite or UseNpgsql.
    • ResolveMigrationsAssembly (DataSourceResolver.cs:465-487): returns null for a Never engine and when no explicit value exists (DataSourceResolver.cs:470-474), and throws when logical names sharing a database declare conflicting assemblies, naming the engine's own MigrationsAssemblySettingName and every declaration in the message (DataSourceResolver.cs:476-484). GetMigrationsAssembly (DataSourceResolver.cs:458-459), TopLevelMigrationsAssembly (DataSourceResolver.cs:287-288) and the two GetConnectionString overloads (DataSourceResolver.cs:500-504) are one-line delegations to the engine descriptor. The top-level one, used inside ResolveDefaultSeed, keeps each engine on its own value: only SQL Server and PostgreSQL declare a top-level migrations assembly, so a mixed-engine host can never scaffold one engine's schema from another's snapshot (DataSourceResolver.cs:279-283).
  • Why it's built this way: resolving eagerly at construction turns a misconfiguration into a startup failure rather than a mid-request surprise, and the deterministic canonical-name rule keeps routing stable across configuration orderings (ADR-006). The ordinal identity comparison is the conservative choice: collapsing two connection strings that only look different would silently merge two databases. Engine substitution is scoped the same conservative way, since it can only fire for a request that could not have been served at all (DataSourceResolver.cs:121-124). Pushing every per-engine answer onto the engine descriptor means a new engine is a new descriptor, not a new arm in each switch here (ADR-130).
  • Where it's used: registered as the singleton IDataSourceResolver (DependencyInjection.cs:91), with its static ResolveFrameworkDefaultEngine read during registration (DependencyInjection.cs:409); consumed by EntityDataSourceRegistry, the context factories, and DesignTimeDbContextHelper, which constructs its own instance for dotnet ef commands and registers it in a hand-built container (DesignTimeDbContextHelper.cs:131-134, DesignTimeDbContextHelper.cs:194). Its rules are pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/DataSourceResolverTests.cs:12, including the alphabetically-first canonical name (DataSourceResolverTests.cs:84), the two Cosmos cases where the same account with a different database stays distinct while the same database collapses (DataSourceResolverTests.cs:99, DataSourceResolverTests.cs:118), the SQLite-only host being served the framework's SQL Server default from SQLite (DataSourceResolverTests.cs:335), the SQL-Server-only host being unchanged (DataSourceResolverTests.cs:413), the polyglot host routing each engine to itself (DataSourceResolverTests.cs:424), the host with no database at all passing the engine straight through (DataSourceResolverTests.cs:479), and PostgreSQL being preferred over SQLite as the substitute (DataSourceResolverTests.cs:578).

EntityDataSourceRegistry

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/EntityDataSourceRegistry.cs:21 · Level 5 · class (sealed)

  • What it is: the singleton implementation of IEntityDataSourceRegistry. It reflects over the configuration assemblies, finds every entity type configuration, derives each entity's physical database from the configuration class's attributes and the entity's namespace, and freezes the result into a lock-free lookup that it rescans lazily when new assemblies appear.
  • Depends on: DataSourceKey, IEntityDataSourceRegistry, IDataSourceResolver, IEntityTypeConfigurationBase<TEntity, TIdentifierType>, NamespaceConventions, UseDataSourceAttribute, UseDatabaseAttribute, IEntityConfigurationAssemblyProvider, and its own nested Snapshot; System.Reflection, System.Collections.Frozen, and System.Threading.Lock (BCL).
  • Concept introduced, deriving routing from configuration classes rather than from the EF model. [Rubric §8, Data Architecture] (ADR-006: an entity lives in exactly one database) and [Rubric §15, Best Practices & Code Quality] (fail fast on an ambiguous configuration). The doc comment (EntityDataSourceRegistry.cs:8-19) states the design: the map is derived from configuration classes, so it exists before any model is built; it is built lazily on first access and rescanned once on a lookup miss to pick up module assemblies loaded later; and duplicate registrations of one entity are tolerated when they agree on the physical source and rejected when they conflict.
  • Walkthrough
    • Primary constructor (EntityDataSourceRegistry.cs:21-23) takes the IEntityConfigurationAssemblyProvider that decides which assemblies are scanned and the IDataSourceResolver that performs the final collapse. _rebuildLock (EntityDataSourceRegistry.cs:30) and the volatile _snapshot (EntityDataSourceRegistry.cs:31) implement the pattern taught under Snapshot.
    • GetDataSourceKey(Type) (EntityDataSourceRegistry.cs:34-38) null-guards and forwards to the string overload via FullName; GetDataSourceKey(string) (EntityDataSourceRegistry.cs:41-47) is TryGetDataSourceKey plus a throw whose message names the exact remedy, adding an EntityTypeConfigurationSQLServer/PostgreSQL/Cosmos/Sqlite for the entity in a discovered configuration assembly.
    • TryGetDataSourceKey (EntityDataSourceRegistry.cs:50-72): probes the current snapshot; on a miss it calls RescanIfAssembliesChanged once (EntityDataSourceRegistry.cs:63) and retries, then returns default and false. The two-step shape keeps the common hit path entirely off the lock.
    • GetPhysicalSourcesInUse (EntityDataSourceRegistry.cs:81-82): returns the snapshot's precomputed PhysicalSources, which is how migrations, EnsureCreated, and outbox draining enumerate this host's databases without allocating per call.
    • GetOrBuildSnapshot (EntityDataSourceRegistry.cs:84-96) reads the volatile field first and only takes the lock when it is null; RescanIfAssembliesChanged (EntityDataSourceRegistry.cs:98-113) rebuilds only when the provider reports assemblies not already in ScannedAssemblies (EntityDataSourceRegistry.cs:103-107), so a genuinely unknown entity costs one set comparison, not one rescan per lookup.
    • BuildSnapshot (EntityDataSourceRegistry.cs:115-161): for every loadable type in every configuration assembly it skips abstract and open-generic types (EntityDataSourceRegistry.cs:122-125), finds the closed IEntityTypeConfigurationBase<,> interface (EntityDataSourceRegistry.cs:127-132), takes the entity type from the first generic argument (EntityDataSourceRegistry.cs:134), and calls DeriveDataSourceKey. A second configuration registering the same entity against a different key throws with a message naming both configuration classes and both keys (EntityDataSourceRegistry.cs:141-152); an agreeing duplicate is simply ignored. The frozen dictionary, the scanned-assembly set, and the distinct physical-source list are built together at the end (EntityDataSourceRegistry.cs:157-160).
    • DeriveDataSourceKey (EntityDataSourceRegistry.cs:172-185): reads the engine from UseDataSourceAttribute and returns null when it is absent (EntityDataSourceRegistry.cs:174-178), deliberately skipping configurations that implement a provider interface directly instead of deriving from the attributed base classes. It then resolves the logical name in priority order, UseDatabaseAttribute first, then NamespaceConventions.GetModuleName, then DataSourceKey.DefaultName (EntityDataSourceRegistry.cs:180-182), and delegates the collapse to IDataSourceResolver.ResolveLogical (EntityDataSourceRegistry.cs:184).
    • GetLoadableTypes (EntityDataSourceRegistry.cs:190-200): wraps assembly.GetTypes() and tolerates ReflectionTypeLoadException by keeping the types that did load, mirroring module discovery, so a partially-loaded assembly does not abort the whole scan.
  • Why it's built this way: building from configuration classes at startup rather than per query means a missing or conflicting configuration surfaces early, which matters most in a multi-database system where the alternative failure mode is a silent read against the wrong database. The skip for configurations without [UseDataSource] is documented as matching legacy behavior (EntityDataSourceRegistry.cs:168-170): those entities land in the Default model but are not routable through the unit of work, which is the same fallback ApplicationDbContext applies when filtering configurations into a model (ApplicationDbContext.cs:962-977).
  • Where it's used: registered as the singleton IEntityDataSourceRegistry (DependencyInjection.cs:92) and rebuilt by hand for design-time commands (DesignTimeDbContextHelper.cs:135, DesignTimeDbContextHelper.cs:195); consumed by CrossDataSourceDegradeConvention, DataSourceService, DbContextFactory, both ApplicationDbContext model passes (ApplicationDbContext.cs:386, ApplicationDbContext.cs:963), and the outbox, audit-trail and migrations enumerations. Its behavior is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/EntityDataSourceRegistryTests.cs:15, covering the agreeing and conflicting duplicate cases (EntityDataSourceRegistryTests.cs:94, EntityDataSourceRegistryTests.cs:104), the attribute-less skip (EntityDataSourceRegistryTests.cs:121), the unknown-entity throw and probe (EntityDataSourceRegistryTests.cs:130, EntityDataSourceRegistryTests.cs:140), and the distinct source enumeration (EntityDataSourceRegistryTests.cs:149).

TenantDataSourceTargets

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:43 · Level 14 · class (static)

  • What it is: a static class that expands the physical data sources a background service owns into the TenantDataSourceTarget units it must actually visit once database-per-tenant is configured (Expand, TenantDataSourceTargets.cs:52-83), plus a scope-creation extension (CreateTenantScope, TenantDataSourceTargets.cs:100-119). Deciding which sources to feed it is not its job: that lives on FrameworkTableTargets.
  • Depends on: DataSourceKey, TenantDataSourceTarget, TenancySettings with its nested TenantEntrySettings and TenantDataSourceOverrideSettings, TenancySettingsValidator.ConnectionStringFor for the per-engine connection-string lookup (TenantDataSourceTargets.cs:74), and ITenantContext (group-05-cqrs-pipeline.md#itenantcontext) for CreateTenantScope.
  • Concept introduced, why a per-tenant database is invisible to a shared sweep. [Rubric §8, Data Architecture] and [Rubric §29, Resilience & Business Continuity] both apply, and the class remarks (TenantDataSourceTargets.cs:29-42) carry the reasoning. A shared-schema tenant needs nothing here: its rows live in the shared database that the null-tenant target already drains, and the outbox deliberately has no tenant column precisely so that adopting tenancy never forces a migration on an existing consumer. A tenant with its own database is the opposite case: its outbox rows and its audit-trail rows sit in a database nothing else opens, so without an extra target its events would sit undelivered and its rows unpurged forever. Each such tenant therefore contributes one extra target per source it overrides, and the sweep sets the tenant on its scope before asking for the context, which is what routes that context at the tenant's connection string (ADR-073).
  • Walkthrough
    • Expand(IEnumerable<DataSourceKey> sources, TenancySettings? settings) (TenantDataSourceTargets.cs:52-54) takes a nullable settings argument, because a host that never registered tenancy passes null and must still get a usable list.
    • It materializes the source sequence only when it is not already a collection (sources as IReadOnlyCollection<DataSourceKey> ?? [.. sources], TenantDataSourceTargets.cs:56) and sizes the result list from that count (TenantDataSourceTargets.cs:57), since the common no-tenancy case produces exactly one target per source.
    • Pass one adds the shared target for every source, new TenantDataSourceTarget(source, null) (TenantDataSourceTargets.cs:59-62). This is the entire result when settings are null or declare no tenants (TenantDataSourceTargets.cs:64-67), which is the ordering guarantee the callers rely on: shared targets always come first.
    • Pass two walks the declared tenants and, for each, every source the caller owns (TenantDataSourceTargets.cs:69-79). A pair is added only when the tenant declares an override keyed by that physical source name and that override carries a non-blank connection string for that source's engine (TenantDataSourceTargets.cs:73-74). Both halves of that test matter: an override for a source this host does not own adds nothing, and an override that only declares, say, a SQLite connection adds nothing for a SQL Server source. Both cases have their own test (TenantDataSourceTargetTests.cs:64, TenantDataSourceTargetTests.cs:78).
    • CreateTenantScope(this IServiceScopeFactory, TenantDataSourceTarget) (TenantDataSourceTargets.cs:100-119) null-guards the factory, creates a scope, then withdraws any ambient origin with using (Context.AmbientOrigin.Suppress()) (TenantDataSourceTargets.cs:104-105). Inside that block, when the target's TenantId is set it calls ITenantContext.SetTenant on the scope (TenantDataSourceTargets.cs:107-110); otherwise it resolves ITenantContext once and discards it (TenantDataSourceTargets.cs:111-116), so a shared-target scope is created unresolved and routes to the shared source. The remarks explain why (TenantDataSourceTargets.cs:93-99): the explicit target always wins over an enclosing background hop's origin (AmbientOrigin.Current, which a scope created inside an outbox, broker or internal command delivery otherwise inherits), so a scope for another tenant does not collide with the hop's tenant. The doc comment is explicit about ordering (TenantDataSourceTargets.cs:84-92): call it before asking the scope for a context, because the tenant is what routes the scoped context factory to the tenant's connection string and is also what the query filter reads.
  • Why it's built this way: Expand is a pure function over settings, with no DI and no I/O, so every caller shares one expansion rule and each target list can be unit-tested without a database (TenantDataSourceTargetTests.cs:47). Keeping the tenant loop outside the sweeps also keeps the tenancy feature additive: a host with no Tenancy section gets one shared target per source and nothing else (TenantDataSourceTargetTests.cs:26, TenantDataSourceTargetTests.cs:36). CreateTenantScope centralizes the call-order rule between setting the tenant and asking for the context, which every sweep would otherwise have to get right on its own.
  • Where it's used: Expand is called by FrameworkTableTargets, which supplies the relational source set for the framework-table sweeps (FrameworkTableTargets.cs:44, FrameworkTableTargets.cs:63), and by DatabaseInitializationExtensions, which filters the expansion down to t.TenantId is not null because the shared sources were already initialized in the pass above it (DatabaseInitializationExtensions.cs:209-210). CreateTenantScope is the per-target scope call of the startup initializer (DatabaseInitializationExtensions.cs:212), OutboxProcessor (OutboxProcessor.cs:187, and per row at OutboxProcessor.cs:509), OutboxCleanupService (OutboxCleanupService.cs:98), OutboxAdministration (OutboxAdministration.cs:217), InternalCommandProcessor (InternalCommandProcessor.cs:153), InternalCommandCleanupService (InternalCommandCleanupService.cs:105), InternalCommandAdministration (InternalCommandAdministration.cs:261) and AuditTrailCleanupJob (AuditTrailCleanupJob.cs:97). Two of the sweeps are pinned against the same expansion in tests (TenantDataSourceTargetTests.cs:100, TenantDataSourceTargetTests.cs:116).
  • Caveats / not-in-source: the expansion is driven purely by declared configuration. A tenant whose database exists but is not declared under Tenancy:Tenants produces no target, and nothing in this class detects that; the round-trip check that a source name is a real physical name lives in TenancySettingsValidator, not here.

FrameworkTableTargets

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/FrameworkTableTargets.cs:32 · Level 15 · class (sealed)

  • What it is: a singleton that answers one question for every sweep over the framework's own relational tables (OutboxMessages, InternalCommands, the audit trail): which physical databases, and which per-tenant copies of them, does this host have to visit (FrameworkTableTargets.cs:8-13). Three public members, Relational() (FrameworkTableTargets.cs:43-44), Relational(DataSource, string) (FrameworkTableTargets.cs:54-64) and Named(DataSource, string, string?) (FrameworkTableTargets.cs:75-82), all returning IReadOnlyList<TenantDataSourceTarget>.
  • Depends on: three primary-constructor parameters (FrameworkTableTargets.cs:32-35): IEntityDataSourceRegistry for the physical sources in use, IDataSourceResolver to resolve the caller's configured home source, and an optional IOptions<TenancySettings> (TenancySettings). It delegates the per-tenant expansion to TenantDataSourceTargets.Expand and reads the relational flag from DataSourceEngines.
  • Concept introduced, one answer for "where do the framework's tables live". [Rubric §8, Data Architecture] and [Rubric §7, Microservices Readiness]: with database-per-service (ADR-006) every relational source has its own outbox and internal-command table, and with database-per-tenant (ADR-073) each tenant that keeps its own copy of a source has another. The class remarks (FrameworkTableTargets.cs:14-24) say why this is one class: the outbox processor, the internal-command processor, both cleanup services, both operator surfaces and the audit-trail sweep all ask the same question, so the answer is derived here once rather than from the registry, the resolver and the tenancy settings inside each of them.
  • Walkthrough
    • RelationalSourcesInUse (FrameworkTableTargets.cs:84-86, private) is the base set: every physical source the registry reports in use, filtered to engines whose capabilities declare them relational. Cosmos sources drop out because they host none of these tables (FrameworkTableTargets.cs:21-22).
    • Relational() (FrameworkTableTargets.cs:43-44) is that set, deduplicated, then passed to TenantDataSourceTargets.Expand with the bound tenancy settings, or null when tenancy was never registered. It is what a sweep uses when its table has no configured home of its own.
    • Relational(homeEngine, homeDatabaseName) (FrameworkTableTargets.cs:54-64) adds one more source: the physical source the caller's own settings name as its table's home, resolved through IDataSourceResolver.ResolveLogical (FrameworkTableTargets.cs:60), but only when that engine is relational (FrameworkTableTargets.cs:58). This is what the outbox and internal-command sweeps use: the entity registry alone would miss the publish or scheduling table when it lives in a source no registered entity happens to use.
    • Named(homeEngine, homeDatabaseName, targetName) (FrameworkTableTargets.cs:75-82) is the same list narrowed to the one target an operator named, matched case-insensitively against TenantDataSourceTarget.ToString(), the display form the operator surfaces report (FrameworkTableTargets.cs:79-81). A null name keeps every target; a name matching nothing returns an empty list.
  • Why it's built this way: every caller recomputes the list per call, which the remarks call cheap and tolerant of module assemblies that finish loading after startup (FrameworkTableTargets.cs:21-22), so the class holds no cached state and is safe as a singleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:96). The tenancy option is defaulted to null (FrameworkTableTargets.cs:35), so a host without tenancy visits the shared databases only (FrameworkTableTargets.cs:27-31). Testing the relational flag on the engine descriptor rather than naming Cosmos keeps the rule correct for any engine registered in DataSourceEngines (ADR-130).
  • Where it's used: injected into OutboxProcessor (OutboxProcessor.cs:61, called at OutboxProcessor.cs:148), OutboxCleanupService (OutboxCleanupService.cs:47, called at OutboxCleanupService.cs:193), InternalCommandProcessor (InternalCommandProcessor.cs:48, called at InternalCommandProcessor.cs:115), InternalCommandCleanupService (InternalCommandCleanupService.cs:44, called at InternalCommandCleanupService.cs:161), and AuditTrailCleanupJob (AuditTrailCleanupJob.cs:47, which uses the home-less Relational() at AuditTrailCleanupJob.cs:75). The two operator surfaces use Named: OutboxAdministration (OutboxAdministration.cs:36, called at OutboxAdministration.cs:203) and InternalCommandAdministration (InternalCommandAdministration.cs:38, called at InternalCommandAdministration.cs:247). All paths are under MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/. Tests construct it in TenantDataSourceTargetTests.cs, OutboxProcessorTests.cs, OutboxProcessorExecuteAsyncTests.cs, OutboxCleanupServiceTests.cs and AuditTrailCleanupJobTests.cs.

ExplicitKeyInsertGroup

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/ExplicitKeyInsertGroup.cs:12 · Level 0 · record (internal sealed)

  • What it is: a three-member positional record, (string Schema, string Table, IReadOnlyList<EntityEntry> Entries) (ExplicitKeyInsertGroup.cs:12). It names one batch of pending inserts that all target the same table and all carry an explicit value for a store-generated key, which is exactly the unit an engine's explicit-key toggle operates on (ExplicitKeyInsertGroup.cs:5-8).
  • Depends on: EF Core's EntityEntry (ExplicitKeyInsertGroup.cs:1), and nothing first-party.
  • Concept introduced, importing rows that already have ids. [Rubric §8, Data Architecture] (assesses whether persistence mechanics are deliberate): a store-generated key normally means the application never supplies the id, but an import from an external system must preserve the source's ids. SQL Server only accepts that with SET IDENTITY_INSERT <table> ON, one table at a time per session (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:324-329). Grouping the affected entries by table is what turns that constraint into a loop.
  • Walkthrough: no members beyond the positional ones. Schema and Table come from EF model metadata, never user input, which is what makes the raw toggle statement safe to build by concatenation (see IExplicitKeyInsertDialect).
  • Why it's built this way: the record is engine-neutral and lives beside the engine strategies rather than inside DbContextFactory, because the engine produces the groups (SQLServerDataSourceEngine.FindGroups) and the factory consumes them (ADR-130).
  • Where it's used: returned by IExplicitKeyInsertDialect.FindGroups (IExplicitKeyInsertDialect.cs:21), built by the SQL Server engine (SQLServerDataSourceEngine.cs:158), and consumed by DbContextFactory.SaveExplicitKeyGroupsAsync (DbContextFactory.cs:384-434), which saves each group in its own round with the toggle on, hiding the other groups' Added entries as Unchanged (DbContextFactory.cs:397-403) and excluding their domain events from capture through DomainEventSaveChangesInterceptor.BeginCaptureExclusion (DbContextFactory.cs:405-413).

MigrationPolicy

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/MigrationPolicy.cs:8 · Level 0 · enum (internal)

  • What it is: how an engine decides whether one physical source is migrated (Migrate) or created outright (EnsureCreated) at startup (MigrationPolicy.cs:3-7).
  • Depends on: nothing first-party.
  • Concept: one of the plain-value capabilities that replaced engine branching; the idea is taught on DataSourceEngineCapabilities. [Rubric §8, Data Architecture] applies: schema evolution policy is a per-engine fact, not a per-call-site decision.
  • Walkthrough: three members. Never (MigrationPolicy.cs:11): no migrations pipeline (Cosmos). WhenAssemblyConfigured (:14): migrated only once a migrations assembly is configured for the source (SQLite, PostgreSQL). Always (:20): always migrated, even with no migrations assembly, and a named source with none falls back to the Default source's assembly with a warning (SQL Server, MigrationPolicy.cs:16-19).
  • Why it's built this way: three values, not a boolean, because SQL Server's fallback behavior is distinct from "migrate when configured".
  • Where it's used: PhysicalDataSource.UsesMigrations switches on it (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:42-46); DataSourceResolver reads Always for the Default-assembly fallback and Never to drop migrations settings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceResolver.cs:394,451,471). Each engine declares its value in its Capabilities (for example CosmosDataSourceEngine.cs:42).

RowVersionStrategy

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/RowVersionStrategy.cs:8 · Level 0 · enum (internal)

  • What it is: how an engine maps and maintains the RowVersion optimistic-concurrency token (RowVersionStrategy.cs:3-7).
  • Depends on: nothing first-party.
  • Concept: a capability value (see DataSourceEngineCapabilities). [Rubric §8, Data Architecture] applies: optimistic concurrency is uniform for callers while the token's mechanics differ per engine.
  • Walkthrough: three members. None (RowVersionStrategy.cs:11): no token configured (Cosmos). StoreGenerated (:14): a server-generated rowversion mapped with IsRowVersion() and never stamped (SQL Server). ClientStamped (:17): a plain concurrency token the audit interceptor stamps on every insert and update (PostgreSQL, SQLite).
  • Why it's built this way: only SQL Server has a native row-version column type, so the other relational engines need the application to write the token; naming the strategy keeps that split in one place instead of an engine comparison in two files.
  • Where it's used: mapping in ApplicationDbContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:592) and stamping in AuditSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:67).

DataSourceEngineCapabilities

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/DataSourceEngineCapabilities.cs:23 · Level 1 · record (internal sealed)

  • What it is: what one engine can and cannot do, as plain values a call site reads instead of naming an engine (DataSourceEngineCapabilities.cs:3-8). Five positional members (DataSourceEngineCapabilities.cs:23-28).
  • Depends on: MigrationPolicy, RowVersionStrategy.
  • Concept introduced, capabilities instead of engine checks. [Rubric §1, SOLID] (assesses whether code is open for extension without edits scattered across consumers): a call site that asks "is this engine relational?" keeps working when a fifth engine is added, while one that asks "is this SQL Server?" must be found and edited. [Rubric §8, Data Architecture] applies too: engine differences are recorded as data. The doc comment states the admission rule: only facts whose values genuinely differ between engines get their own member, and everything that splits exactly along the relational / non-relational line collapses into IsRelational (DataSourceEngineCapabilities.cs:5-7).
  • Walkthrough:
    • Migrations (:24): a MigrationPolicy.
    • ConnectionStringRequired (:25): a source with no connection string is a startup misconfiguration rather than an optional source to skip (SQL Server only, :10-13).
    • IsRelational (:26): the bundle of relational-only features: .Include() within one physical source, transactions, raw parameterized SQL, indexes, foreign keys with meaningful delete behavior, Any(predicate) translation, and the framework's own relational tables (outbox, inbox, internal commands, scheduled jobs) (:14-19).
    • NullsSortFirstAscending (:27): keyset pagination depends on it (:20).
    • RowVersion (:28): a RowVersionStrategy.
  • Why it's built this way: a fitness test forbids concrete engine branching outside the engine strategies (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/DataSourceBranchingFitnessTests.cs:19,71), so a capability value is the sanctioned way to differ (ADR-130).
  • Where it's used: exposed as IDataSourceEngine.Capabilities (IDataSourceEngine.cs:74). IsRelational is read by the conventions (SoftDeleteUniqueIndexConvention at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:43, RestrictDeleteByDefaultConvention at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/RestrictDeleteByDefaultConvention.cs:66, CrossDataSourceDegradeConvention at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/CrossDataSourceDegradeConvention.cs:66), by DataSourceService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceService.cs:32) and by DomainEventSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:141,257). ConnectionStringRequired is read by PhysicalDataSource (PhysicalDataSource.cs:59), and NullsSortFirstAscending by EFReadRepository<TEntity, TIdentifierType>, which defaults to true when no engine is known (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:523,721).

IExplicitKeyInsertDialect

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/IExplicitKeyInsertDialect.cs:13 · Level 1 · interface (internal)

  • What it is: the engine-specific half of an explicit-key insert. Only an engine that refuses an explicit value for a store-generated key without a session toggle has one (SQL Server, through SET IDENTITY_INSERT); every other engine returns null from IDataSourceEngine.ExplicitKeyInsert and the save runs unchanged (IExplicitKeyInsertDialect.cs:5-12).
  • Depends on: ExplicitKeyInsertGroup; EF Core's DbContext.
  • Concept: the strategy split taught on IDataSourceEngine applied to one behavior. The doc comment draws the line explicitly: the engine-neutral half (grouping rounds, hiding other tables' entries, excluding their domain events, pinning the connection) stays in DbContextFactory (IExplicitKeyInsertDialect.cs:10-11).
  • Walkthrough:
    • FindGroups(DbContext) (IExplicitKeyInsertDialect.cs:21): returns the added entries whose single-column key is store-generated on this engine and carries an explicit (non-temporary) value, one ExplicitKeyInsertGroup per table; empty when nothing needs the toggle.
    • BuildToggleSql(schema, table, enable) (:28): the raw statement that switches explicit-key insert on or off for one table. The parameters are documented as coming from EF model metadata, never user input (:24-25).
  • Why it's built this way: a nullable dialect property lets the factory express "this engine needs no toggle" without an engine check (ADR-130). The toggle is session state, so the factory pins one connection for the whole save (DbContextFactory.cs:349-359).
  • Where it's used: implemented by SQLServerDataSourceEngine, which returns itself (SQLServerDataSourceEngine.cs:19,49). DbContextFactory takes the explicit-key path only when a caller requested it and the context's engine has a dialect (DbContextFactory.cs:293-295), then drives FindGroups and BuildToggleSql (DbContextFactory.cs:335,421-434). The request flag is set through IUnitOfWork.RequestExplicitKeyInsert (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:88); the one first-party caller is ADC's Sessionize refresh (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:192).

CosmosDataSourceEngine

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/CosmosDataSourceEngine.cs:18 · Level 11 · class (internal sealed)

  • What it is: the IDataSourceEngine strategy for Azure Cosmos DB, the one non-relational engine.
  • Depends on: CosmosDbContext, IEntityTypeConfigurationCosmos<TEntity, TIdentifierType>, CosmosIntIdValueGenerator, NamespaceConventions, ConnectionStringSettings, DataSourceEntrySettings, TenantDataSourceOverrideSettings; EF Core Cosmos provider.
  • Concept: see IDataSourceEngine.
  • Walkthrough:
    • Descriptive facts: Engine is DataSource.CosmosDB (CosmosDataSourceEngine.cs:25), SubstitutionPriority 3, the last resort (:28), and MigrationsAssemblySettingName is null because Cosmos migrates nothing (:33-35). ConnectionIdentityIncludesDatabaseName is true (:38): one Cosmos account hosts many databases, so the connection string alone does not identify the physical source.
    • Capabilities (:41-46): MigrationPolicy.Never, connection string not required, not relational, nulls sort first ascending, RowVersionStrategy.None. ExplicitKeyInsert is null (:49).
    • Migrations-assembly readers return string.Empty (:73,76); CreateDbContext builds a CosmosDbContext over static empty options, since all configuration happens in OnConfiguring (:20-22,79-83).
    • ApplyKeyAndTableMapping (:86-102): all of a module's entities share one container named after the module (falling back to the entity name) with the entity Id as partition key (:92-96); a generated int id uses CosmosIntIdValueGenerator, otherwise ValueGeneratedNever (:98-101).
    • IncludeColumns keeps the SQL Server annotation and is unreachable in practice, since Cosmos never maps the outbox or internal-command tables that call it (:105-110); QuoteColumn uses brackets (:114) only for a filter that BuildSoftDeleteFilter then discards by returning null (:117).
  • Why it's built this way: the Cosmos-specific mapping (container, partition key, client-side id generation) that used to be engine branches now sits in one class (ADR-130).
  • Where it's used: registered in DataSourceEngines (DataSourceEngines.cs:24), and allow-listed as an engine strategy by DataSourceBranchingFitnessTests.cs:27.

DataSourceEngines

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/DataSourceEngines.cs:19 · Level 11 · class (internal static)

  • What it is: the one registry of database engines, keyed by the shipped DataSource value (DataSourceEngines.cs:6).
  • Depends on: IDataSourceEngine and its four implementations; BCL FrozenDictionary.
  • Concept: the lookup half of the strategy pattern taught on IDataSourceEngine.
  • Walkthrough: Registered (DataSourceEngines.cs:22-28) is the array of the four engines in registration order (Cosmos, PostgreSQL, SQLite, SQL Server). ByEngine (:30-31) freezes it into a dictionary keyed by each engine's Engine. All (:34) exposes the array; For(DataSource) (:40-43) returns the registered engine or throws InvalidOperationException naming the unimplemented value.
  • Why it's built this way: static rather than a DI service because several consumers run where no container is reachable (model-building conventions, EntityTypeConfiguration.ApplyEngineConventions, SoftDeleteFilterSql, IndexBuilderExtensions), and because engines are stateless facts about a closed enum that only Common can extend (DataSourceEngines.cs:8-12). Adding a fifth engine is one new class plus one line in Registered (:14-16).
  • Where it's used: substitution order is All sorted by SubstitutionPriority in DataSourceResolver (DataSourceResolver.cs:36) and TenancySettingsValidator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:29); For backs ApplicationDbContext.Engine (ApplicationDbContext.cs:61), PhysicalDbContextFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:32), EntityTypeConfiguration<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:77), SoftDeleteFilterSql (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:36,71), FrameworkTableTargets (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/FrameworkTableTargets.cs:58,86), and infrastructure registration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:411).

IDataSourceEngine

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/IDataSourceEngine.cs:18 · Level 11 · interface (internal)

  • What it is: everything the framework needs to know about one database engine, so that no call site branches on DataSource. Implementations are stateless singletons looked up through DataSourceEngines.For (IDataSourceEngine.cs:9-12).
  • Depends on: DataSource, DataSourceEngineCapabilities, IExplicitKeyInsertDialect, ApplicationDbContext, PhysicalDataSource, IEntityConfigurationAssemblyProvider, the three settings types (ConnectionStringSettings, DataSourceEntrySettings, TenantDataSourceOverrideSettings); EF Core EntityTypeBuilder and IndexBuilder.
  • Concept introduced, one strategy per engine. [Rubric §2, Design Patterns] (assesses whether patterns solve a real problem idiomatically): a Strategy keyed by an enum replaces switch and if statements on the engine that were spread across resolver, conventions, configuration and factory code. [Rubric §1, SOLID] (open/closed): a fifth engine is one class, not edits at every branch. [Rubric §34, Architecture Governance & Documentation] (assesses whether architectural rules are enforced, not just written down): DataSourceBranchingFitnessTests fails the build on concrete engine branching outside the engine strategies, with the remedy "route engine-specific behavior through IDataSourceEngine (ADR-130)" (DataSourceBranchingFitnessTests.cs:19,71). The interface groups its members in three bands: DESCRIPTIVE facts, Capabilities (plain values) and BEHAVIOUR hooks, the few places where the engine has to act rather than answer (IDataSourceEngine.cs:13-15).
  • Walkthrough:
    • Identity: Engine (IDataSourceEngine.cs:21), the enum value the engine answers for.
    • Descriptive (:23-69): SubstitutionPriority (:29, lower wins when the resolver substitutes a configured engine for an unconfigured one; it replaces the hand-ordered EnginePreference array, :25-28), EntityConfigurationInterface (:32, the open generic configuration interface whose implementations belong to this engine's model), MigrationsAssemblySettingName (:38, for error messages, null when the engine migrates nothing), ConnectionIdentityIncludesDatabaseName (:44), three GetConnectionString overloads for the top-level section, a named DataSources entry and a tenant override (:49,54,59), and the two migrations-assembly readers (:64,69).
    • Capabilities (:74): a DataSourceEngineCapabilities.
    • Behaviour (:76-119): CreateDbContext builds the engine's sealed context over one physical source, never pooled (:83-86); ApplyKeyAndTableMapping<TEntity, TIdentifierType> is the body of EntityTypeConfiguration.ApplyEngineConventions (:95-97); IncludeColumns adds covering columns with the engine's provider annotation (:103); QuoteColumn and BuildSoftDeleteFilter produce the filtered-index predicate, the latter null where filtered indexes are unsupported (:108,113); ExplicitKeyInsert is the optional IExplicitKeyInsertDialect (:119).
  • Why it's built this way: recorded in ADR-130. It does not replace the one-context-class-per-engine rule (ADR-006): each strategy creates its own sealed context.
  • Where it's used: four implementations (CosmosDataSourceEngine, PostgreSQLDataSourceEngine, SqliteDataSourceEngine, SQLServerDataSourceEngine), all held by DataSourceEngines; see that section for the consumers. ApplicationDbContext exposes the current context's engine as an internal Engine property (ApplicationDbContext.cs:61), which interceptors and the factory read.

PostgreSQLDataSourceEngine

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/PostgreSQLDataSourceEngine.cs:17 · Level 11 · class (internal sealed)

  • What it is: the IDataSourceEngine strategy for PostgreSQL (Npgsql).
  • Depends on: PostgreSQLDbContext, IEntityTypeConfigurationPostgreSQL<TEntity, TIdentifierType>, NamespaceConventions, the settings types; Npgsql EF provider.
  • Concept: see IDataSourceEngine.
  • Walkthrough:
    • Descriptive facts: Engine is DataSource.PostgreSQL (PostgreSQLDataSourceEngine.cs:24), SubstitutionPriority 1 (:27), settings key PostgreSQLMigrationsAssembly (:33), connection identity by connection string alone (:36).
    • Capabilities (:39-44): MigrationPolicy.WhenAssemblyConfigured, connection string not required, relational, nulls do not sort first ascending, RowVersionStrategy.ClientStamped. ExplicitKeyInsert is null (:47): PostgreSQL accepts an explicit key without a toggle.
    • It reads its own top-level migrations assembly (:71-75) and creates a PostgreSQLDbContext (:85-89).
    • ApplyKeyAndTableMapping (:92-106) is deliberately identical to SQL Server, table named after the entity in the module schema (falling back to dbo), so an entity moves between the two engines by changing its configuration base class alone (:98-100).
    • The predicate differences: Npgsql's IncludeProperties annotation (:109-110), double-quoted identifiers (:113) and a "IsDeleted" = false soft-delete filter (:116).
  • Why it's built this way: ADR-113 keeps the mapping shared with SQL Server; ADR-130 puts what differs in this one class.
  • Where it's used: registered in DataSourceEngines (DataSourceEngines.cs:25); allow-listed by DataSourceBranchingFitnessTests.cs:28.

SqliteDataSourceEngine

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SqliteDataSourceEngine.cs:16 · Level 11 · class (internal sealed)

  • What it is: the IDataSourceEngine strategy for SQLite.
  • Depends on: SqliteDbContext, IEntityTypeConfigurationSqlite<TEntity, TIdentifierType>, the settings types; EF Core SQLite provider and the SQL Server index-builder extension.
  • Concept: see IDataSourceEngine.
  • Walkthrough:
    • Descriptive facts: Engine is DataSource.Sqlite (SqliteDataSourceEngine.cs:23), SubstitutionPriority 2 (:26), settings key SqliteMigrationsAssembly (:32), connection identity by connection string alone (:35).
    • Capabilities (:38-43): MigrationPolicy.WhenAssemblyConfigured, connection string not required, relational, nulls sort first ascending, RowVersionStrategy.ClientStamped. ExplicitKeyInsert is null (:46).
    • GetTopLevelMigrationsAssembly always returns empty: ConnectionStrings carries no SQLite migrations assembly, so a mixed-engine host can never hand SQLite the SQL Server snapshot (:70-74). Only a named DataSources entry can supply one.
    • ApplyKeyAndTableMapping (:91-103) maps to a table named after the entity with no schema (:97) and a generated key as ValueGeneratedOnAdd().UseIdentityColumn(1, 1) (:100).
    • IncludeColumns keeps the SQL Server annotation, which the SQLite provider ignores (:106-108); double-quoted identifiers (:112) and a "IsDeleted" = 0 filter (:115).
  • Why it's built this way: see ADR-130.
  • Where it's used: registered in DataSourceEngines (DataSourceEngines.cs:26); allow-listed by DataSourceBranchingFitnessTests.cs:30.

SQLServerDataSourceEngine

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DataSources.Engines · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:19 · Level 11 · class (internal sealed)

  • What it is: the IDataSourceEngine strategy for SQL Server, and the only engine that also implements IExplicitKeyInsertDialect (SQLServerDataSourceEngine.cs:19).
  • Depends on: SQLServerDbContext, IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, ExplicitKeyInsertGroup, NamespaceConventions, the settings types; EF Core SQL Server provider.
  • Concept: see IDataSourceEngine and, for the explicit-key toggle, ExplicitKeyInsertGroup.
  • Walkthrough:
    • Descriptive facts: Engine is DataSource.SQLServer (SQLServerDataSourceEngine.cs:26), SubstitutionPriority 0, the preferred substitute (:29), settings key SQLServerMigrationsAssembly (:35), connection identity by connection string alone (:38).
    • Capabilities (:41-46): MigrationPolicy.Always, connection string required, relational, nulls sort first ascending, RowVersionStrategy.StoreGenerated. ExplicitKeyInsert returns this (:49).
    • Reads its top-level migrations assembly (:73-77) and creates a SQLServerDbContext (:87-91).
    • ApplyKeyAndTableMapping (:94-107): table named after the entity in the module schema (falling back to dbo), shared verbatim with PostgreSQL (:100-101).
    • Bracket-quoted identifiers (:114) and a [IsDeleted] = 0 filter (:117).
    • FindGroups (:120-159) walks the change tracker for Added entries (:128), skips anything without a single-property primary key (:132-134), keeps only keys whose SQL Server value-generation strategy is IdentityColumn (:137-141), skips entries whose key is still an EF temporary value, since only an explicitly set (imported) value needs the toggle (:143-146), and buckets survivors by (schema, table) with dbo as the schema fallback (:148-155).
    • BuildToggleSql (:162-163) concatenates SET IDENTITY_INSERT [schema].[table] ON|OFF; the identifier cannot be parameterized, and the caller suppresses S2077 with that justification (DbContextFactory.cs:420).
  • Why it's built this way: SQL Server is the default engine, so it alone requires a connection string and migrates even with no configured assembly (falling back to the Default source's assembly, MigrationPolicy.cs:16-19). Implementing the dialect on the engine itself keeps the identity-insert rules (identity strategy, temporary values) next to the other SQL Server facts (ADR-130).
  • Where it's used: registered in DataSourceEngines (DataSourceEngines.cs:27); allow-listed by DataSourceBranchingFitnessTests.cs:29; its dialect is driven by DbContextFactory (DbContextFactory.cs:293-295,335).

DetectChangesScope

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:284 · Level 0 · struct (private readonly, nested)

  • What it is: a two-field disposable struct nested inside ApplicationDbContext whose only job is to put EF's ChangeTracker.AutoDetectChangesEnabled flag back the way it found it when a save finishes.
  • Depends on: Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker and System.IDisposable (both external/BCL); nothing first-party beyond its enclosing context class.
  • Concept introduced, the scoped-setting guard (restore-on-dispose). [Rubric §12, Performance & Scalability] (assesses whether hot paths avoid repeated O(n) work) and [Rubric §15, Best Practices & Code Quality] (assesses whether temporary global-state mutation is bounded rather than leaked). EF's ChangeTracker.Entries<T>() runs a full DetectChanges pass on every call and memoizes nothing. Interceptors scan the tracker during SavingChanges and EF then detects once more on its own before building the save, so a single save paid three O(tracked entities x properties) snapshot comparisons where one suffices (ApplicationDbContext.cs:257-264). Turning detection off for the duration of the save is the optimization; a using-scoped struct is what makes turning it back on unforgettable, including on the exception path.
  • Walkthrough: the primary constructor DetectChangesScope(ChangeTracker changeTracker, bool previousSetting) (ApplicationDbContext.cs:284) captures the tracker and the flag value that was in force before suppression. Dispose() (ApplicationDbContext.cs:286) is a single expression-bodied assignment that writes previousSetting back onto changeTracker.AutoDetectChangesEnabled. It is created only by DetectChangesOnce() (ApplicationDbContext.cs:274-282), which reads the current setting (:235), calls ChangeTracker.DetectChanges() once when detection was on (:236-237), sets the flag to false (:239), and hands back the scope (:240).
  • Why it's built this way: readonly struct means no heap allocation on a path that runs on every save, and private keeps the mechanism invisible to callers. Restoring the previous value rather than hardcoding true is the load-bearing detail: a caller that had deliberately disabled auto-detect keeps its choice and never gets an unexpected detection pass on the way out (ApplicationDbContext.cs:269-271). Suppressing the remaining passes is safe because everything the interceptors do afterwards bypasses detection anyway: the audit interceptor writes through entry.Property(...).CurrentValue (AuditSaveChangesInterceptor.cs:80-83) and the domain-event interceptor adds outbox rows through Add, both of which take effect on the entry immediately.
  • Where it's used: both save overrides that EF funnels through, SaveChangesAsync(bool, CancellationToken) (ApplicationDbContext.cs:214-220) and SaveChanges(bool) (ApplicationDbContext.cs:247-251), open one with using var detection = DetectChangesOnce(). The behavior is pinned by SaveChangeDetectionTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Interceptors/SaveChangeDetectionTests.cs:31, :47, :64, :78, :90), which asserts detection runs exactly once, that an untracked property edit still persists, that audit stamps still land, that a caller's explicit false survives the save, and that a default context is left with detection enabled for the next caller.
  • Caveats / not-in-source: the remarks name two tracker-scanning interceptors (AuditSaveChangesInterceptor at AuditSaveChangesInterceptor.cs:69 and DomainEventSaveChangesInterceptor at DomainEventSaveChangesInterceptor.cs:242), which are the two that are always registered. Two optional interceptors enumerate the tracker as well when a host opts into them (TenantSaveChangesInterceptor at TenantSaveChangesInterceptor.cs:74 and AuditTrailSaveChangesInterceptor at AuditTrailSaveChangesInterceptor.cs:199), so the suppression saves strictly more than the comment claims; the comment is narrower than the code, not wrong about the mechanism.

ModelBuilderExtensions

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ModelBuilderExtensions.cs:10 · Level 0 · class (internal static)

  • What it is: an internal static class holding a single extension member, ApplyAllConfigurations, that scans an assembly for concrete classes implementing a provider-specific configuration interface, instantiates each through DI, and applies it to the EF model, with an optional per-entity filter.
  • Depends on: Microsoft.EntityFrameworkCore.ModelBuilder, System.Reflection, and Microsoft.Extensions.DependencyInjection.ActivatorUtilities (all BCL/EF/NuGet). It is called by ApplicationDbContext; it does not itself reference NamespaceConventions or EntityConfigurationOptions.
  • Concept introduced, reflection-driven configuration application with a DI-aware activator. [Rubric §8, Data Architecture] (assesses deliberate, discoverable model configuration) and [Rubric §2, Design Patterns] (a generic apply-all built over EF's ApplyConfiguration<TEntity>): because the entity CLR type is only known at runtime, the method resolves EF's open generic ModelBuilder.ApplyConfiguration<TEntity>(IEntityTypeConfiguration<TEntity>) once, then closes it per entity via MakeGenericMethod. Configurations are created with ActivatorUtilities.CreateInstance (ModelBuilderExtensions.cs:62), so a configuration class may constructor-inject services rather than needing a parameterless ctor. The member is declared inside a C# extension(ModelBuilder modelBuilder) block (ModelBuilderExtensions.cs:12), the preview extension-member syntax this workspace uses instead of this-parameter extension methods.
  • Walkthrough:
    • Guards all four required arguments with ArgumentNullException.ThrowIfNull (ModelBuilderExtensions.cs:31-34).
    • Resolves the single-parameter ApplyConfiguration overload by reflection (ModelBuilderExtensions.cs:38-40).
    • Selects concrete, non-generic-definition types in the assembly whose interface set contains a closed form of interfaceType (the open generic like IEntityTypeConfigurationSQLServer<,>) (ModelBuilderExtensions.cs:42-51).
    • For each, takes the first generic argument as the entity type, skips it when entityFilter returns false (ModelBuilderExtensions.cs:56-60), then instantiates via ActivatorUtilities and invokes the closed ApplyConfiguration (ModelBuilderExtensions.cs:62-64).
  • Why it's built this way: internal keeps this a framework detail; modules never call it. The entityFilter parameter is the boundary that keeps each physical database's model to only its own entities (see ApplicationDbContext), and DI-based activation lets configurations depend on services without a parameterless-ctor constraint.
  • Where it's used: called from ApplicationDbContext.ApplyConfigurationsForEntitiesInContext (ApplicationDbContext.cs:975-981), which passes the engine's configuration interface (for example IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>) and a filter that matches each entity's registry-resolved DataSourceKey.

TransactionCommitAmbiguousException

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Factory · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/TransactionCommitAmbiguousException.cs:22 · Level 0 · class (sealed)

  • What it is: the exception DbContextFactory.ExecuteInTransactionAsync throws when the commit phase fails, meaning the transaction may or may not have become durable because a commit can fail after the database applied it but before the acknowledgement reached the client (TransactionCommitAmbiguousException.cs:3-7).
  • Depends on: the BCL Exception only.
  • Concept introduced, an unknown outcome is a distinct failure mode. [Rubric §29, Resilience, Reliability & Business Continuity Concerns] (resilience policies applied through a shared mechanism rather than per call) and [Rubric §29, Resilience & Business Continuity] (assesses whether failure modes are identified and handled deliberately): a generic failure would be wrong twice over. First, mechanically: SQL Server's EnableRetryOnFailure strategy (configured at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:63-66 with five retries and a 10 second ceiling) classifies most commit-phase errors (timeouts, dropped connections) as transient, and EF decides retriability by walking an exception's whole inner chain, so any wrapper carrying the transient error would still be retried, re-running every write of an operation whose commit may already be durable, including its outbox rows (TransactionCommitAmbiguousException.cs:8-14, DbContextFactory.cs:588-591). That is why the commit failure is returned rather than thrown from RunTransactionalAttemptAsync and TryCommit (DbContextFactory.cs:601-605, :572-574, :617-620) and only converted into this exception past the strategy (DbContextFactory.cs:592-593). Second, semantically: "it failed" and "nobody can say whether it failed" call for different recovery, so the type itself is the signal.
  • Concept introduced, naming the partial outcome. [Rubric §13, Observability & Operability] (assesses whether an operator can tell what actually happened from what the system reports): commits across several physical sources are sequential and independent, so a failure part-way through leaves earlier sources durable. Rather than leave that inferable only from timing, the exception carries three lists that partition every source the failed commit touched.
  • Walkthrough:
    • DefaultMessage (TransactionCommitAmbiguousException.cs:24-26): states both halves, the unknown durability and the deliberate non-retry.
    • Five constructors (:29-69): the parameterless, (string) and (string, Exception) standard set; (Exception innerException) (:45-46), which pairs the provider's failure with the default message; and the four-argument diagnostic one (:57-69), which additionally takes the committed, ambiguous and rolled-back sources and composes them into the message. That last one is the one DbContextFactory actually constructs (DbContextFactory.cs:744); it null-coalesces both lists to empty (:63, :66-68) so a caller passing null gets an empty group rather than a second exception.
    • CommittedSources (:76), AmbiguousSource (:85), RolledBackSources (:93): the three outcome groups, each with doc comments stating what a reader may conclude. Committed sources are durable, so the ambiguity is a partial commit and reconciliation means replaying only what the remaining sources owed (:71-75). The ambiguous source alone is unknowable, and with a single transactional source (the case every host runs today) it is the whole outcome while the other two groups are empty (:78-84). Rolled-back sources wrote nothing that survives, with "best-effort" spelled out literally: a rollback that itself throws is swallowed and the transaction abandoned to the server (:87-92).
    • ComposeMessage (:99-118): builds at most three clauses, skipping any empty group, and appends " Per-source outcome: ..." to the default text; with nothing to report it returns the default message unchanged (:115-117), so a single-source failure reads as one clause rather than three.
  • Why it's built this way, and what a caller does about it: the exception's own doc comment assigns recovery to the caller (TransactionCommitAmbiguousException.cs:15-20). An API request marked [Idempotent] replays safely; whatever the transaction wrote to the outbox is delivered by the OutboxProcessor if the commit did land (ADR-003); and the deferred in-process dispatch is dropped, so no handler acts on state that may not exist (DbContextFactory.cs:760-781). The source comment records that a witness row (a marker written inside each source's transaction that a replay could read) would close the multi-source gap entirely, and that it is deliberately not built, because the single transactional source every host runs today needs none (DbContextFactory.cs:539-549).
  • Where it's used: thrown at DbContextFactory.cs:593, constructed at :646; pinned by DbContextFactoryCommitAmbiguityTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/DbContextFactoryCommitAmbiguityTests.cs:73, :105, :129, :149, :168), which drives a context whose execution strategy retries on any exception and asserts the operation runs exactly once, that nothing is dispatched in-process, that the transaction is abandoned, that an ordinary success path still commits and flushes, that a failure inside the operation is still retried, and that a second-source commit failure reports each source's outcome.
  • Caveats / not-in-source: nothing in the framework catches this type. Whether a host's exception middleware maps it to a specific HTTP status is Not determinable from source: no first-party handler references it outside the throw site and its tests.

ExplicitKeyInsertRoundOrder

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Factory · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/ExplicitKeyInsertRoundOrder.cs:17 · Level 0 · class (internal static)

  • What it is: a pure ordering helper for the explicit-key insert path of DbContextFactory. Given the entity types each insert round saves, it returns every round index exactly once, principals before dependents, so a round that holds a foreign key never saves before the round holding the table that key points at (ExplicitKeyInsertRoundOrder.cs:17).
  • Depends on: Microsoft.EntityFrameworkCore.Metadata.IReadOnlyEntityType and BCL collections only; nothing first-party.
  • Concept introduced, ordering by the table dependency graph (Kahn's algorithm). [Rubric §8, Data Architecture] (transaction and write-ordering correctness): the change tracker yields entries in the order they were added, which says nothing about foreign keys, so saving one table per round in that order can violate a constraint. Order (:37) builds a graph whose nodes are rounds and whose edges are "this round has a foreign key into a table held by that round" (PrincipalRounds, :45; the table is the key, TableOf, :109, because several entity types can share one table), then runs a topological sort (TopologicalOrder, :79).
  • Walkthrough:
    • PrincipalRounds (:45-76): maps each (schema, table) to its round, then for each round collects the other rounds its foreign keys point at. A self-reference is no edge (principalRound != round, :67-68), since EF orders rows within one round itself.
    • TopologicalOrder (:79-107): repeatedly takes the lowest-index round whose principals are all already placed (:87-94), so independent rounds keep their original order. When none qualifies the graph has a cycle, which has no valid order; the lowest-index unplaced round is taken to break it (:96-100), which keeps the result deterministic.
  • Why it's built this way: determinism is the point. A stable tie-break (lowest index) means the same input always saves in the same order, and a cycle degrades to a defined order instead of an exception.
  • Where it's used: DbContextFactory.SaveWithExplicitKeyInsertAsync calls it once per explicit-key save and reorders its groups by the result (DbContextFactory.cs:342). The ordering is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/Factory/ExplicitKeyInsertRoundOrderTests.cs.

ApplicationDbContext

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:46 · Level 11 · class (abstract)

  • What it is: the single abstract DbContext base that every engine-specific context (SQLServerDbContext, PostgreSQLDbContext, CosmosDbContext, SqliteDbContext) inherits. One instance exists per physical database: the same class is instantiated multiple times, each carrying a different PhysicalDataSource and building a model that contains only that database's entities (ApplicationDbContext.cs:35-40).
  • Depends on: AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, TenantSaveChangesInterceptor, AuditTrailSaveChangesInterceptor, DataSourceModelCacheKeyFactory, CrossDataSourceDegradeConvention, SoftDeleteUniqueIndexConvention, IEntityDataSourceRegistry, IEntityConfigurationAssemblyProvider, PhysicalDataSource, DataSourceKey, IDataSourceEngine through DataSourceEngines, SoftDeleteFilterSql, IAuditableEntity, ITenantEntity, AuditableBaseEntity<TIdentifierType>, OutboxMessage, InboxMessage, ScheduledJobEntry, AuditTrailEntry, RefreshSessionModelBuilderExtensions, and SchedulerSettings, AuditTrailSettings and RefreshSessionSettings via IOptions<>, plus MiniProfiler and EF Core.
  • Concept introduced, DbContext as Unit of Work + Change Tracker. [Rubric §8, Data Architecture] (assesses transactions, migrations, soft-delete, audit, concurrency): EF's DbContext is the unit of work, tracking every Added/Modified/Deleted entity since the last save and writing them in a single transaction. This is also [Rubric §3, Clean Architecture] (the EF detail stays in Infrastructure; domain entities carry no EF attributes) and [Rubric §6, CQRS & Event-Driven] (domain events are captured transactionally with the aggregate write). The interceptors registered in OnConfiguring run around base.SaveChangesAsync to stamp audit fields, stamp and guard the tenant, serialize domain events into the outbox, and diff the change trail, so those cross-cutting concerns live in the interceptor pipeline rather than inline in every handler.
  • Concept introduced, named global query filters. [Rubric §11, Security] and [Rubric §30, Compliance, Privacy & Data Governance] (both assess whether isolation of other parties' data is structural rather than per-query discipline): this class declares two filters by name, SoftDelete (ApplicationDbContext.cs:475) and Tenant (:477). EF composes named filters with AND, so a tenant-owned soft-deletable entity is filtered on both without either filter knowing about the other, and a caller asking to see deleted rows drops exactly SoftDelete and leaves Tenant in force (:469-474, :490-499). Isolation therefore cannot be forgotten at a call site.
  • Walkthrough:
    • Primary constructor (ApplicationDbContext.cs:46-51): takes DbContextOptions, an IServiceProvider, an IEntityConfigurationAssemblyProvider, and the PhysicalDataSource this instance targets, delegating to DbContext(options).
    • DataSourceKey (:54): exposes the (engine, database name) pair this context serves; PhysicalSource exposes the resolved connection info to subclasses as internal.
    • Engine (:61): internal, DataSourceEngines.For(physicalDataSource.Key.Engine), the IDataSourceEngine this context targets. Its doc comment (:56-60) names both halves of what it answers: what the engine can do (whether it hosts the framework's relational tables, so the transactional outbox, which Cosmos DB does not) and how its SQL and keys are shaped. Every engine branch in this class now reads Engine.Capabilities rather than comparing a DataSource value, and the same property is what DomainEventSaveChangesInterceptor tests (Engine.Capabilities.IsRelational, DomainEventSaveChangesInterceptor.cs:141, :236) and what DbContextFactory reads for transactions and explicit-key inserts.
    • EngineAnnotation (:67): the internal const model annotation name "MMCA:Engine", which carries the engine of the model being built while entity configurations are applied (see ApplyConfigurationsForEntitiesInContext below).
    • Model-gate fields (:81, :94, :112, :129): _schedulerTableEnabled, _auditTrailTableEnabled, _refreshSessionTableEnabled and _permissionGrantTableEnabled, all four resolved in OnConfiguring and read by the model-building methods. Their remarks state the rule that keeps them correct: OnModelCreating must not depend on anything the model cache key does not cover, so the settings lookups happen once, up front, in the same place the interceptors are resolved. The fourth gate (:129) is shaped like the refresh-session one but keyed on a registration rather than a configuration flag: AddStoredPermissionGrants(configuration) registers Auth.PermissionGrantModelGate, which is the whole opt-in, and Authentication:PermissionGrants:DataSourceName (default Default) names the one database that carries the rows, resolved by ResolvePermissionGrantGate (:913) and read by ConfigurePermissionGrants.
    • TenantIdAccessor (:147) and CurrentTenantId (:154): an internal Func<string?>? assigned by the scoped DbContextFactory at context creation (DbContextFactory.cs:115, :151), and the public property that invokes it. The remarks explain why it is an accessor and not a copied value: a context can be created before the request's tenant is resolved, and a copy taken at that moment would pin the context to the wrong answer for its whole life. null reads as "no tenant", which makes the Tenant filter inert for background services, seeders and admin flows.
    • OutboxOriginAccessor (:171) and CurrentOutboxOrigin (:177): the same accessor shape, one step wider. OutboxOriginAccessor is an internal Func<Outbox.OutboxOrigin>? assigned by the scoped DbContextFactory alongside the tenant accessor (DbContextFactory.cs:156) and read once per save, before its capture loop, by DomainEventSaveChangesInterceptor when it writes outbox rows. The remarks name the reason it is an accessor rather than an injected service: a context is built by the singleton PhysicalDbContextFactory and carries only the root provider, and the interceptor that needs the ambient values is itself a singleton, so neither can reach a scoped service directly; the scoped factory is the one component that sits in both worlds. null (a context created outside a scope, design time or a directly-constructed test context) reads as "nothing captured", and CurrentOutboxOrigin falls back to default in that case.
    • ValReturn<T>: the nested keyless scalar holder, documented in its own section above.
    • CurrentSaveUserId (:184): internal audit user id with a private setter, written by the save overloads and read by AuditSaveChangesInterceptor; null marks a system operation and the interceptor resolves it to default.
    • SaveChangesAsync(userId, ct) (:194): the mutation entry point. Opens a MiniProfiler step, sets CurrentSaveUserId, calls base.SaveChangesAsync, and clears the id again in a finally so a later plain base.SaveChangesAsync on the same instance (an internal outbox write, for example) cannot silently reuse the previous caller's identity for its stamps.
    • SaveChanges(userId) (:230): the synchronous counterpart with the same set/reset discipline. Its doc comment records the behavioral difference: the sync path cannot dispatch events in-process, so captured events are delivered by the outbox processor instead.
    • Change-detection overrides (:214, :247): both EF save overloads wrap the base call in using var detection = DetectChangesOnce(), the optimization taught under DetectChangesScope.
    • OnConfiguring (:290-353): resolves the two always-present interceptors from DI (:297-298) and adds them, inserting TenantSaveChangesInterceptor between them when it is registered (:305-312). Registration order is execution order, and the comment (:300-304) states why the tenant interceptor belongs in the middle: after the audit stamps (it must not run against half-stamped entries) and before the domain-event interceptor serializes outbox rows, so those rows describe an entity whose tenant is already final. AuditTrailSaveChangesInterceptor is appended last when present (:319-322), because it diffs the final values. Both optional interceptors are fetched with GetService, not GetRequiredService: a host that never opted in, a design-time context, or a directly constructed test context must still build. The method then resolves the four model gates, including _permissionGrantTableEnabled (:343), calls ConfigureSensitiveDataLogging (:345), and replaces EF's IModelCacheKeyFactory with DataSourceModelCacheKeyFactory (:350) so each database gets its own model.
    • ConfigureSensitiveDataLogging (:367-375): a private helper called from OnConfiguring. It calls optionsBuilder.EnableSensitiveDataLogging() only when SensitiveDataLoggingGate.IsEnabled says both PersistenceSettings asked for it and IHostEnvironment reports Development, both resolved with GetService so a design-time or directly-constructed context (which registers neither) reads as "off". Living on the shared base rather than in each engine's context class is what keeps the guarantee from holding for SQL Server while a fourth engine quietly leaks it.
    • ConfigureConventions (:378-409): adds four model-finalization conventions in a fixed order. CrossDataSourceDegradeConvention strips FK constraints and navigations between entities in different physical databases (a structural no-op in the collapsed-monolith case); SoftDeleteUniqueIndexConvention makes unique indexes on soft-deletable entities exclude deleted rows, so a soft-deleted row does not block re-creating the "same" record (a hand-authored index filter is kept and extended: the soft-delete clause is appended with AND unless the filter already constrains IsDeleted, ApplicationDbContext.cs:393); RestrictDeleteByDefaultConvention (:394-399) runs last of the three finalizing conventions, deliberately, so it never stamps a relationship the cross-source convention has already removed, and records on every foreign key whether its delete behavior was chosen or inherited, inverting EF's own cascade default for a required relationship nobody configured; and, opt-in, StronglyTypedIdModelConfiguration.Apply (:401-408) runs only when a StronglyTypedIdRegistry is registered (AddStronglyTypedIds, ADR-115), mapping every declared wrapper type to the primitive it wraps on every engine because this runs on the one base context; absent the service it is a no-op and the primitive identifier aliases stay the identifier model.
    • Set<TEntity>(): a public override that forwards straight to base.Set<TEntity>() and adds no behavior of its own.
    • OnModelCreating (:416-444): applies soft-delete filters, tenant filters and concurrency tokens, registers the four keyless ValReturn<T> views, then configures the outbox (passed physicalDataSource.Key.Engine, :423), the inbox, the internal-command job queue (ConfigureInternalCommands, one table per relational source like the outbox), the scheduler table, the audit-trail table, the refresh-session table, and, last, the stored permission-grant table via ConfigurePermissionGrants, gated the same two-condition way as refresh sessions.
    • ApplySoftDeleteFilters (:453-467): protected static; iterates every non-owned IAuditableEntity type and builds an expression-tree HasQueryFilter("SoftDelete", e => e.IsDeleted == false) (:459-465). Expression trees are required because the CLR type is only known at runtime; owned types are excluded because they inherit the parent filter. [Rubric §5, Vertical Slice] (a global filter removes per-query Where(!IsDeleted) boilerplate from every slice).
    • ApplyTenantFilters (:514-574): protected (instance, because the filter body reads this context). For every non-owned ITenantEntity it configures the discriminator column as required, capped at TenantIdMaxLength of 64 (:483) and non-Unicode (:531-534), indexes it only when Engine.Capabilities.IsRelational (:543-553, so every engine but Cosmos), and builds HasQueryFilter("Tenant", e => CurrentTenantId == null || EF.Property<string>(e, "TenantId") == CurrentTenantId) (:566-572). Three mechanisms are worth internalizing here. The filter embeds this context as a constant typed as ApplicationDbContext (:521), which EF rewrites to the executing context at query compile time and lifts CurrentTenantId into a SQL parameter, so two scopes on two tenants share one compiled model and still read disjoint rows. It reads the column through EF.Property rather than a CLR member access (:557-564), which works for an explicitly implemented interface member and for a shadow property alike. And the supporting index follows the filter composition: an entity that is also an IAuditableEntity gets (TenantId, IsDeleted) because every read of it carries TenantId = @tenant AND IsDeleted = 0, while a tenant-only entity keeps the single-column index (:536-553).
    • ConfigureConcurrencyTokens (:588-608): protected (instance, because it reads this context's Engine). When Engine.Capabilities.RowVersion is RowVersionStrategy.StoreGenerated (:591, SQL Server's rowversion) it applies IsRowVersion(); on the other relational engines, PostgreSQL and SQLite, it applies IsConcurrencyToken() (:599-606), to the RowVersion property of every non-owned auditable entity. The doc comment (:576-587) names who manages the value on those engines: AuditSaveChangesInterceptor writes a fresh value on every insert and update (it checks RowVersionStrategy.ClientStamped, AuditSaveChangesInterceptor.cs:67), so the UPDATE's WHERE clause actually detects a concurrent writer. EF then includes the token in UPDATE/DELETE WHERE clauses and throws DbUpdateConcurrencyException on conflicts. [Rubric §8, Data Architecture].
    • QuoteColumn and IncludeColumns (:610-648): two private static helpers the filtered outbox, internal-command and scheduler indexes below route through, each now a one-line delegation to the engine. QuoteColumn(DataSource engine, string column) (:618-619) forwards to SoftDeleteFilterSql.QuoteColumn, and its doc comment (:611-613) states the result: brackets on SQL Server, SQL-standard double quotes on PostgreSQL and SQLite. IncludeColumns (:644-648) forwards to DataSourceEngines.For(engine).IncludeColumns, which picks the provider's own IncludeProperties overload (the SQL Server and PostgreSQL extensions share one signature and are ambiguous unqualified once both are referenced). Its remarks (:631-643) record two deliberate choices: every engine other than PostgreSQL keeps the SQL Server annotation, SQLite included because its provider ignores it, and the helper takes property names rather than a selector so the scheduler table does not reflect as a Persistence -> Scheduling type reference.
    • ConfigureOutbox (:661, taking DataSource engine): maps OutboxMessages in dbo with length/unicode constraints, plus three purpose-built filtered indexes, built through QuoteColumn/IncludeColumns so the same method body produces valid filter and include syntax on every relational engine. IX_OutboxMessages_Pending covers the poll path over (ProcessedOn, OccurredOn) filtered to the processed column being null and includes RetryCount and LockedUntil so the processor's extra predicates do not force a key lookup per candidate row; IX_OutboxMessages_Processed covers the retention sweep over rows the pending index deliberately excludes; and IX_OutboxMessages_Ordering over (OrderingKey, OccurredOn), filtered to keyed pending rows, answers the claim predicate's "is there an earlier unprocessed row with this key?" question with a seek instead of a scan per candidate row. [Rubric §12, Performance & Scalability].
    • ConfigureInbox (:721): maps InboxMessages in dbo with a unique IX_InboxMessages_MessageId (the consumer-side idempotency key) and an IX_InboxMessages_ProcessedOn index so the age-based purge has something to seek.
    • ConfigureScheduler (:802): the first gated table. It returns immediately unless _schedulerTableEnabled, then maps ScheduledJobEntry to ScheduledJobs in dbo keyed by JobName, with IX_ScheduledJobs_NextRunOn including the lease columns through IncludeColumns (:831). The index comment records the load-bearing decision: it is deliberately not filtered to unlocked rows, because the poll must also find rows whose lease has expired, which is how a dead replica's work is reclaimed.
    • ConfigureAuditTrail (:847): the second gated table, mapping AuditTrailEntry to AuditTrailEntries in dbo with a read index over (EntityType, EntityKey, ChangedOn) and a retention index over ChangedOn. Note the asymmetry with the scheduler, stated in the gate fields' remarks (:81, :94): the job table is host-scoped and lives in the Default source only, while the trail table belongs in every relational source, because a trail row must commit in the same transaction as the change it describes and a transaction does not span databases (the outbox precedent).
    • ConfigureRefreshSessions (:892): the third gated table, and the one gated on a configurable source rather than a fixed one. It returns unless _refreshSessionTableEnabled, which requires both RefreshSessions:Enabled and this context targeting the source named by RefreshSessions:DataSourceName (:336-339); when it passes it simply calls RefreshSessionModelBuilderExtensions.ApplyRefreshSessionConfiguration. The doc comment states why the mapping lives in the framework base rather than in a consumer's context class: under ADR-006 downstream apps run on the sealed engine contexts and have no context class of their own to override, and RefreshSession is not an AuditableBaseEntity, so the module entity-configuration mechanism does not reach it either.
    • ApplyConfigurationsForEntitiesInContext (:953-987): the discovery method subclasses call from their OnModelCreating. It asks the engine for its configuration interface (DataSourceEngines.For(dataSource).EntityConfigurationInterface, :955) rather than switching on the DataSource value, resolves IEntityDataSourceRegistry (:962), then for each assembly from the provider calls ModelBuilderExtensions.ApplyAllConfigurations with a filter that keeps only entities whose registry-resolved key equals this DataSourceKey, or, for unregistered entities, only when this context is the engine's Default source (:974-980). The whole pass runs inside a try/finally that sets EngineAnnotation to this context's engine on the model first (:969) and removes it afterwards (:985). The comment (:964-968) gives the reason: a configuration declared for one engine can be applied to another engine's model when the host substitutes an unconfigured engine, so it cannot trust its own attribute for SQL text and reads the effective engine from the annotation instead; removing it keeps the finished model and every migration snapshot unchanged.
  • Why it's built this way: ADR-006 (database-per-service) requires the same context class per database; without a specialized model-cache key EF would build one model and silently reuse it, so queries would hit tables that do not exist in the other databases. The single ApplicationDbContext is deliberately never split into per-module context classes (also ADR-006). The interceptor pipeline keeps audit, tenancy and outbox concerns out of every handler (ADR-073 for the tenancy model, ADR-075 for the trail, ADR-074 for the job store). The four optional tables (scheduler, audit trail, refresh sessions, permission grants) are settings-or-registration-gated rather than always mapped so a host that never opted in keeps the exact model it had before those features shipped, and its migrations never see the tables. PostgreSQL joins SQL Server and SQLite as a third relational engine under ADR-113; the internal-command job queue is configured on every relational source the same way the outbox is (ADR-114); strongly typed identifiers apply only when a host opts in (ADR-115); and the restrict-by-default delete convention inverts EF's own cascade default for every non-owned foreign key (ADR-119). Every engine-specific branch reads the engine object behind Engine rather than comparing DataSource values inline (ADR-130), so a fact about an engine is stated once, on that engine. The comment above the registry lookup (:957-961) records one more deliberate fallback: an entity configured without the attributed base classes lands in the Default model but is not routable through the unit of work.
  • Where it's used: inherited by the four concrete contexts below; created per source by PhysicalDbContextFactory through the engine's CreateDbContext (PhysicalDbContextFactory.cs:32), cached per scope and given its tenant accessor by DbContextFactory (DbContextFactory.cs:107-115), and consumed by the interceptors and the outbox processor. The model gates are pinned by SchedulerModelGateTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/SchedulerModelGateTests.cs:28, :38, :48, :59), AuditTrailModelGateTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/AuditTrail/AuditTrailModelGateTests.cs:29, :39, :49, :63) and RefreshSessionModelGateTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/RefreshSessionModelGateTests.cs:42, :51, :60, :73, :89); the tenant filter by ApplicationDbContextTenantFilterTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Tenancy/ApplicationDbContextTenantFilterTests.cs:27, :41, :59, :74, :96, :116, :131, :143, :156, :171, :184, :196), which covers cross-tenant hiding, composition with soft delete, the null-tenant escape, two tenants sharing one cached model, the live accessor being read at query time, the composite (TenantId, IsDeleted) index and the single-column one, and owned types getting no filter of their own.

DataSourceModelCacheKeyFactory

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/DataSourceModelCacheKeyFactory.cs:16 · Level 11 · class (sealed)

  • What it is: a replacement for EF Core's default IModelCacheKeyFactory. Where the default keys the model cache by context type alone, this keys by (context type, data source name, design-time flag), so each physical database gets its own EF model.
  • Depends on: ApplicationDbContext (to read DataSourceKey.Name) and EF Core's IModelCacheKeyFactory.
  • Concept introduced, model caching under one-context-class-per-engine. [Rubric §8, Data Architecture]: EF builds an in-memory model per DbContext type and caches it. When the same class serves two databases, EF would reuse the first model and the second database's entities would be missing. Inserting DataSourceKey.Name into the cache key makes EF treat "SQL Server / Conference" and "SQL Server / Identity" as distinct models. The class comment states the failure mode plainly (DataSourceModelCacheKeyFactory.cs:11-14): without it, queries would target tables that do not exist in the other databases. This is the critical enabler for ADR-006.
  • Walkthrough: Create(DbContext context, bool designTime) (DataSourceModelCacheKeyFactory.cs:19-22) returns (context.GetType(), applicationDbContext.DataSourceKey.Name, designTime) when the context is an ApplicationDbContext, otherwise falls back to (context.GetType(), designTime). The value tuple's structural equality is all EF needs to key its cache dictionary.
  • Why it's built this way: the fix is minimal and lives entirely in Infrastructure through EF's supported extension point; no EF internals are subverted. Note what is deliberately absent from the key: the tenant. One compiled model serves every tenant, and the tenant value enters as a SQL parameter through the Tenant query filter instead (see ApplicationDbContext.ApplyTenantFilters and its remarks at ApplicationDbContext.cs:501-507), which is what keeps a multi-tenant host from building one model per tenant.
  • Where it's used: registered in ApplicationDbContext.OnConfiguring via optionsBuilder.ReplaceService<IModelCacheKeyFactory, DataSourceModelCacheKeyFactory>() (ApplicationDbContext.cs:350), so every engine context inherits it.

CosmosDbContext

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/CosmosDbContext.cs:15 · Level 12 · class (sealed)

  • What it is: the sealed ApplicationDbContext subclass targeting Azure Cosmos DB. One instance exists per physical Cosmos data source (account plus database). It is the most divergent of the three concrete contexts because Cosmos is non-relational: no outbox table, no relational indexes, and a truncated OnModelCreating path.
  • Depends on: ApplicationDbContext, PhysicalDataSource, DataSource, OutboxMessage, IEntityConfigurationAssemblyProvider, and the Cosmos EF provider (Microsoft.Azure.Cosmos). [Rubric §8, Data Architecture] (one concrete context per engine) and [Rubric §11, Security] (the emulator-only certificate bypass).
  • Walkthrough:
    • 4-arg constructor (CosmosDbContext.cs:15-20): forwards options, service provider, assembly provider, and physical source to the base.
    • Emulator handling in OnConfiguring (CosmosDbContext.cs:22-73): two decisions, deliberately separate (:29-31). The emulator account key alone (UsesEmulatorKey, :32) selects ConnectionMode.Gateway (:44). Disabling certificate validation additionally requires a loopback endpoint (ShouldBypassCertificateValidation, :33), because the key is published by Microsoft and so never proves the endpoint is the local emulator; only then does an HttpClientFactory install DangerousAcceptAnyServerCertificateValidator under a #pragma warning disable S4830 (:46-61). The production path uses ConnectionMode.Direct with MaxRequestsPerTcpConnection(20) and MaxTcpConnectionsPerEndpoint(32) (:66-68). The database name comes from PhysicalSource.CosmosDatabaseName (:38).
    • UsesEmulatorKey (:82-83) and ShouldBypassCertificateValidation (:98-99): internal static predicates. The first is an ordinal Contains("C2y6yDjf5"), the well-known prefix of the emulator's default account key. The second requires that key AND a loopback AccountEndpoint, judged by the private HasLoopbackAccountEndpoint (:101-116): the connection string is parsed with DbConnectionStringBuilder and the endpoint with Uri.TryCreate, then tested with Uri.IsLoopback, never by a string prefix, so a host such as localhost.attacker.example does not qualify, and an unparseable string or endpoint keeps validation on (:93-97).
    • No outbox override: the class no longer overrides anything to opt out of the outbox. Cosmos is non-relational on its engine object, and DomainEventSaveChangesInterceptor tests Engine.Capabilities.IsRelational (DomainEventSaveChangesInterceptor.cs:141, :236), so domain events from a Cosmos context are dispatched in-process only.
    • OnModelCreating (CosmosDbContext.cs:119-146): applies the Cosmos configurations (:121), then Ignore<OutboxMessage>() (:124) and, for the same relational-only reason, Ignore<InternalCommandMessage>() (:127), then removes every index from every entity type because the provider does not support relational HasIndex/HasFilter, then calls ApplySoftDeleteFilters and ApplyTenantFilters directly. It deliberately does not call base.OnModelCreating because every table the base maps (outbox, inbox, internal commands, scheduler, audit trail, refresh sessions) is a relational-only construct the Cosmos provider cannot express. Soft-delete and tenant filters are applied independently via the extracted helper methods; the tenant helper skips only its index for Cosmos, which indexes every property itself.
  • Why it's built this way: pushing all provider differences into this subclass keeps the base and the entity configuration bodies engine-agnostic; stripping indexes lets one configuration body serve both SQL Server and Cosmos. Calling the two filter helpers directly rather than through the base is what keeps soft delete and tenancy in force on an engine that cannot run the rest of the base pipeline (the tenant helper skips its index on a non-relational engine, ApplicationDbContext.cs:544-554).
  • Where it's used: instantiated per Cosmos source by CosmosDataSourceEngine.CreateDbContext (CosmosDataSourceEngine.cs:79-83), which PhysicalDbContextFactory reaches through DataSourceEngines when a data source resolves to the CosmosDB engine (PhysicalDbContextFactory.cs:32). The emulator predicates are pinned by CosmosDbContextTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/CosmosDbContextTests.cs).
  • Caveats / not-in-source: Gateway mode is still selected by an ordinal substring match on the emulator key prefix alone, though the certificate bypass now also requires a loopback endpoint. Per ADR-018 the plumbing ships and is tested, but no host in this workspace configures a Cosmos source today.

PostgreSQLDbContext

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/PostgreSQLDbContext.cs:23 · Level 12 · class (sealed)

  • What it is: the sealed ApplicationDbContext subclass targeting PostgreSQL, the third relational engine alongside SQL Server and SQLite (PostgreSQLDbContext.cs:23-30). One instance exists per physical PostgreSQL data source.
  • Depends on: ApplicationDbContext, PhysicalDataSource, DataSource, IEntityConfigurationAssemblyProvider, PersistenceSettings via IOptions<>, UtcDateTimeConverter, and the Npgsql EF provider. [Rubric §8, Data Architecture] (a third concrete context, same shape as SQL Server) and [ADR-113](https://ivanball.github.io/docs/adr/113-postgresql-as-a-first-class-engine.html).
  • Walkthrough:
    • TimestampWithTimeZone (PostgreSQLDbContext.cs:30): internal const string holding "timestamp with time zone", the PostgreSQL store type every DateTime maps to. Named as a constant so the context and its tests assert the same string rather than a repeated literal.
    • _persistenceSettings (:38-39): resolved once per instance in a field initializer, for the same CS9107 reason documented on SQLServerDbContext: reading the primary constructor parameter from a member body would capture it into this type's state while the base constructor also receives it. GetService, not GetRequiredService, so the design-time provider behind dotnet ef (which registers no options at all) falls back to PersistenceSettings' defaults.
    • OnConfiguring (:41-59): calls UseNpgsql(PhysicalSource.ConnectionString, npgsql => ...). The options action conditionally sets npgsql.MigrationsAssembly(PhysicalSource .MigrationsAssembly) (the same "no migrations next to the context" contract as SQL Server and SQLite, now a single shared property on PhysicalDataSource rather than a per-engine one), applies npgsql.CommandTimeout(_persistenceSettings.CommandTimeoutSeconds), and enables npgsql.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorCodesToAdd: null) for the same transient-failure reason SQL Server does (connection drops, a managed instance failover, a pooler recycling a backend), with the same Database.CreateExecutionStrategy().ExecuteAsync caveat for manual BeginTransactionAsync calls, already honored by TransactionalCommandDecorator<TCommand, TResult>. ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) suppresses the same microservices-extraction warning documented on SQLServerDbContext.
    • ConfigureConventions (:61-70): calls base.ConfigureConventions then adds the UTC normalization every PostgreSQL timestamp needs: Properties<DateTime>().HaveConversion< UtcDateTimeConverter>().HaveColumnType(TimestampWithTimeZone). Declared as a type-mapping configuration rather than property-by-property so it reaches the framework's own tables (outbox, inbox, scheduled jobs, audit trail, refresh sessions) and a consumer's entities alike, including nullable DateTime properties, which EF configures from the same non-nullable entry.
    • OnModelCreating (:73-77): calls ApplyConfigurationsForEntitiesInContext(DataSource .PostgreSQL, modelBuilder) then base.OnModelCreating, so the full base pipeline (soft-delete and tenant filters, application-managed concurrency tokens, outbox/inbox/internal-command tables, and the three settings-gated tables) runs.
  • Why it's built this way: shares the SQL Server shape (retry, command timeout, pending-model- changes suppression) because both are production-grade relational engines under ADR-113; the one PostgreSQL-specific addition, the timezone-aware timestamp conversion, exists because Npgsql maps DateTime to timestamp without time zone by default, and the framework's audit and outbox timestamps are always UTC.
  • Where it's used: instantiated per PostgreSQL source by PhysicalDbContextFactory when a data source resolves to the PostgreSQL engine; built at design time by DesignTimeDbContextHelper. Its model is pinned by PostgreSQLDbContextModelTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/PostgreSQLDbContextModelTests.cs) and exercised against a real server by PostgreSQLPersistenceTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.PostgreSQL.Tests/PostgreSQLPersistenceTests.cs).

SqliteDbContext

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SqliteDbContext.cs:12 · Level 12 · class (sealed)

  • What it is: the sealed ApplicationDbContext subclass targeting SQLite, the minimal concrete context. One instance exists per physical SQLite data source (database file), useful for lightweight local development or testing without a SQL Server instance (SqliteDbContext.cs:7-10).
  • Depends on: ApplicationDbContext, PhysicalDataSource, DataSource, IEntityConfigurationAssemblyProvider, and the SQLite EF provider.
  • Walkthrough: the 4-arg constructor forwards to the base (SqliteDbContext.cs:12-17). OnConfiguring guards its argument, then calls UseSqlite(PhysicalSource.ConnectionString, ...) with one option: it sets sqlite.MigrationsAssembly(PhysicalSource.MigrationsAssembly) when that value is configured (SqliteDbContext.cs:31-34), the same shared PhysicalDataSource property SQL Server and PostgreSQL now read instead of a per-engine one. The comment above it states the contract this shares with SQL Server (:28-30): without an explicit assembly EF looks for migrations next to the context, which lives in MMCA.Common.Infrastructure and has none, so a per-source migrations project would never be found. There is no retry policy (the store is file-local) and no command-timeout override. OnModelCreating calls ApplyConfigurationsForEntitiesInContext(DataSource.Sqlite, modelBuilder) then base.OnModelCreating (SqliteDbContext.cs:40-44), so unlike Cosmos it keeps the full base pipeline: soft-delete and tenant filters, concurrency tokens as application-managed tokens rather than rowversion, the outbox and inbox tables, and the three settings-gated tables. See SQLServerDbContext for the shared subclass shape.
  • Why it's built this way: SQLite needs none of the SQL Server hardening (transient-failure retry, a per-environment command timeout), so the override is intentionally sparse, but the migrations assembly is not optional hardening: it is what lets a SQLite source participate in IDbContextFactory.MigrateAsync at all, since PhysicalDataSource.UsesMigrations returns true for SQLite only when that assembly is configured (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:41-47). Keeping the full base pipeline is what makes this context a faithful stand-in for SQL Server in tests, which is how the framework's own tenancy and model-gate suites run without a database (for example MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Tenancy/ApplicationDbContextTenantFilterTests.cs:24, which holds one SQLite connection open for the fixture's lifetime). [Rubric §14, Testability].
  • Where it's used: instantiated per SQLite source by PhysicalDbContextFactory when a data source resolves to the Sqlite engine (PhysicalDbContextFactory.cs:32 dispatches to SqliteDataSourceEngine.cs:88). Its migration behavior is pinned by DbContextFactoryMigrationTargetTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/DbContextFactoryMigrationTargetTests.cs:94, :106, :115, :126), which asserts that a SQLite source with a migrations assembly is migrated, one without is skipped, HasPendingMigrationsAsync considers only the migrated source, and EnsureCreatedAsync still covers both.

SQLServerDbContext

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:15 · Level 12 · class (sealed)

  • What it is: the sealed ApplicationDbContext subclass targeting SQL Server, the production-primary context. One instance exists per physical SQL Server data source (database); its connection string and migrations assembly come from the resolved PhysicalDataSource.
  • Depends on: ApplicationDbContext, PhysicalDataSource, DataSource, IEntityConfigurationAssemblyProvider, PersistenceSettings via IOptions<>, and the SQL Server EF provider (RelationalEventId). [Rubric §8, Data Architecture] (one concrete context per engine, one instance per database) and [Rubric §29, Resilience & Business Continuity] (the retry policy and the command timeout are baked into the SQL Server path).
  • Walkthrough:
    • 4-arg constructor (SQLServerDbContext.cs:15-20): forwards to the base.
    • _persistenceSettings (SQLServerDbContext.cs:35-36): a readonly field resolved in the field initializer as serviceProvider.GetService<IOptions<PersistenceSettings>>()?.Value ?? new PersistenceSettings(). Two details are deliberate and documented at :23-35. It is resolved once per instance rather than read from the primary-constructor parameter inside OnConfiguring, because referencing that parameter from a member body would capture it into the type's state while the base constructor also receives it (CS9107); caching per instance is safe precisely because these contexts are never pooled. And it uses GetService, not GetRequiredService, because the design-time provider behind dotnet ef registers no options at all and must not be made to throw: both a missing registration and a null value fall back to the defaults.
    • OnConfiguring (SQLServerDbContext.cs:39-82): calls UseSqlServer(PhysicalSource.ConnectionString, sql => ...). The options action does three things: conditionally sets sql.MigrationsAssembly(PhysicalSource.MigrationsAssembly) (:49-52), the same single property PostgreSQL and SQLite now read instead of a per-engine one, so each extracted service can point at its own per-module migrations project; applies sql.CommandTimeout(_persistenceSettings.CommandTimeoutSeconds) (:56), because without it every command silently inherits ADO.NET's 30 second default with no way to tune it per environment (the setting's own default is 30, range-validated to 1-600, at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/PersistenceSettings.cs:21-22); and enables sql.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null) (:64-67). An inline comment (:61-63) records the retry caveat: with retry enabled, any manual BeginTransactionAsync must be wrapped in Database.CreateExecutionStrategy().ExecuteAsync, which TransactionalCommandDecorator<TCommand, TResult> already does through DbContextFactory. Finally ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) (:80) suppresses EF Core's pending-model error.
    • OnModelCreating (SQLServerDbContext.cs:85-89): calls ApplyConfigurationsForEntitiesInContext(DataSource.SQLServer, modelBuilder) then base.OnModelCreating, so the full base pipeline (soft-delete and tenant filters, rowversion concurrency tokens, outbox/inbox tables, and the three settings-gated tables) runs.
  • Why it's built this way: the PendingModelChangesWarning suppression is required by the microservices-extraction design: each extracted host registers only its enabled modules' configurations, so its runtime model is a strict subset of the migration snapshot (the union of all modules), and EF Core 9+ would otherwise promote that mismatch to an error inside Migrator.ValidateMigrations during MigrateAsync (SQLServerDbContext.cs:68-74). The documented trade-off (:77-79): monolith hosts lose the "you forgot a migration" safety net, so CI should run dotnet ef migrations has-pending-model-changes against the migrations assembly with the full model loaded as a separate gate. Retry-on-failure exists so cold-replica startup connections and platform replica replacements do not surface as user-facing 5xx (ADR-006, ADR-009).
  • Where it's used: instantiated per SQL Server source by PhysicalDbContextFactory (PhysicalDbContextFactory.cs:32 dispatches to SQLServerDataSourceEngine.cs:91); built at design time by DesignTimeDbContextHelper.CreateSqlServer (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:52), which is what makes one migrations project per database possible. It is also the type DbContextFactory tests for on the identity-insert path (DbContextFactory.cs:267). It is the primary production context in both MMCA.ADC and MMCA.Store, and the context type every committed migration snapshot is generated against.
  • Caveats / not-in-source: whether any repository's CI actually runs the has-pending-model-changes gate the comment recommends is Not determinable from this file.

IDbContextFactory

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Factory · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IDbContextFactory.cs:10 · Level 12 · interface

  • What it is: the framework's own context-factory contract, the scoped object that hands out one ApplicationDbContext per physical DataSourceKey and then coordinates saving, transactions, schema lifecycle, and disposal across every context a scope touched (IDbContextFactory.cs:5-10). It is deliberately not EF Core's IDbContextFactory<TContext>: the two names collide, which is why consumers that need both add a using IDbContextFactory = ...DbContexts.Factory.IDbContextFactory; alias (InProcessEventBus.cs:8, BrokerEventBus.cs:8, EfInboxStore.cs:8) or fully qualify it (OutboxProcessor.cs:189).
  • Depends on: ApplicationDbContext, DataSourceKey, and the BCL IDisposable plus IAsyncDisposable it extends (IDbContextFactory.cs:10).
  • Concept introduced, addressing a context by physical source rather than by type. [Rubric §8, Data Architecture] (assesses whether transaction boundaries and unit-of-work scope are deliberate, and whether per-service data isolation is real): EF's own factory answers "give me a context of type T". This one answers "give me the context for this database", which is the only question that makes sense once ADR-006 splits storage along a Name axis and ADR-018 adds an orthogonal Engine axis. The interface also owns the honest statement of what a multi-source save can and cannot promise, in the ExecuteInTransactionAsync doc comment (IDbContextFactory.cs:60-65): each physical source gets its own transaction, commits are sequential and best-effort, there is no two-phase commit, a failure mid-commit leaves earlier sources committed, and the outbox is the cross-source consistency mechanism.
  • Walkthrough:
    • GetDbContext(DataSourceKey) (IDbContextFactory.cs:16): the only accessor, documented to create the context if this scope does not already have one for that source.
    • EnsureCreatedAsync (:22), MigrateAsync (:81), HasPendingMigrationsAsync (:87): schema lifecycle across every source the host uses. The contract states the asymmetry: EnsureCreatedAsync skips sources with no configured connection string (:18-21), while the two migration members cover only the sources a migrations pipeline owns, which is every SQL Server source plus any SQLite source with a configured SqliteMigrationsAssembly; Cosmos and assembly-less SQLite are created by EnsureCreatedAsync instead (:74-80).
    • SaveChangesAsync (:27) and SaveChanges (:32): save across all active contexts, the async overload documented as carrying audit stamping and domain-event dispatch.
    • RequestExplicitKeyInsert (:40): a one-shot flag for the next save, signalling that it may include entities carrying explicit values for store-generated keys. The contract is now engine-neutral (:34-39): each context's engine decides whether those inserts need a per-table toggle (its explicit-key insert dialect, IExplicitKeyInsertDialect), an engine with none saves unchanged, and the flag is cleared once the save completes (see ExplicitKeyInsertGroup).
    • BeginTransaction / CommitTransaction / RollbackTransaction (:45, :50, :55): applied to every active context that supports transactions.
    • ExecuteInTransactionAsync<TResult> (:70-72): the member handlers actually reach, running the operation under the active execution strategy so a retrying strategy retries the whole unit.
  • Why it's built this way: the application layer already talks to IUnitOfWork; this second interface exists so the physical-topology coordination (which databases, which transactions, which migrations) has a home that Infrastructure can implement and tests can mock, without leaking EF Core upward.
  • Where it's used: registered scoped as DbContextFactory (DependencyInjection.cs:104). UnitOfWork delegates its whole save and transaction surface to it (UnitOfWork.cs:85-94), the startup path resolves it to create, migrate, or verify databases (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:74, :242, and per tenant at :144), and the background and messaging paths resolve it per scope to reach a specific source (OutboxProcessor at OutboxProcessor.cs:189, OutboxCleanupService.cs:100, OutboxAdministration.cs:219, EfInboxStore.cs:42, InProcessEventBus.cs:34, BrokerEventBus.cs:32, ScheduledJobRunner.cs:222, AuditTrailReader.cs:35, AuditTrailCleanupJob.cs:46, EFRefreshSessionStore.cs:32, and RefreshSessionCleanupService.cs:112).

IPhysicalDbContextFactory

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Factory · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IPhysicalDbContextFactory.cs:15 · Level 12 · interface

  • What it is: the contract for constructing a raw context for a given physical data source. Its doc comment states the split precisely: the engine selects the context class (SQL Server, Cosmos, SQLite) and the source name selects the database (connection string, migrations assembly, EF model), and contexts created here are neither scoped nor cached (IPhysicalDbContextFactory.cs:6-13).
  • Depends on: ApplicationDbContext as the return type, DataSourceKey as the addressing type, and PhysicalDataSource on the second overload (IPhysicalDbContextFactory.cs:22, :37).
  • Concept introduced, construction split from lifetime. [Rubric §2, Design Patterns] (assesses whether patterns are applied where they earn their keep): this is the Abstract Factory half of the pair. Deciding which class to new up for which database is a pure function of the key and needs no per-request state, so it lives in a singleton; deciding how long a context lives, when it saves, and what transaction it is in is per-request state and lives in the scoped IDbContextFactory layered on top (IPhysicalDbContextFactory.cs:10-13).
  • Walkthrough: two members. Create(DataSourceKey key) (IPhysicalDbContextFactory.cs:22) returns a new instance targeting the database the resolver maps that key to. Create(DataSourceKey key, PhysicalDataSource physicalDataSource) (IPhysicalDbContextFactory.cs:37) is the database-per-tenant overload: the caller clones the resolved PhysicalDataSource with the tenant's connection string and passes the same key, so EF's model cache still serves one compiled model per (context type, source name) across every tenant (IPhysicalDbContextFactory.cs:24-35).
  • Why it's built this way: keeping the construction decision behind a two-method interface is what makes the scoped coordinator testable without a database ([Rubric §14, Testability]): the commit-ambiguity tests hand DbContextFactory a Mock<IPhysicalDbContextFactory> that returns a SQLite in-memory context (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/DbContextFactoryCommitAmbiguityTests.cs:49-55). The two-overload shape is also what keeps tenancy (ADR-073) a routing decision in the scoped layer rather than a second factory.
  • Where it's used: registered singleton as PhysicalDbContextFactory (DependencyInjection.cs:105); injected into DbContextFactory (DbContextFactory.cs:50), which calls both overloads at :94-96.

PhysicalDbContextFactory

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Factory · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:19 · Level 13 · class (sealed)

  • What it is: the singleton implementation of IPhysicalDbContextFactory. It resolves the key's connection information through IDataSourceResolver (or accepts it from the caller) and asks the key's engine object to construct the matching context (PhysicalDbContextFactory.cs:25-33).
  • Depends on: IServiceProvider, IDataSourceResolver, and IEntityConfigurationAssemblyProvider, all taken through a primary constructor (PhysicalDbContextFactory.cs:19-22), plus DataSourceEngines and the IDataSourceEngine it returns, whose CreateDbContext (IDataSourceEngine.cs:83) builds the SQLServerDbContext, PostgreSQLDbContext, SqliteDbContext, or CosmosDbContext.
  • Concept introduced, the never-pool rule. [Rubric §8, Data Architecture] and [Rubric §12, Performance & Scalability] (which assesses whether performance work is deliberate rather than reflexive): EF Core's AddPooledDbContextFactory is the standard way to avoid re-allocating contexts, and it is explicitly forbidden here. The class comment says why (PhysicalDbContextFactory.cs:13-17): each instance carries per-source constructor state (its PhysicalDataSource), so a pool would hand a context configured for one database to a caller asking for another and silently point repositories at the wrong database. The same warning is repeated at the registration site (MMCA.Common.Infrastructure/DependencyInjection.cs:103-109), which is where someone optimizing DI would look first. Under database-per-tenant the rule is load-bearing twice over, since a pooled context could then cross a tenant boundary rather than only a module one.
  • Walkthrough:
    • No options of its own: the static empty DbContextOptions<T> objects now live one per engine class (for example SQLServerDataSourceEngine.cs:23, CosmosDataSourceEngine.cs:22), each built once and reused; provider, connection, interceptors, and model cache key are all set inside each context's OnConfiguring, so those options exist only to satisfy the DbContext constructor.
    • Create(DataSourceKey key) (PhysicalDbContextFactory.cs:25): a one-liner that resolves the PhysicalDataSource with resolver.GetPhysical(key) and forwards to the two-argument overload, so the resolver path and the tenant path share one construction switch.
    • Create(DataSourceKey key, PhysicalDataSource physicalDataSource) (PhysicalDbContextFactory.cs:28-33): null-guards the supplied source (:30), then returns DataSourceEngines.For(key.Engine).CreateDbContext(serviceProvider, assemblyProvider, physical) (:32). The engine-to-class switch that used to live here is gone: each engine class constructs its own context with its own options.
  • Why it's built this way: this is the single point where the two storage axes meet, the Engine axis of ADR-018 picking the class and the Name axis of ADR-006 picking the database, so adding an engine is a new engine class plus a context class rather than a change anywhere in application code (ADR-130). Taking the connection information as a parameter is what let ADR-073 add a third, per-tenant axis without touching the switch.
  • Where it's used: registered singleton (DependencyInjection.cs:105); DbContextFactory.GetDbContext calls one overload or the other on every cache miss (DbContextFactory.cs:106-108), which is the only first-party call site.

DbContextFactory

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Factory · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:49 · Level 13 · class (sealed)

  • What it is: the scoped implementation of IDbContextFactory and the busiest type in this group. It caches one ApplicationDbContext per physical DataSourceKey for the life of the scope, coordinates save, transaction, migration, and disposal across all of them, and is also the database-per-tenant routing point (DbContextFactory.cs:22-31).
  • Depends on: IPhysicalDbContextFactory, IEntityDataSourceRegistry, IDataSourceResolver, and ICurrentUserService, all null-guarded from the primary constructor into readonly fields (DbContextFactory.cs:49-69), plus three parameters read directly off the primary constructor: ITenantContext, IOptions<TenancySettings> (:43-44, with their contracts documented at :31-37), and an optional ICorrelationContext? correlationContext = null (:46), the scope's correlation id, captured onto every outbox row a context this factory creates writes; defaulted so a container that never registered one (a bare test provider) keeps the previous constructor shape and simply stores no correlation id. It also calls four internal static members of DomainEventSaveChangesInterceptor (BeginCaptureExclusion, EndCaptureExclusion, DropDeferred, FlushDeferredAsync, at DbContextFactory.cs:411, :342, :447, and :581).
  • Concept introduced, coordinating one logical save across several physical databases. [Rubric §8, Data Architecture] (transaction boundaries, unit-of-work scope, per-service isolation) and [Rubric §12, Performance & Scalability] (transactions handled once in a shared mechanism, never copy-pasted into handlers): every hard question raised by ADR-006 lands here. How many contexts does a request have (one per source it touches)? What does "commit" mean when there are two (sequential, best-effort, no two-phase commit)? When are in-process domain events allowed to run (only after a successful commit)? What happens when the commit itself fails with an unknown outcome (see TransactionCommitAmbiguousException)?
  • Concept introduced, per-tenant routing without a second EF model. [Rubric §11, Security] and [Rubric §30, Compliance, Privacy & Data Governance]: under ADR-073 a tenant may declare its own connection string for a source, and this class is where that declaration turns into a different database. The trick is that only the connection string changes: the DataSourceKey stays the same, which is what EF's model cache is keyed on, so one compiled model serves every tenant (DbContextFactory.cs:163-168).
  • Walkthrough:
    • State (DbContextFactory.cs:59-96): MaxSavePasses (3, :52); _dbContexts, the per-scope Dictionary<DataSourceKey, ApplicationDbContext> that guarantees every repository in a scope shares one change tracker per database (:62); _routedContextTenants, recording which tenant each per-tenant-routed context was created for and holding only overridden sources (:64-69); _transactionActive (:86); the one-shot _explicitKeyInsertRequested flag (:93); and a volatile bool _disposed (:95).
    • GetDbContext(DataSourceKey) (DbContextFactory.cs:99-133): throws if disposed (:89), then on a cache miss asks ResolveTenantOverride for the tenant's own connection information and creates the context through whichever Create overload applies (:93-96), records the creating tenant when the source was routed (:100-101), attaches the scope's accessors (:103), and enlists the new context in an already-active transaction (:107-108), so a source first touched inside a transactional command still shares the boundary. On a cache hit it calls GuardRoutedTenantUnchanged (:124) and then GuardSharedContextNotRoutable (:128).
    • AttachScopeAccessors (DbContextFactory.cs:147-161, renamed from AttachTenantAccessor): hands the context two delegates rather than copied values, because a context can be created before the request's tenant or principal is resolved and both the query filter and the outbox capture must read the answer that holds at query and save time rather than at construction time. It sets context.TenantIdAccessor = () => tenantContext.TenantId as before, and now also context.OutboxOriginAccessor = () => new OutboxOrigin(_currentUserService.UserId, AmbientOrigin.FlattenRoles(_currentUserService.Roles), tenantContext.TenantId, correlationContext?.CorrelationId) (:149-153), invoked once per save that writes outbox rows, not once per row, so flattening the role claims costs one pass per save. It null-guards inside rather than at the call site so the null tolerance a mocked physical factory needs does not leak a maybe-null flow state back into the caller.
    • ResolveTenantOverride (DbContextFactory.cs:169-194): returns null (source stays shared) unless there is a tenant, bound settings, an entry for that tenant, and an entry for this source name (:145-151); otherwise it takes the engine-appropriate connection string through TenancySettingsValidator.ConnectionStringFor (:153), still returning null when the tenant overrides only a different engine (:154-158), and finally clones the shared PhysicalDataSource with the tenant's connection string and Cosmos database name (:160-167).
    • GuardRoutedTenantUnchanged (DbContextFactory.cs:202-219): if the cached context was routed for a tenant and the scope's tenant has since changed, it throws with both tenant ids named (:185-192). The comment states the stakes (:170-175): serving that context to a second tenant would read and write the first tenant's data under the second tenant's filter value.
    • GuardSharedContextNotRoutable (DbContextFactory.cs:227): the mirror-image guard. A context created shared (because a repository call ran before the tenant resolved) is refused once the scope's tenant turns out to override that source: unless the context was itself routed or ResolveTenantOverride now returns null, it throws an InvalidOperationException telling the caller to resolve the tenant before the first repository call or use a fresh scope. Serving it would read and write the shared database for a tenant that owns its own.
    • GetSourcesInUse (DbContextFactory.cs:258-259): the union of every source backing a registered entity (from IEntityDataSourceRegistry) and every source already materialized in this scope, which is what EnsureCreatedAsync (:196-207) and GetMigrationTargets (:705-723) iterate. EnsureCreatedAsync skips sources with an empty connection string (:200-203).
    • GetMigrationTargets (DbContextFactory.cs:804-817): the shared filter behind MigrateAsync (:686-690) and HasPendingMigrationsAsync (:726-735). It keeps a source only when its resolved PhysicalDataSource.UsesMigrations is true (:713-714), then skips a non-SQL-Server target whose connection string is empty (:716-717). The asymmetry is deliberate and documented (:697-702): an optional SQLite source a test host leaves unconfigured stays silently absent, while a SQL Server source with no connection string still fails loudly at startup, because for SQL Server that is a misconfiguration rather than an option.
    • SaveChangesAsync (DbContextFactory.cs:271-319): reads and immediately clears the explicit-key-insert flag (:272), then loops at most MaxSavePasses times over the contexts it has not yet saved (:237-251), passing _currentUserService.UserId into each context's audit-aware SaveChangesAsync overload (:247-249). The re-loop exists because saving dispatches domain events in-process, and a handler that resolves a repository for a source nobody had touched yet calls GetDbContext mid-enumeration (:233-236). After the loop it asserts that no cached context still has changes and throws an InvalidOperationException naming the offending sources if any does (:259-270), because anything still tracked when the unit of work returns would be silently lost. The comment at :253-258 explains why the assertion reads the change tracker rather than the saved set: it must catch both a context materialized past the pass bound and a handler that dirtied an already-saved context.
    • SaveChanges (DbContextFactory.cs:450-458): the synchronous path, a single pass over a snapshot of the cached contexts with no re-loop.
    • Explicit-key insert path (DbContextFactory.cs:293-295, :329-440): a context takes it only when the flag is set AND context.Engine.ExplicitKeyInsert returns an IExplicitKeyInsertDialect (SQL Server's SET IDENTITY_INSERT); every other engine saves normally. SaveWithExplicitKeyInsertAsync (:329) asks the dialect for the ExplicitKeyInsertGroups and opens the connection itself when it is not already open, because the toggle is session state and without an ambient transaction EF would otherwise return the connection to the pool between the toggle and the INSERT; an already-open connection is left as found. SaveExplicitKeyGroupsAsync (:377) then saves one table per round with the toggle SQL built by dialect.BuildToggleSql. Before saving, the groups are reordered by ExplicitKeyInsertRoundOrder.Order (:341-345), because the change tracker's order is the order rows were added and says nothing about foreign keys; the result is one round per table, principals first.
    • BeginTransaction / CommitTransaction / RollbackTransaction (DbContextFactory.cs:460-495): each filters to contexts that support transactions and, symmetrically, to those that do or do not already carry one (:426, :433, :440), because EF throws on a second BeginTransaction for the same connection and GetDbContext may already have enlisted a late-created context (:422-425). Commit snapshots the committed contexts and then calls EnrolledCommandWake.Release on them (:475-481): internal commands enrolled during the transaction are durable only now, so this is the first moment waking the processor finds them. Rollback additionally calls DropDeferredWork on every context (:493-494), which drops both the deferred in-process dispatch (DomainEventSaveChangesInterceptor.DropDeferred) and the owed processor wake (EnrolledCommandWake.Drop, :850-853): the aggregate changes and their outbox rows just rolled back, so neither may run, and neither must survive into a retry.
    • ExecuteInTransactionAsync<TResult> (DbContextFactory.cs:551-596): re-entrancy first, a nested call simply runs the operation on the ambient transaction (:510-511), because an inner commit would make the outer scope's earlier work durable ahead of its own decision (:503-509). Otherwise it picks the execution strategy from the first transaction-capable context, materializing the Default source through the resolver if none exists yet (:519-520, deliberately resolved rather than taken literally so a host with no SQL Server connection does not open a connection string that does not exist, :513-518), and runs the attempt under strategy.ExecuteAsync (:526-534), calling ResetForRetry before every attempt after the first (:528-529).
    • RunTransactionalAttemptAsync (DbContextFactory.cs:606-669): one attempt, begin to commit. A failed Result rolls back and returns (:563-570), which is what makes ADR-013's Result-over-exceptions rule safe for partial persistence. On success it commits through TryCommit and only then flushes the deferred dispatch on every context, snapshotting the dictionary first because a handler can still materialize a new source (:576-583). Between the commit and that flush it releases the internal-command wake for every context (EnrolledCommandWake.Release, :634), deliberately before the event flush so a throwing in-process handler cannot swallow it. A cancellation attempts a best-effort rollback and, if even that throws, clears the flag and drops every deferred dispatch by hand (:587-603); any other exception rolls back and rethrows (:604-608).
    • TryCommit (DbContextFactory.cs:719-751) and AbandonAfterCommitFailure (:662-683): a commit failure is returned, not thrown (:646). TryCommit snapshots the enlisted contexts first, because that order is the commit order (:625-630), then commits them one by one, accumulating the successes; on a throw it names the failing source, treats everything past it in the snapshot as rolled back (:644), and hands all three groups to TransactionCommitAmbiguousException. AbandonAfterCommitFailure rolls back whatever has not committed yet, drops every deferred dispatch and owed wake (DropDeferredWork, :766), and swallows secondary rollback failures so the commit ambiguity stays the reported failure (:666-682).
    • ResetForRetry (DbContextFactory.cs:836-843): before the strategy re-runs the operation it drops deferred work (DropDeferredWork, :840: dispatch and the owed wake) and calls ChangeTracker.Clear() on every context, so entities the aborted attempt added are not inserted a second time (with a duplicate outbox row per event).
    • SupportsTransactions (DbContextFactory.cs:860-861): context.Engine.Capabilities.IsRelational, so the Cosmos "no multi-document transactions" fact is read off the engine object rather than a type test; HasActiveTransaction (:830-831) is Database.CurrentTransaction is not null.
    • Disposal (DbContextFactory.cs:866-896): Dispose and DisposeAsync both dispose every cached context, clear the dictionary, set _disposed, and suppress finalization.
  • Why it's built this way: ADR-006 makes "one scope, several databases" the normal case, so somebody has to own the cross-context bookkeeping; putting it here keeps handlers writing SaveChangesAsync exactly as they would against a single database, and ADR-073 then reuses that one chokepoint for per-tenant routing. The five subtleties (bounded re-loop plus the unsaved assertion, deferred dispatch released only after commit, a commit failure exempted from retry and reported per source, the routed-tenant guard, and the re-entrancy short circuit) are each a correctness fix with the reasoning written into the source next to the code.
  • Where it's used: registered scoped as IDbContextFactory (DependencyInjection.cs:104) and consumed through that interface everywhere (see the interface's section). Directly instantiated in tests, for example MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/DbContextFactoryCommitAmbiguityTests.cs:55 and :195; the save-loop invariants are pinned by DbContextFactorySaveIntegrityTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/DbContextFactorySaveIntegrityTests.cs:82, :101, :120), which assert that a handler mutating an already-saved context throws naming that context, that an extra read-only context does not, and that a clean scope returns the written count.
  • Caveats / not-in-source: the MaxSavePasses bound of 3 is documented as "two passes cover the realistic case, the third is slack" (DbContextFactory.cs:59-63); whether any production workload has ever needed the third pass is Not determinable from source. The routed-tenant guard also implies a usage rule the code can only enforce after the fact: a scope serves one tenant, and switching tenants means a fresh scope (:185-192).

CrossTenantWriteException

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/CrossTenantWriteException.cs:24 · Level 0 · class (sealed, exception)

  • What it is: the single failure type of the write side of multi-tenancy. It is thrown when a save would insert, update or delete a row owned by a tenant other than the one the current scope resolved, or would insert an untenanted row from a scope that resolved no tenant at all (CrossTenantWriteException.cs:5-9).
  • Depends on: the BCL only (InvalidOperationException, string.Format with CultureInfo.InvariantCulture). It is thrown exclusively by TenantSaveChangesInterceptor; it does not reference it.
  • Concept introduced, why isolation fails loudly on the write side and silently on the read side. [Rubric §11, Security] assesses whether authorization boundaries are enforced by construction rather than by caller discipline, and [Rubric §30, Compliance and Data Governance] assesses whether tenant data separation is demonstrable. Read isolation is a query filter, and a filter that disagrees with the caller simply returns nothing: a wrong tenant sees an empty list, which is the correct and harmless outcome. A save has no equivalent harmless outcome. By the time the interceptor sees the entry, the row is about to be written, and the only honest results are "the correct tenant" or "no write at all", so this type throws instead of filtering (CrossTenantWriteException.cs:11-18). The message names the entity type and both tenants because the realistic cause is a scope that was never given a tenant (a background job, a queued handler) rather than a genuine attempt to cross the boundary.
  • Walkthrough:
    • DefaultMessage (:26-27): the parameterless-constructor text, "The save would write across the tenant boundary and was rejected."
    • The three public constructors (:30-31, :35-36, :41-42): parameterless, message, and message-plus-inner. They exist so the type satisfies the standard exception shape the analyzers require, even though the framework itself never calls them.
    • The private constructor (:44-54): the one the factories use. It takes the formatted message plus the three diagnostic values and assigns them to the properties.
    • EntityType, CurrentTenantId, EntityTenantId (:57, :62, :68): all string?, all get-only. A catch site can branch on the data instead of parsing the message. CurrentTenantId is null exactly when the scope resolved no tenant; EntityTenantId is null when the entity carried none.
    • ForMismatch(entityType, operation, currentTenantId, entityTenantId) (:78-94): builds the "the entity belongs to tenant X but this scope resolved tenant Y" message. operation is the literal insert, update or delete passed by the interceptor, so one factory covers all three write shapes.
    • ForUnresolvedTenant(entityType) (:101-111): builds the message for the other failure, an untenanted insert from an untenanted scope, and names the two ways out in the message itself: resolve a tenant for the scope via ITenantContext.SetTenant, or assign the tenant on the entity before saving. Both tenant ids are passed as null.
  • Why it's built this way: it derives from InvalidOperationException (:19-22) so existing handler and middleware catch sites treat it exactly as they treat the framework's other save-time invariant failures, with no new catch clause anywhere. Both factories are internal, which keeps the interceptor the only thing that can produce a populated instance while leaving the type public for consumers to catch. See ADR-073 for the multi-tenancy model this enforces.
  • Where it's used: thrown at three call sites in TenantSaveChangesInterceptor (TenantSaveChangesInterceptor.cs:110 for the unresolved-tenant insert, :123 for a mismatched insert, :150-151 for a mismatched update or delete); asserted by TenantSaveChangesInterceptorTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Tenancy/TenantSaveChangesInterceptorTests.cs:12, with the unresolved-tenant case at :34, the mismatched insert at :59, and the mismatched update and delete at :73 and :90).

EncryptedStringConverter

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Encryption · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Encryption/EncryptedStringConverter.cs:72 · Level 0 · class (sealed)

  • What it is: an EF Core ValueConverter<string, string> that encrypts a string property with AES-256-GCM on the way to the database and decrypts it on the way back, transparently to the entity (EncryptedStringConverter.cs:8-11). It is applied per property in an entity configuration, not globally (:12-18), and the value it stores is a versioned envelope: the key it decrypts with is named by a byte inside the stored value itself, not by whatever key the converter was built with.
  • Depends on: System.Security.Cryptography (AesGcm, RandomNumberGenerator, CryptographicException), System.Collections.Frozen (FrozenDictionary, :1), System.Text.Encoding, and EF Core's ValueConverter<TModel, TProvider>. No first-party type.
  • Concept introduced, authenticated encryption at rest, and what it costs you. [Rubric §11, Security] (assesses whether sensitive data is protected in transit and at rest with sound primitives) and [Rubric §30, Compliance, Privacy & Data Governance] (assesses whether personal data is classified and handled deliberately). AES-GCM is an authenticated mode: it gives confidentiality and integrity in one pass, so tampering with a stored value makes decryption throw instead of silently yielding garbage, and no separate HMAC step is needed. The price is stated at length in the class comment (:19-32) and is the part worth internalizing: every write draws a fresh random nonce (:193), so the same plaintext produces a different column value each time. A non-deterministic column cannot carry an equality or range predicate (the comparison is against a ciphertext that never matches, and the query returns no rows rather than failing), cannot carry a unique index, and cannot be sorted or grouped server side. Anything that must stay searchable needs a second, deterministic surface such as a keyed hash beside the encrypted column. This is the counterpart to the erasure story taught by IAnonymizable and PiiAttribute (ADR-005): erasure overwrites a field you never need again, encryption protects one you still have to read back.
  • Concept introduced, a self-describing envelope is what makes key rotation possible. [Rubric §8, Data Architecture] (assesses whether persistence mechanics are deliberate) and again [Rubric §11, Security]. A stored value is Base64 of [key version (1)] [nonce (12)] [ciphertext (N)] [tag (16)] (:38-44, laid out at :203-208), so the row carries everything needed to read it back except the key material: 29 bytes of overhead before Base64 inflation (:75, :78, :81). Two properties fall out of that one byte. First, a converter can hold a whole key ring keyed by version with one version nominated as current (:109): writes stamp the current version (:116, :205), reads take their key from the version byte in the data (:223-224), so a rotation is add the new key as current, deploy, re-encrypt rows in the background, then retire the old version, with no maintenance window and no rows left unreadable in between (:45-61). Second, the version byte is authenticated, not merely stored: it is passed to AES-GCM as associated data on both sides (:198, :231), so rewriting it fails the tag check rather than silently selecting another key, and it fails even when the substituted version happens to map to the same key. That last property is the one a "just prefix the version" design usually misses.
  • Walkthrough:
    • Sizes and the default version (EncryptedStringConverter.cs:75, :78, :81, :84, :87): VersionSize = 1, NonceSize = 12 bytes (96 bits, the NIST recommendation for GCM), TagSize = 16 bytes (128 bits), KeySize = 32 bytes, and DefaultKeyVersion = 1, the version the single-key constructor stamps.
    • Two public constructors over one private one (:94-97, :109-112, :114-119): the byte[] constructor is sugar for a one-entry ring at version 1, built by CreateSingleKeyRing (:95, :127-138), which null-guards the key (:129) and rejects anything that is not exactly 32 bytes with a message naming the length it got (:130-135). The ring constructor takes an IReadOnlyDictionary<byte, byte[]> plus the version to write with. Both funnel into the private constructor, which passes the two lambdas up to ValueConverter, Encrypt as the to-provider direction bound to the current version and Decrypt as the from-provider direction (:116-117). The frozen ring is captured by those lambdas.
    • ValidateAndFreeze (:146-182): the ring is validated once, at construction. Not null (:150), not empty (:152-155), no null entry (:159-164), every key exactly 32 bytes (:166-171), and the nominated current version actually present (:174-179), which is what lets Encrypt index the ring without a lookup guard (:189-190). Then ToFrozenDictionary() (:181) takes a defensive copy, so mutating the dictionary the caller passed in afterwards cannot change which keys the converter uses (:140-145).
    • GenerateKey() (:125): RandomNumberGenerator.GetBytes(32), the convenience path for producing a valid key during setup.
    • Encrypt (:184-211): empty and null pass through unchanged (:186-187), otherwise resolve the current key (:190), UTF-8 encode (:192), draw a 12-byte nonce (:193), and encrypt into a same-length ciphertext buffer with a 16-byte tag, handing the single version byte over as associated data (:194-201). The four regions are then laid into one buffer in envelope order (:203-208) and Base64-encoded (:210). Storing the version and nonce alongside the ciphertext is what makes each row self-describing: no side table of nonces and no out-of-band record of which key wrote which row.
    • Decrypt (:213-239): Base64 decode (:218), reject anything shorter than version plus nonce plus tag with a CryptographicException (:220-221), read the version from position 0 and fail with a message naming only the version number (never key material) when the ring has no key for it (:223-225), then slice nonce, ciphertext, tag, and the associated-data byte by fixed offsets and decrypt (:227-236). A wrong key, a tampered ciphertext byte, and a rewritten version byte all fail inside AesGcm.Decrypt, which is the integrity guarantee doing its job.
  • Why it's built this way: GCM over CBC removes the "encrypt then MAC" bookkeeping that is easy to get wrong, and putting the whole scheme behind a ValueConverter means an entity property stays a plain string in the domain model. ADR-037 records the decision and is explicit that this is a second layer above transparent database encryption: TDE decrypts for anyone who can query, this converter keeps the value ciphertext the moment it leaves the application. The key never lives in the converter's own configuration: the comment (:33-37) points at Key Vault, user-secrets, or environment variables. The converter is also deliberately stateless and context-free (:62-70): version resolution is data-driven from the envelope and nothing consults the DbContext, the current user, or any ambient scope, because a value converter is a pair of compiled expressions running in the provider's materialization path and cannot reach them. Per-tenant or per-request key selection is therefore out of scope here by design and needs a SaveChanges interceptor or application-layer encryption above EF Core.
  • Where it's used: nowhere in application code today. A workspace-wide search of *.cs for the type finds only its own file, its 21 unit tests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Encryption/EncryptedStringConverterTests.cs:6, covering the plaintext round trip at :10, non-determinism at :24 and :38, key generation at :52 and :61, the invalid-length and null-key guards at :71 and :123, the empty-string passthrough at :82 and :95, the too-short value at :108, Unicode at :129, the version byte the single-key constructor stamps at :145, a key-ring round trip at :157, a full rotation in which pre-rotation ciphertext stays readable while new writes carry the new version at :175, an unregistered version at :205, the tampered-version-byte failure at :226, the four ring-validation guards at :244, :250, :259, and :268, and the defensive copy of the caller's dictionary at :281), and one prose mention in the IAnonymizable doc comment (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAnonymizable.cs:17-19) recommending it for personal fields that must survive erasure in readable form. No entity configuration calls HasConversion(new EncryptedStringConverter(...)) in any of the repos, and no DI registration supplies a key or a ring.
  • Caveats / not-in-source: this is therefore a shipped but unadopted extension point, a posture ADR-037 states outright and still records as zero adoption after the versioned envelope shipped in MMCA.Common v1.153.0. Zero adoption is also what made the format change affordable: there is no legacy decode path, so a value written in the earlier un-versioned [nonce] [ciphertext] [tag] layout does not read back under the current converter (its first byte is a nonce byte, not a version), and the window in which the envelope is free to change closes at the first adopted column. Three further limits are worth knowing before adopting it. The version space is a single byte (:75), ample for annual or quarterly rotation but wrapping rather than growing, and a reused version number is exactly the ambiguity the byte exists to prevent. The suite covers the short-value, unregistered-version, and rewritten-version failures but never flips a bit inside the ciphertext body or decrypts under a wrong key at the same version, so integrity over the ciphertext rests on the primitive rather than on a test. And the searchability constraint stands: the Identity User stores Email as a queried column (see IdentityModuleDbSeederBase<TUser>, whose existence check is an EF predicate on that column), so encrypting it with this converter would silently break that lookup rather than fail loudly.

IDbSeeder

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Seeding · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/IDbSeeder.cs:7 · Level 0 · interface

  • What it is: the one-method contract for an Infrastructure-layer seeder, Task SeedAsync(CancellationToken) (IDbSeeder.cs:13). Implementations populate a module's tables with initial reference data at startup (IDbSeeder.cs:3-5).
  • Depends on: nothing but the BCL Task and CancellationToken.
  • Concept introduced, two seeding contracts at two layers. [Rubric §3, Clean Architecture] (assesses whether each concern sits in the layer that owns it, with dependencies pointing inward): the framework has two seeding interfaces and they are not competitors. IModuleSeeder lives in Application, takes an IServiceProvider, and is the unit ModuleLoader knows about; IDbSeeder lives in Infrastructure, takes nothing, and is the unit that actually writes rows. The apps compose them: the module seeder reads configuration, resolves what it needs, and calls the database seeder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:33-36). That keeps the configuration gate in the layer that can see IConfiguration and the row writing in the layer that can see the unit of work.
  • Walkthrough: a single member. There is no IsEnabled, no ordering property, and no result type: a seeder either runs to completion or throws, and ordering comes from the caller.
  • Why it's built this way: keeping the Infrastructure contract this thin is what lets the same seeder be invoked from a module seeder, from a test, or by hand, without dragging a service provider along.
  • Where it's used: implemented once in the framework, by the abstract DbSeeder (DbSeeder.cs:7); every concrete seeder in the apps derives from that class rather than from this interface directly.
  • Caveats / not-in-source: nothing registers IDbSeeder in DI and nothing resolves it. Concrete seeders are constructed with new inside their module's IModuleSeeder (for example IdentityModuleSeeder.cs:35-36), which ModuleLoader runs through SeedAllAsync at startup, after schema initialization (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:137). There is no reflection-based discovery of IDbSeeder and no hosted service that drains a list of them.

LocalLease

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:465 · Level 0 · record struct (private readonly, nested)

  • What it is: a two-field value, Until (a DateTime) and Token (a Guid), describing the lease one save's in-process outbox rows are inserted under (DomainEventSaveChangesInterceptor.cs:462-465). Until is when the lease expires and pollers may take the rows; Token is what the release after a failed dispatch is guarded by.
  • Depends on: nothing beyond the BCL (DateTime, Guid). It is where CapturedState gets its LocalLeaseToken from, and the argument DomainEventSaveChangesInterceptor hands to CaptureEventsAndPersistToOutbox (:228).
  • Concept introduced, a lease is a deadline plus an identity. [Rubric §29, Resilience and Business Continuity] assesses whether a replica that dies mid-work leaves recoverable state. A local event's outbox row is written, then dispatched in-process, then stamped processed. In a multi-replica host another replica's OutboxProcessor could poll the row in the gap and deliver the event a second time. The lease closes that gap: the row is inserted already locked until Until (:310-311), so every poller skips it while this process handles the event. The Token makes the later release safe, because it clears the lock only on rows still carrying this save's token (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxFinalizer.cs:79), never a row a processor has since claimed under its own.
  • Walkthrough:
    • Creation (NewLocalLease, :315-316): Until is the injected TimeProvider now plus _localLeaseSeconds (:66), read once from Outbox:LeaseSeconds (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxSettings.cs:85, default 300); Token is a fresh Guid.NewGuid().
    • Application (ApplyLocalLease, :305-312): copies the pair onto OutboxMessage.LockedUntil and LockToken. A null lease returns without touching the row.
    • Absence (:111): the synchronous SavingChanges passes localLease: null, because that path never dispatches in-process, so its rows belong to the processor from the start.
  • Why it's built this way: a readonly record struct is a value that lives for one capture and is never stored on the singleton interceptor, which keeps the singleton free of per-save state. private keeps the lease an implementation detail; the only trace that outlives the capture is the Token, copied into CapturedState.
  • Where it's used: created per async save (:98), threaded through CaptureEventsAndPersistToOutbox (:228, :280, :300), and deliberately not applied to integration-event rows, which nothing in-process delivers (:276-283).

SeedAccount

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Seeding · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/SeedAccount.cs:17 · Level 0 · record (sealed)

  • What it is: the five-member positional record describing one development or test account for IdentityModuleDbSeederBase<TUser> to create: (string Email, string Password, string Role, string? FirstName = null, string? LastName = null) (SeedAccount.cs:17-22).
  • Depends on: nothing. It is a pure data carrier with no framework types in its signature, which is what lets each app spell its own role vocabulary as a plain string.
  • Concept introduced: none new. It is the parameter object of the template-method seeder taught in IdentityModuleDbSeederBase<TUser>; [Rubric §11, Security] applies only through the notice below.
  • Walkthrough: the doc comments carry the contract for each member. Email is also the idempotency key, the value the "already seeded?" check compares against (:12). Password is plaintext and is hashed by the seeder before persistence (:13). Role is the role as the app's own vocabulary spells it (:14), so ADC passes UserRole.Organizer.Value and Store passes its own admin role without the framework knowing either. FirstName and LastName are nullable because not every app's User carries them (:15-16).
  • Why it's built this way: a record rather than a tuple gives the five values names at every call site, and positional construction keeps an account list readable as a literal array (see the app lists cited below).
  • Where it's used: the abstract Accounts property of IdentityModuleDbSeederBase<TUser> (IdentityModuleDbSeederBase.cs:51); supplied by ADC's IdentityModuleDbSeeder as three accounts (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:34-39) and by Store's through a StoreAccounts array (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:27, :34).
  • Caveats / not-in-source: the record's own remarks (SeedAccount.cs:6-11) call the security property out: seed credentials are plaintext by construction, so an account list is development-only data that must be gated or replaced with environment-sourced secrets before a seeder runs in a deployed environment. Both apps' lists contain deliberately weak passwords.

DbSeeder

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Seeding · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/DbSeeder.cs:7 · Level 1 · class (abstract)

  • What it is: the abstract base every concrete database seeder derives from. It implements IDbSeeder by re-declaring SeedAsync as abstract (DbSeeder.cs:10) and adds exactly one piece of shared machinery: a converter from an int seed id to whatever identifier type the module uses (DbSeeder.cs:3-5).
  • Depends on: IDbSeeder, plus the BCL Guid, BitConverter, and Span<byte>.
  • Concept introduced, seed data across two identifier strategies. [Rubric §15, Best Practices & Code Quality] (assesses whether shared mechanics live in one place rather than being restated per module): the identifier-type alias taught in the primer means one module's key is int and another's is Guid (ADC's SpeakerIdentifierType is a Guid, its UserIdentifierType is an int). Seed data, however, is naturally written with small readable literals: category 1, room 2. GetId<T>(int) is the bridge, so a seeder can write GetId<SpeakerIdentifierType>(3) and stay correct whichever alias the module picked.
  • Walkthrough:
    • SeedAsync (DbSeeder.cs:10): abstract, public, no default behavior. The base deliberately does not template the seeding flow itself; that is IdentityModuleDbSeederBase<TUser>'s job for the one flow that repeated across apps.
    • GetId<TIdentifier>(int id) (DbSeeder.cs:20-39): protected static, constrained to notnull. For Guid it writes the int's four bytes into the start of a zeroed 16-byte stackalloc span and constructs a Guid from it (:23-31), which is deterministic: the same seed integer always produces the same Guid, so re-running a seeder against an existing database matches the rows it wrote last time. For int it is a boxed pass-through (:33-36). Anything else throws NotSupportedException naming the type (:38).
  • Why it's built this way: protected static keeps the helper out of the public surface (a seeder is not a general-purpose id converter) while still allowing every derived seeder to use it without an instance. Determinism, not uniqueness, is the property that matters: seeders must be idempotent across restarts, which is what production hosts rely on when they run the seeder on every boot (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:137).
  • Where it's used: the base of every module seeder in both apps, for example ADC's ConferenceModuleDbSeeder (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:34), Store's CatalogModuleDbSeeder (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Infrastructure/Persistence/DbContexts/Seeding/CatalogModuleDbSeeder.cs:15) and SalesModuleDbSeeder (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Persistence/DbContexts/Seeding/SalesModuleDbSeeder.cs:27), and the framework's own IdentityModuleDbSeederBase<TUser> (IdentityModuleDbSeederBase.cs:39-42). Pinned by DbSeederTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/Seeding/DbSeederTests.cs:6, :9, :17, :27, :36, :45), which assert the int pass-through, Guid determinism, distinctness across different ints, the unsupported-type throw, and that SeedAsync can be implemented.
  • Caveats / not-in-source: the Guid mapping consumes only the first four of sixteen bytes, so the produced values are structurally recognizable rather than random. That is intentional for seed data and is not a source of production ids: entity ids come from the database or from CosmosIntIdValueGenerator.

AggregateCapture

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:445 · Level 2 · record (private sealed, nested)

  • What it is: a two-field pairing of one tracked aggregate root and the exact array of domain events snapshotted from it for the current save (DomainEventSaveChangesInterceptor.cs:442-447).
  • Depends on: EF Core's EntityEntry<IAggregateRoot> and IDomainEvent via IAggregateRoot.
  • Concept introduced, snapshot-then-remove instead of clear. The naive move after dispatching events is entity.ClearDomainEvents(). That is wrong here, and this record is the fix. An in-process handler running during dispatch can raise a new event on the same aggregate; a wholesale clear would wipe that new event before any later capture could see it, so it would never dispatch and never reach the outbox. Holding the exact snapshot lets the interceptor remove precisely what it captured and leave everything else in place (DomainEventSaveChangesInterceptor.cs:424-434).
  • Walkthrough:
    • Entry (:373): the EntityEntry<IAggregateRoot>, not the bare entity. Keeping the entry means the record still has EF's view of the aggregate available if the flush path ever needs it, and capture.Entry.Entity reaches the aggregate itself (:363).
    • Events (:374): an IDomainEvent[] materialized with a collection expression at capture time (:223, [.. e.Entity.DomainEvents]), which is what makes it a snapshot rather than a live view of the aggregate's mutable list.
  • Why it's built this way: a positional record gives value semantics and an immutable pair for free, and private sealed keeps it invisible outside the interceptor. It is data, not behavior: the only method that touches it is ClearDomainEvents, which calls capture.Entry.Entity.RemoveDomainEvents(capture.Events) on each (:362-363, against the contract member at MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAggregateRoot.cs:32).
  • Where it's used: constructed once per event-carrying aggregate in CaptureEventsAndPersistToOutbox (:220-224), stored inside CapturedState (:382), and consumed by ClearDomainEvents (:360-364).

OwnedDependents

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/OwnedDependents.cs:12 · Level 2 · class (internal, static)

  • What it is: a single-purpose helper that answers one question about a tracked owner entry: is any owned reference or owned collection item of it being inserted, updated or deleted in this save? (OwnedDependents.cs:14-26).
  • Depends on: EF Core's EntityEntry, ReferenceEntry, CollectionEntry and EntityState only. No MMCA types.
  • Concept introduced, an owner can look Unchanged while its row is being rewritten. EF tracks an edit inside an owned value object (an OwnsOne address, an OwnsMoney amount) on the owned entry and leaves the owner's own state Unchanged, even though the owned columns live in the owner's table and the row is updated. Any interceptor that decides from entry.State alone therefore misses the update. [Rubric §8, Data Architecture] assesses whether audit guarantees hold at the schema level, and this is the case that breaks "every row knows who wrote it and when" for value-object edits.
  • Walkthrough:
    • HaveChanges (:24-26): true when any reference navigation passes IsChangedOwnedReference or any collection navigation passes HasChangedOwnedItem.
    • IsChangedOwnedReference (:29-31): the navigation must target an owned type (Metadata.TargetEntityType.IsOwned()) and its TargetEntry must be Added, Modified or Deleted.
    • HasChangedOwnedItem (:34-37): the collection must target an owned type, and any non-null item whose context.Entry(item).State differs from Unchanged counts.
    • Known blind spot (:18-21): an optional owned reference set to null leaves no target entry to read, so clearing one is not detected; replacing or editing one is.
  • Why it's built this way: one shared walk keeps the two interceptors that need it from drifting into two slightly different definitions of "the owner changed". It is internal static because it is plumbing between sibling interceptors, not a public extension point.
  • Where it's used: AuditSaveChangesInterceptor promotes an Unchanged owner with owned changes to Modified before stamping (AuditSaveChangesInterceptor.cs:73), and AuditTrailSaveChangesInterceptor treats such an entry as auditable (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:237).

CapturedState

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:455 · Level 10 · record (private sealed, nested)

  • What it is: the whole of what DomainEventSaveChangesInterceptor learns before a save and needs again after it: which aggregates it captured, which events it will dispatch in process, which outbox rows back those events, whether any integration events are in the batch, and the token the local rows were leased under (DomainEventSaveChangesInterceptor.cs:447-460).
  • Depends on: AggregateCapture, IDomainEvent, OutboxMessage; its lease token comes from LocalLease.
  • Concept introduced, why per-save state cannot live in a field. The interceptor is registered as a singleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:65), and one singleton serves every context in every scope concurrently. Anything it remembers between SavingChanges and SavedChanges therefore has to be keyed by the context, not stored on the instance. That is exactly what this record is: the value side of a ConditionalWeakTable<DbContext, CapturedState> (:73). [Rubric §12, Performance and Scalability] assesses whether shared components stay safe and cheap under concurrency; the weak table adds no lock and no lifetime bookkeeping, because an entry disappears when its context is collected.
  • Walkthrough (all five members are positional and immutable):
    • Captures (:456): the AggregateCapture[], used only to remove exactly the captured events afterwards.
    • LocalEvents (:457): the events that get in-process dispatch. On a context that writes outbox rows this deliberately excludes every IIntegrationEvent (:270-283); on a context without outbox support, or in a host that turned the outbox off, it is simply every captured event (:297).
    • LocalOutboxEntries (:458): the List<OutboxMessage> rows backing LocalEvents, in the same order they were added. After a successful dispatch these are the rows stamped processed (:378); after a failed one they are the rows whose lease is released (:415).
    • HasIntegrationEvents (:459): a bool rather than a second list, because integration events are never dispatched here. The only thing the flush needs to know is whether to wake the outbox processor (:380-381).
    • LocalLeaseToken (:460): a nullable Guid, the token LocalOutboxEntries were leased under. It is null when the rows were inserted unleased (the synchronous path, :111), and ReleaseLocalLeaseAsync returns early on null (:410).
  • Why it's built this way: splitting local events from integration events at capture time, and recording the split in this one value, is what makes AddDomainEvent(integrationEvent) broker-correct (ADR-003). Before this routing existed, an integration event was dispatched locally and its row marked processed, so it silently never reached the wire (:24-29). Carrying only the lease token, not the whole lease, is enough: the expiry is already on the rows, and the token is all the guarded release needs.
  • Where it's used: created at the end of CaptureEventsAndPersistToOutbox (:300-301), read by DispatchAndFinalizeAsync (:348), carried across a commit boundary inside DeferredDispatch (:358), consumed by FlushStateAsync (:369-399) and ReleaseLocalLeaseAsync (:408-422), and read on the synchronous path by SavedChanges (:141-149).

AuditSaveChangesInterceptor

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:38 · Level 11 · class (sealed)

  • What it is: the EF Core interceptor that stamps CreatedOn/CreatedBy, LastModifiedOn/LastModifiedBy and DeletedOn/DeletedBy on every IAuditableEntity entry immediately before the write (AuditSaveChangesInterceptor.cs:10-36). On PostgreSQL and SQLite it also writes the RowVersion optimistic-concurrency token, because neither engine generates one server-side (:21-27). It is the first of the interceptors the framework installs and the reason no handler anywhere in ADC or Store sets an audit field by hand.
  • Depends on: ApplicationDbContext (for CurrentSaveUserId, the engine capabilities and the change tracker), IAuditableEntity, IRowVersioned (only for the RowVersion property name), RowVersionStrategy read off DataSourceEngineCapabilities, OwnedDependents (to see edits confined to an owned value object), and the BCL TimeProvider, injected through the primary constructor (:38).
  • Concept introduced, the EF SaveChangesInterceptor as the cross-cutting hook of the persistence layer. [Rubric §13, Observability & Operability] assesses whether audit, correlation and logging are wired once centrally rather than repeated per handler. An EF SaveChangesInterceptor is a hook EF calls at fixed points of the save pipeline: SavingChanges/SavingChangesAsync before the write and SavedChanges/SavedChangesAsync after it. Choosing an interceptor over an override of SaveChangesAsync buys three things: the logic runs on the synchronous and asynchronous paths alike, several interceptors compose in a defined order rather than fighting over one method body, and the concern lives in one class the module authors never see. [Rubric §8, Data Architecture] is engaged too, because "every row knows who wrote it and when" is a schema-level guarantee here, not a convention.
  • Concept introduced, stamping a transition rather than a value. The soft-delete stamps are not driven by what IsDeleted is but by whether it changed during this save (:14-20). That distinction is what makes the delete stamp behave like the creation stamp: it is written once, by the save that flipped the flag, and every later update of the same already-deleted row leaves it untouched. Reading the flag's value instead would rewrite DeletedOn on every subsequent write to a deleted row, which destroys the one fact the column exists to record.
  • Concept introduced, an owner is modified when its owned value object is. EF tracks an edit inside an owned value object (an OwnsOne address, an OwnsMoney amount) on the owned entry and leaves the owner Unchanged, so a state switch alone would skip the row even though it is rewritten. Without the owner-side walk the row would be saved with stale LastModifiedOn/By and, on PostgreSQL and SQLite, an unchanged RowVersion (:28-35).
  • Walkthrough:
    • SavingChangesAsync (:40-50) and SavingChanges (:52-61): identical two-line bodies. Each pattern-matches eventData.Context against ApplicationDbContext, calls StampAuditFields, then delegates to base. The type test is the guard: a context that is not the framework's own is left completely alone.
    • StampAuditFields (:63-106): reads the clock once per save via timeProvider.GetUtcNow().UtcDateTime (:65) so every row in one save carries the same instant, and resolves the user once as context.CurrentSaveUserId ?? default (:66). CurrentSaveUserId is the nullable user id the context was handed for this save (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:184, set at :197 and :232), so default is the sentinel for a system-originated write with no user behind it. It also decides once per save whether this engine needs a client-written row version: context.Engine.Capabilities.RowVersion == RowVersionStrategy.ClientStamped (:67), which is true for the PostgreSQL and SQLite engines (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/PostgreSQLDataSourceEngine.cs:44, SqliteDataSourceEngine.cs:43; the enum member is at RowVersionStrategy.cs:17).
    • The owner-side walk (:71-75): before the switch, an entry that is Unchanged but for which OwnedDependents.HaveChanges(entry) is true is treated as Modified; every other entry keeps its own state. Everything below then branches on that effective state, not on entry.State (:77). Clearing an optional owned reference to null leaves no owned entry to see and is not stamped (:33-34).
    • The Added branch (:79-89): sets all four creation and modification properties through entry.Property(nameof(...)).CurrentValue, going through the change tracker rather than the CLR setters so the fields can stay init-only or privately settable on the entity, then calls StampSoftDeleteTransition with wasDeleted: false and StampRowVersion. A brand new row has no prior state, so an entity inserted already soft-deleted still gets its delete stamp (:85-88).
    • The Modified branch (:90-98): the interesting one. It sets LastModifiedBy/ LastModifiedOn, and explicitly marks CreatedBy and CreatedOn as IsModified = false (:91-92). That is the invariant: an update can never rewrite creation provenance, even if the caller mutated those properties on a tracked instance. It then runs the same soft-delete transition check, this time against the flag's stored value (:96), and stamps a fresh row version (:97).
    • Detached, Unchanged, Deleted and the default (:99-103): deliberate no-ops. Note what Deleted means here: a hard delete, which the framework does not use for auditable entities. A soft delete arrives as Modified, because the entity only set a flag (see ADR-005 for soft delete versus erasure).
    • StampRowVersion (:113-119): when the engine is client-stamped and the entity's model has a RowVersion property (entry.Metadata.FindProperty, :115), it writes Guid.NewGuid().ToByteArray() as the property's current value (:117). Setting the current value marks the property modified while EF keeps the loaded value as the original, so the UPDATE still carries the old token in its WHERE clause and a concurrent writer is detected (:108-111). On SQL Server the rowversion column is database-generated and this method writes nothing.
    • WasDeleted (:122-123): the flag as the database has it, read from the property's OriginalValue. The is true test rather than a cast is what keeps an unset or shadow value from throwing.
    • StampSoftDeleteTransition (:130-144): compares the current flag against the prior one and returns immediately when they agree (:137-140), so no transition means no write at all. On a transition it writes both stamps in one direction: the resolved user and the save's instant on a delete, and null on both columns on an undelete (:142-143).
  • Why it's built this way: taking TimeProvider instead of calling DateTime.UtcNow makes the stamps deterministic under test ([Rubric §14, Testability]), which is what AuditSaveChangesInterceptorTests relies on (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Interceptors/AuditSaveChangesInterceptorTests.cs:13, with the soft-delete transitions pinned at :149 (delete stamps written), :165 (an update after a delete keeps the original stamp), :186 (undelete clears both), :207 (an active entity keeps them null) and :216 (an insert that is already deleted is stamped), and the client-stamped row version on SQLite at :76). The class is registered as a singleton because it holds no per-save state at all (DependencyInjection.cs:64).
  • Where it's used: resolved from DI and attached in ApplicationDbContext.OnConfiguring (ApplicationDbContext.cs:297, added at :307 or :311). It is always first in the interceptor chain, which is what lets TenantSaveChangesInterceptor, the domain-event interceptor and the optional AuditTrailSaveChangesInterceptor assume the audit stamps are already final when they run (ApplicationDbContext.cs:300-304, :314-318).

DeferredDispatch

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:468 · Level 11 · record (private sealed, nested)

  • What it is: one unit of post-commit work: a CapturedState plus the interceptor instance that captured it (DomainEventSaveChangesInterceptor.cs:467-468).
  • Depends on: DomainEventSaveChangesInterceptor and CapturedState.
  • Concept introduced, carrying the owner so a static entry point can flush. DbContextFactory is the type that knows when a transaction committed, so it is the type that must trigger the deferred dispatch. It calls a static method, DomainEventSaveChangesInterceptor.FlushDeferredAsync (:144), rather than resolving the interceptor from DI: the factory therefore has no constructor dependency on the interceptor, which keeps the persistence graph acyclic. But a flush needs the instance (the dispatcher and logger it was constructed with), so each queued item carries its own owner and the static entry point simply calls back through it: dispatch.Owner.FlushStateAsync(...) (:152).
  • Walkthrough:
    • Owner (:388): the interceptor instance that produced the state. In a normal host this is the one singleton, but the record does not assume that.
    • State (:388): the captured state to flush.
    • Instances live in a second weak table, ConditionalWeakTable<DbContext, List<DeferredDispatch>> (:68), so a context can accumulate several deferrals when a transactional command saves more than once (:313, via DeferredTable.GetOrCreateValue(context).Add(...)).
  • Why it's built this way: the ordering rule it implements is that in-process handlers must never act on state that could still roll back. Email, cache writes and pushes issued from a handler are not transactional, so dispatching them before the commit would leave real side effects behind an aborted transaction, and an execution-strategy retry would repeat them once per attempt (:26-33). See ADR-003 and the Transactional decorator of ADR-014.
  • Where it's used: enqueued by DispatchAndFinalizeAsync when context.Database.CurrentTransaction is not null (:308-314); drained by FlushDeferredAsync after a successful commit (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:641); discarded wholesale by DropDeferred on every rollback path (DbContextFactory.cs:852, :599, :668, :746).

DomainEventSaveChangesInterceptor

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:54 · Level 11 · class (sealed, partial)

  • What it is: the interceptor that turns domain events into durable messages. Before the write it captures every pending event off the tracked aggregate roots and adds an OutboxMessage row for each, so events commit in the same transaction as the data. After the write it routes them: local events are dispatched in-process and their rows stamped processed, while integration events are left unprocessed for the OutboxProcessor to publish. On the async path the local rows are inserted under a short lease, so no replica's poller delivers an event a second time while this process is still handling it (DomainEventSaveChangesInterceptor.cs:13-39).
  • Depends on: IDomainEventDispatcher, IOutboxSignal, ILogger<T>, a TimeProvider and two optional options objects, all through the primary constructor (:54-59): the MessageBusSettings that supplies IsOutboxEnabled, and the OutboxSettings that supplies Outbox:LeaseSeconds; ApplicationDbContext, IAggregateRoot, IDomainEvent, IIntegrationEvent, OutboxMessage, and OutboxFinalizer (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxFinalizer.cs:13). BCL: ConditionalWeakTable, ReferenceEqualityComparer.
  • Concept introduced, the dual-dispatch outbox (ADR-003) and its two failure modes. [Rubric §6, CQRS and Event-Driven] assesses whether events are delivered reliably rather than optimistically, and [Rubric §29, Resilience and Business Continuity] assesses whether a crash at any point leaves the system recoverable. The mechanism has three moves. One, persistence: every captured event becomes an outbox row added to the same DbContext before base.SaveChangesAsync runs, so the row and the aggregate change commit or fail together and no crash can lose an event that the data implies happened. Two, the in-process fast path: after the write, local events go straight to the dispatcher and their rows are stamped processed, so the background processor finds nothing to do. Three, the fallback: if that dispatch throws, the lease is released, the rows stay unprocessed and outboxSignal.Signal() wakes the processor to retry. The routing split is what makes the pattern correct for extracted services: an integration event never takes the fast path, because in-process delivery would mark it processed and it would never reach the broker (:24-29).
  • Concept introduced, the local-row lease. Between the commit and the processed stamp, a local event's row is unprocessed and visible to every replica's poller. In a multi-replica host that window could deliver the event twice. So the async SavingChangesAsync builds a LocalLease (now plus Outbox:LeaseSeconds, under a fresh token; :98, :315-316) and ApplyLocalLease stamps it onto each local row (:280, :305-312). Three exits follow. Success: the processed stamp ends the row's life. Failed dispatch: the lease is released under its token so the processor retries after the normal processing delay, not after the full lease (:387-390). Crash: nothing releases it, and the rows are retried once the lease expires. Integration-event rows are never leased, because nothing in-process delivers them. The synchronous SavingChanges passes no lease (:111), because that path never dispatches in-process and its rows belong to the processor at once (:109-110).
  • Concept introduced, the outbox is a posture, not a constant. The interceptor reads IsOutboxEnabled off the injected message-bus options once, into the _outboxEnabled field (:64), and that field is passed into every capture (:98, :111). The resolved posture is the explicit MessageBus:EnableOutbox value when a host sets one, and otherwise "on for any transport other than in-process" (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:175), so a monolith running the in-process bus writes no outbox rows at all and dispatches everything directly, while a host on a broker keeps the durable path. A host that resolves no options at all keeps the outbox (:46-50, :64). This is why the type has two behavioral axes rather than one: context.Engine.Capabilities.IsRelational (is this a relational engine, and so one with an outbox table) and _outboxEnabled (does this host want it), and both must be true to take the durable branch (:257). The engine axis is read off the context's DataSourceEngineCapabilities: SQL Server, PostgreSQL and SQLite declare IsRelational: true, Cosmos declares false (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:44, PostgreSQLDataSourceEngine.cs:42, SqliteDataSourceEngine.cs:41, CosmosDataSourceEngine.cs:44).
  • Walkthrough (this is the densest type in the group; read it as capture, then route, then defer):
    • Fields and three static weak tables (:62, :64, :66, :73, :80, :88). The fields are the clock, an injected TimeProvider with no default (:57, :62), so a test can drive the ProcessedOn stamp and the lease expiry directly, the resolved outbox posture, and the lease length in seconds (:66, taken from OutboxSettings, or its default when the host resolves no options). StateTable holds the CapturedState between saving and saved; DeferredTable holds the DeferredDispatch list for a context inside a transaction; CaptureExclusionTable holds the aggregate instances a save must skip. ConditionalWeakTable is chosen throughout so the interceptor can stay a singleton without ever keeping a context alive or needing cleanup code (:68-72).
    • SavingChangesAsync / SavingChanges (:90-101, :103-114): both call CaptureEventsAndPersistToOutbox for an ApplicationDbContext, then delegate to base. The async one passes NewLocalLease(), the sync one localLease: null. Capture is synchronous by necessity: the outbox rows must be in the change tracker before EF generates the SQL.
    • CaptureEventsAndPersistToOutbox (:228-302), the heart of the type:
      • It first calls DiscardAbandonedCapture (:234, defined at :323-340). A previous SavingChanges that never reached SavedChanges (a failed save, then an execution-strategy retry) left its outbox rows tracked as Added. Re-capturing on top would write a second row per event and publish everything twice, so every Added OutboxMessage on the context is detached first. The comment justifies the blanket detach: this interceptor is the only writer of outbox rows, and a completed save leaves none Added (:330-332).
      • It reads the exclusion set (:240) and projects the tracked aggregate roots that have events and are not excluded into AggregateCapture values (:242-246), taking a snapshot copy of each event list, and returns early when nothing carried an event (:248-249).
      • When context.Engine.Capabilities.IsRelational and outboxEnabled are both true (:257), it reads context.CurrentOutboxOrigin once into a local (:268) rather than per event, then walks the flattened event list (:270-288): every event gets OutboxMessage.FromDomainEvent(domainEvent, origin), then is sorted, then its row is added to the outbox set. The comment states why a single read is correct rather than a shortcut (:263-267): the interceptor is a singleton and the context only carries the root provider, so a scoped service is not reachable from inside this method at all; the live accessor CurrentOutboxOrigin already resolved the ambient scope once for this save, and every row of the save is one scope's work, so stamping every row with the same captured value is correct by construction, not an approximation. An IIntegrationEvent only flips hasIntegrationEvents; anything else is leased through ApplyLocalLease (:280) and joins both locals and localOutboxEntries. The Add call now comes after that sorting step and carries a targeted VSTHRD103 suppression (:285-287) because EF's DbSet.Add is intentionally synchronous.
      • Otherwise, when the context has no outbox table (Cosmos being the example named in the comment) or the host turned the outbox off, every event is treated as local (:292-298): nothing could carry it to a processor anyway, and for the in-process transport that is the whole delivery path rather than a degradation. No rows exist in that branch, so no lease does either.
      • Finally it stores the CapturedState, including the lease token, under the context (:300-301).
    • SavedChangesAsync (:116-126) calls DispatchAndFinalizeAsync (:346-363), which pulls the state, removes it from the table, and then forks. With an active transaction it clears the captured events now (so a second save in the same transaction cannot re-capture them) and queues a DeferredDispatch (:353-359). Without one it flushes immediately (:362).
    • FlushStateAsync (:369-400): dispatches LocalEvents if any (:373-374), clears the captured events (:376), stamps the local rows processed through OutboxFinalizer.MarkProcessedAsync (:378, a single set-based ExecuteUpdate plus a tracker sync rather than a nested save, OutboxFinalizer.cs:27-31, :38-41, :43-50), and signals the outbox when integration events are present (:380-381). The catch logs through the source-generated LogDispatchError (:436-437), releases the lease (:390), and signals the processor so the unprocessed rows get retried (:383-394); the finally clears the events again, idempotently (:395-399).
    • ReleaseLocalLeaseAsync (:408-422): returns at once when the state has no lease token or no local rows (:410). Otherwise it calls OutboxFinalizer.ReleaseLeaseAsync with CancellationToken.None on purpose, because a dispatch that failed by cancellation still has to hand its rows back (:415; the guarded update is OutboxFinalizer.cs:67-84). It is best effort: a failure is logged through LogLeaseReleaseFailed (:420, :439-440) and swallowed, so the rows simply wait out the lease and the save that already committed is never failed by the release.
    • SavedChanges, the synchronous path (:138-152): it cannot await the dispatcher, so it does not try. For a host with the outbox on and a context whose engine is relational (the property pattern { Engine.Capabilities.IsRelational: true } at :141) it removes the state, clears the captured events (which is what stops a later async save from re-capturing and duplicating them) and signals the processor, leaving delivery entirely to the outbox. A context without outbox support, and a host running with the outbox off, keep the legacy no-op, because with no rows written there is nothing for a processor to pick up and clearing the events here would lose them outright (:129-137).
    • ClearDomainEvents (:430-434): removes exactly the captured events via RemoveDomainEvents, never a wholesale clear, for the reason AggregateCapture exists.
    • FlushDeferredAsync / DropDeferred (:160-169, :177): the two internal static entry points DbContextFactory calls at commit and at rollback. A missed flush is explicitly safe: the rows stay unprocessed and the outbox delivers them once their lease expires (:154-158).
    • BeginCaptureExclusion / EndCaptureExclusion (:194-203, :210): the narrow hook for IDENTITY_INSERT batching. DbContextFactory splits a save into one round per identity table and temporarily marks the other tables' entries Unchanged (DbContextFactory.cs:395-413); those rows are not written this round, so capturing their events now would persist and clear an event ahead of the insert that justifies it. The exclusion set is built with ReferenceEqualityComparer.Instance (:202) and cleared in a finally (DbContextFactory.cs:437-442). The remarks explain why exclusion is by instance and not by entity state (:187-191): skipping every Unchanged aggregate would also drop events raised on an already-saved aggregate, which is how the identity module publishes its registration events.
  • Why it's built this way: ADR-003 specifies at-least-once delivery, with the in-process path as an optimization and the outbox as the guarantee. The lease narrows the duplicate window that optimization would otherwise open on a multi-replica host, without giving up the guarantee: an unreleased lease only delays the fallback, it never drops a row. Deferring past the commit is ADR-014's Transactional decorator honored at the persistence layer: business failures roll back, and a rolled-back save must deliver nothing. The type is partial for the source-generated [LoggerMessage] methods (:436-440), and singleton-safe because every piece of per-save state lives in a weak table keyed by context.
  • Where it's used: registered as a singleton (DependencyInjection.cs:65), attached last of the save-time trio in ApplicationDbContext.OnConfiguring (ApplicationDbContext.cs:298, :307, :311) so it sees final audit stamps and final tenant values; driven at transaction boundaries by DbContextFactory. Pinned by DomainEventSaveChangesInterceptorTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Interceptors/DomainEventSaveChangesInterceptorTests.cs:19), DomainEventSaveChangesInterceptorOutboxRoutingTests (.../DomainEventSaveChangesInterceptorOutboxRoutingTests.cs:27), DomainEventSaveChangesInterceptorOutboxDisabledTests (.../DomainEventSaveChangesInterceptorOutboxDisabledTests.cs:21) and DomainEventCaptureExclusionTests (.../DomainEventCaptureExclusionTests.cs:26).
  • Caveats / not-in-source: DiscardAbandonedCapture detaches every Added OutboxMessage on the context, which is correct only while this interceptor remains the sole writer of outbox rows. That is true in the current source, and the comment states the assumption (:330-332), but it is an invariant a future outbox writer would have to respect. Also, a crash between commit and the processed stamp is not repaired by this type at all: recovery is the lease expiring and a poller taking the rows, so the worst-case delay is Outbox:LeaseSeconds (default 300, OutboxSettings.cs:85).

TenantSaveChangesInterceptor

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Interceptors · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:36 · Level 11 · class (sealed)

  • What it is: the write side of multi-tenancy. It stamps ITenantEntity.TenantId on inserts and refuses any save that would touch another tenant's row (TenantSaveChangesInterceptor.cs:9-13).
  • Depends on: ApplicationDbContext (for CurrentTenantId and the change tracker), ITenantEntity, CrossTenantWriteException, and EF Core's EntityEntry<T>. It has no constructor at all: every member is static and the class is pure policy over the entries.
  • Concept introduced, read isolation and write isolation are separate mechanisms. [Rubric §11, Security] assesses whether a boundary holds without caller cooperation. Tenancy in this framework is enforced twice, independently. On reads it is a named EF query filter, "Tenant", applied in ApplicationDbContext.ApplyTenantFilters (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:515-575), which composes by AND with the "SoftDelete" filter (ApplicationDbContext.cs:492-500) and embeds the executing context as a constant so one cached model serves every tenant (ApplicationDbContext.cs:501-507, :435-436). On writes it is this interceptor. The independence is the point and is called out in the remarks (:29-34): a caller who bypasses the read filter with EF's own parameterless IgnoreQueryFilters() can read across tenants, but still cannot write across them. [Rubric §30, Compliance and Data Governance] applies for the same reason: separation is a property of the engine here, not of reviewer vigilance.
  • Walkthrough:
    • SavingChangesAsync / SavingChanges (:39-48, :51-59): the same two-line shape as the audit interceptor, both routing to ApplyTenant.
    • ApplyTenant (:64-94): reads context.CurrentTenantId once per save (:68), because that property walks a live accessor into the scoped tenant context (ApplicationDbContext.cs:154) and a save must be judged against a single value. It then enumerates ChangeTracker.Entries<ITenantEntity>() filtered by !entry.Metadata.IsOwned() (:74-75) and switches on state: Added to StampOrVerifyInsert, Modified and Deleted to VerifyExistingRow with the operation name, everything else a no-op. Owned types are excluded on both sides of the boundary for the same reason (:70-73): an owned value is only reachable through its owner, so the owner's tenant already is the row's tenant and stamping the copy would only invite the two to disagree.
    • StampOrVerifyInsert (:101-124): four outcomes, in order. No current tenant and no declared tenant throws CrossTenantWriteException.ForUnresolvedTenant (:107-110), because silently writing a row no tenant can ever read is worse than failing the save. No current tenant but an explicitly declared one is allowed through (:112-113): that is a seeder or a per-tenant background job. A current tenant with no declared value gets the stamp (:116-120). A current tenant that disagrees with the declared value throws ForMismatch (:122-123).
    • VerifyExistingRow (:131-153): returns immediately when the scope resolved no tenant (:136-137), which keeps the system context unrestricted exactly as it is on the read side. It then checks both recorded values through FirstForeignTenant (:143-146): the property's OriginalValue catches touching another tenant's row, and its CurrentValue catches reassigning this row to another tenant within the same save.
    • FirstForeignTenant (:161-167) and IsForeign (:170-171): the original is reported in preference to the current one, because "you touched another tenant's row" is a more useful diagnosis than "you renamed its owner" (:155-160). IsForeign treats null and empty as absent, and compares with StringComparison.Ordinal.
    • EntityTypeName (:174-175): ClrType.FullName ?? ClrType.Name, used only for the failure message.
  • Why it's built this way: one concern per interceptor and registration order is execution order (:15-21). This one sits deliberately between the audit and domain-event interceptors: the audit stamps are already written when it runs, and the outbox rows the domain-event interceptor adds afterwards describe an entity whose tenant is final. It is also always registered and inert by default (:22-28, DependencyInjection.cs:67-70): it is a no-op for every entity that does not carry ITenantEntity, which is every entity in a host that never adopted tenancy, so that host pays nothing while a host that does adopt tenancy can never accidentally leave the write guard off. OnConfiguring resolves it with GetService rather than GetRequiredService (ApplicationDbContext.cs:305) so a directly-constructed test or design-time context still builds. See ADR-073.
  • Where it's used: registered as a singleton in AddInfrastructure (DependencyInjection.cs:70) and attached second in the interceptor chain (ApplicationDbContext.cs:307); exercised by TenantSaveChangesInterceptorTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Tenancy/TenantSaveChangesInterceptorTests.cs:12, including the owned-value carve-out at :149 and the still-builds-without-it case at :164) and by the registration tests in .../Persistence/Tenancy/AddMultiTenancyTests.cs:19.
  • Caveats / not-in-source: whether any deployed host in this workspace actually opts into multi-tenancy is Not determinable from source, since the guard is registered unconditionally and is inert until an entity implements ITenantEntity.

IdentityModuleDbSeederBase<TUser>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Seeding · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeederBase.cs:39 · Level 14 · class (abstract)

  • What it is: a DbSeeder subclass that owns the whole per-account seeding idiom (normalize the email, skip if it exists, hash the password, build the aggregate, add, save) for an app-supplied list of development accounts. Its own summary records why it exists: that idiom was written out five times across the two apps' Identity modules and now lives here once (IdentityModuleDbSeederBase.cs:9-13).
  • Depends on: IUnitOfWork and IPasswordHasher through its primary constructor (:38-40), SeedAccount as the input record, Email as the normalized value object, Result<T> as the factory-hook return type, and AuditableAggregateRootEntity<TIdentifierType> as the TUser constraint (:41).
  • Concept introduced, the template method with app-specific hooks. [Rubric §1, SOLID] (assesses whether abstractions are open for extension and closed for modification, and whether subclasses vary only what genuinely differs) and [Rubric §15, Best Practices & Code Quality]: the base fixes the invariant sequence and leaves exactly three extension points, each for a reason the doc comment spells out (:13-24). CreateUser exists because the two apps' User.Create factories take the same values in different parameter orders and only the app can name its own roles (compare ADC's ordering at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:56-62 with Store's at MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:51). EmailExistsAsync exists because the existence predicate must be written against the app's concrete User and never against an interface member, so EF translates it to the same SQL it did before the hoist. That second constraint is the one worth remembering: a predicate over an interface property is not translatable, so hoisting shared persistence logic into a generic base means leaving the query itself behind in the subclass.
  • Walkthrough:
    • Constructor and protected state (IdentityModuleDbSeederBase.cs:39-48): the primary constructor's two parameters are null-guarded into protected UnitOfWork (:44) and PasswordHasher (:47) properties, so subclasses can reach the unit of work for their existence predicate without taking it again.
    • Accounts (:50): abstract IReadOnlyList<SeedAccount>, the ordered list the app supplies.
    • ShouldSeed (:57): protected virtual, defaulting to true. The gate is checked once at the top of SeedAsync (:62-65).
    • SeedAsync (:60-71): the public entry point. Return early when the gate is closed, otherwise walk Accounts in order calling the private per-account routine (:67-70).
    • EmailExistsAsync(Email?, CancellationToken) (:81) and CreateUser(SeedAccount, byte[], byte[]) (:90): the two abstract hooks. Their doc comments define the contract precisely, including that email is null when the seed address failed validation, in which case no user can match it (:77-78), and that a failed Result skips the account silently (:89).
    • SeedAccountAsync (:92-113): the idiom itself. Normalize through Email.Create(...).Value so the EF predicate compares same-typed converted values (:94-95), ask the hook whether the account exists and return if so (:97-101), hash the plaintext password into a (hash, salt) pair (:103), call the app factory and return silently on failure (:104-108), then resolve the repository through IUnitOfWork.GetRepository<TUser, UserIdentifierType>(), add, and save (:110-112).
  • Why it's built this way: saving per account rather than once at the end is deliberate and is stated in the class comment (:26-29): one invalid account cannot roll back the others, which matches the pre-hoist behavior each app had. Hashing goes through IPasswordHasher rather than any local scheme, per ADR-032 (:46). [Rubric §11, Security] applies through the notice at :31-35: seed credentials are deliberately weak plaintext for local development, and a deployed environment must disable seeding or supply environment-sourced secrets.
  • Where it's used: subclassed once per app, by ADC's and Store's IdentityModuleDbSeeder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:28-31, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:22), each of which is constructed and run by its module's IModuleSeeder at startup (ADC's IdentityModuleSeeder at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:35-36). Pinned by IdentityModuleDbSeederBaseTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DbContexts/Seeding/IdentityModuleDbSeederBaseTests.cs:18, :26, :40, :57, :68, :82, :96), which cover the closed gate, per-account add and save, normalization before the existence check, the skip-if-present path, the skip-on-factory-failure path, and the hashed credential reaching the app factory.
  • Caveats / not-in-source: the class comment names ADC's Seeding:IncludeSampleUsers as an example of an app overriding ShouldSeed (:25-30), but no first-party seeder overrides it. ADC's subclass says so explicitly (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:18-20) and keeps the configuration gate in the API-layer IdentityModuleSeeder instead (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:29-31); the only override of ShouldSeed in the workspace is the test double (IdentityModuleDbSeederBaseTests.cs:162). So the gate exists in both places by design, and the comment describes an available option rather than the wiring in force.

IInternalCommandSignal

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Processing · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/IInternalCommandSignal.cs:9 · Level 0 · interface

  • What it is: a wake-up abstraction that lets code schedule an internal command and immediately nudge the processor, instead of the processor relying only on a fixed polling interval.
  • Depends on: nothing first-party; the contract is Task WaitAsync(TimeSpan, CancellationToken) plus a synchronous Signal() (IInternalCommandSignal.cs:12,21).
  • Concept introduced, the signal-based smart-wait pattern for the internal-commands job queue. [Rubric §12: Performance & Scalability] (assesses whether the system avoids wasted work and unnecessary latency): a plain fixed-interval poll trades latency for idle cost either way, while a signal lets a processor sleep long and still react immediately when work shows up. The doc comment on WaitAsync (IInternalCommandSignal.cs:14-17) states its intended caller directly: InternalCommandProcessor, "in place of a plain polling delay."
  • Walkthrough: Signal() (IInternalCommandSignal.cs:11-12) marks that due work is available; WaitAsync(timeout, cancellationToken) (IInternalCommandSignal.cs:21) waits for a signal or until timeout elapses, whichever comes first.
  • Why it's built this way: an interface, not a concrete SemaphoreSlim dependency, so the processor, scheduler, and administration surface can share one wake mechanism through DI and a test harness can substitute a controllable fake.
  • Where it's used: implemented by InternalCommandSignal; consumed by InternalCommandProcessor (the wait loop), InternalCommandScheduler, and InternalCommandAdministration (it signals after a successful requeue, InternalCommandAdministration.cs:192); registered in DependencyInjection.cs.

InternalCommandCycleResult

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Processing · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandCycleResult.cs:17 · Level 0 · record struct

  • What it is: an internal, allocation-cheap result carrier for one polling cycle: HasMoreDueWork (bool) and EarliestUpcoming (DateTime?) (InternalCommandCycleResult.cs:17).
  • Depends on: nothing first-party.
  • Concept: a plain readonly record struct used purely to avoid tuple sprawl across the processor's internal methods; no new pattern to teach beyond the record-struct convention used elsewhere in the framework.
  • Walkthrough: one line, two positional members: HasMoreDueWork drives whether InternalCommandProcessor re-polls immediately; EarliestUpcoming feeds ComputeWaitTime.
  • Why it's built this way: internal readonly record struct keeps the per-cycle result on the stack rather than the heap, since it is produced and consumed once per poll cycle in a loop that can run continuously for the life of the host.
  • Where it's used: returned by InternalCommandProcessor.ProcessDueCommandsAsync (InternalCommandProcessor.cs:125,140) and its private per-target helper, then read by ExecuteAsync's wait loop.

InternalCommandsDisabledNoticeService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Administration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Administration/InternalCommandsDisabledNoticeService.cs:20 · Level 0 · class

  • What it is: an IHostedService that logs a single informational notice on startup for a host that writes InternalCommands rows but runs neither InternalCommandProcessor nor InternalCommandCleanupService (InternalCommandsSettings.Enabled == false).
  • Depends on: ILogger<InternalCommandsDisabledNoticeService>, the [LoggerMessage] source-generator pattern.
  • Concept: a "notice service" idiom that pairs a settings toggle with a one-time explanatory log line rather than staying silent about a deliberate configuration choice. [Rubric §13: Observability & Operability] (assesses whether an operator can tell WHY the system behaves a certain way): a replica that never drains its own queue is easy to mistake for a bug without this notice.
  • Walkthrough: StartAsync (InternalCommandsDisabledNoticeService.cs:24-28) calls the generated LogQueueDisabled (InternalCommandsDisabledNoticeService.cs:33-36) once and returns; StopAsync (InternalCommandsDisabledNoticeService.cs:31) is a no-op.
  • Why it's built this way: the log message itself (InternalCommandsDisabledNoticeService.cs:35) states the operational fact directly: scheduled work is executed only by a host that has InternalCommands:Enabled=true against the same databases, and no migration is needed to turn the queue on later because the InternalCommands table is already part of the model.
  • Where it's used: registered in DependencyInjection.cs (1 site), presumably conditioned on InternalCommandsSettings.Enabled being false.

InternalCommandOrigin

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandOrigin.cs:16 · Level 0 · record struct (internal, readonly)

  • What it is: the six-field snapshot of the scheduling context a deferred InternalCommandMessage needs to restore around its later execution: the user id, a flattened roles string, the tenant id, and the correlation, trace and span ids (InternalCommandOrigin.cs:16-22).
  • Depends on: UserIdentifierType; every other member is a plain nullable string.
  • Concept introduced: none new. It plays the same role for the internal-command queue that the outbox's own ambient-context capture (OutboxOrigin.cs) plays for events: a value type that carries exactly what a disconnected, later execution needs to look like the scope that scheduled it.
  • Walkthrough: a plain positional internal readonly record struct with no members beyond its six constructor parameters. Value semantics are what let it be captured once by InternalCommandOriginCapture.Capture and copied cheaply onto the row.
  • Why it's built this way: a record struct rather than a class because it is a short-lived value with no identity of its own, constructed once per scheduled command and read once when the row is built; internal because only this project's scheduling and message types touch it.
  • Where it's used: built by InternalCommandOriginCapture.Capture (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandOriginCapture.cs:28-39), which InternalCommandScheduler calls while building the row (InternalCommandScheduler.cs:88), and consumed by InternalCommandMessage.FromCommand, which copies its five restorable fields onto the row (InternalCommandMessage.cs:152-157).

InternalCommandMetrics

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Processing · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandMetrics.cs:17 · Level 1 · class (static)

  • What it is: the static home of the OpenTelemetry Meter and every instrument the internal-commands job queue publishes: processed/failed/dead-letter counters, execution duration and scheduling-lag histograms, and pending-depth / oldest-due-age gauges.
  • Depends on: System.Diagnostics.Metrics (Meter, Counter<T>, Histogram<T>, ObservableGauge<T>), ConcurrentDictionary<TKey,TValue>.
  • Concept introduced, per-instance observable gauges for a replicated background worker. [Rubric §13: Observability & Operability]: the doc remarks on PendingDepthGauge (InternalCommandMetrics.cs:84-91) spell out that each replica reports only what IT last observed, never a cluster total, because the poll predicate already excludes rows another replica currently holds under lease; a source whose database is unreachable contributes zero for that cycle instead of a stale value.
  • Walkthrough: MeterName constant (InternalCommandMetrics.cs:20, "MMCA.Common.InternalCommands") backs a single Meter (line 22); ProcessedCounter/FailedCounter/DeadLetterCounter (InternalCommandMetrics.cs:38-60) are tagged by command_type (and reason for the latter two); DurationHistogram and LagHistogram (InternalCommandMetrics.cs:66-78) measure execution time and scheduling lag in seconds; PendingDepthGauge (InternalCommandMetrics.cs:92-96) is backed by _pendingDepth (line 28), read via Interlocked.Read; OldestDueAgeGauge (InternalCommandMetrics.cs:104-108) is backed by a ConcurrentDictionary<string,double> keyed by data_source (lines 34-35) and observed through ObserveOldestDueAge (InternalCommandMetrics.cs:120-128); SetPendingDepth (line 112) and SetOldestDueAge (lines 117-118) are the only writers, both called from InternalCommandProcessor.
  • Why it's built this way: the counter/histogram/gauge split mirrors the questions an operator actually asks: throughput and failure rate (counters), how long work takes and how late it starts (histograms), and how deep and how stale the backlog currently is (gauges); the doc comment on OldestDueAgeGauge (InternalCommandMetrics.cs:98-103) explains it is the signal a "wedged queue" alert fires on, distinct from the lag histogram which only reports rows that DID run.
  • Where it's used: InternalCommandProcessor.cs (8 sites, one poll cycle at a time); MMCA.Common.Aspire's OutboxPollFilterProcessor.cs (1 site) reads MeterName to filter the paired suppressed telemetry.
  • ADRs: ADR-041, ADR-114.

InternalCommandNameResolver

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Processing · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandNameResolver.cs:19 · Level 1 · class (static)

  • What it is: a static, cached resolver between an IInternalCommand CLR type and the string a queued row stores for it, honoring an optional declared-name attribute override in both directions.
  • Depends on: System.Reflection (Type.GetCustomAttribute, AppDomain.GetAssemblies), ConcurrentDictionary<TKey,TValue>, the first-party InternalCommandNameAttribute (not in this group).
  • Concept: reflection-with-caching for a hot path. [Rubric §12: Performance & Scalability]: the doc comment on DeclaredNameCache (InternalCommandNameResolver.cs:21-26) is explicit that caching the null (no-attribute) case too keeps the common unannotated command to one reflection lookup per type per process, rather than one per scheduled row.
  • Walkthrough: GetDeclaredName (InternalCommandNameResolver.cs:35-38) reads InternalCommandNameAttribute with inherit: false (a derived command cannot silently borrow its base's identity) via GetOrAdd; GetStorageName (InternalCommandNameResolver.cs:47-51) returns the declared name if present, else falls back through AssemblyQualifiedName → FullName → Name, the same fallback chain the outbox uses; FindTypeByDeclaredName (InternalCommandNameResolver.cs:66-72) is the reverse lookup for a stored name that is not a CLR type name: it lazily scans loaded, non-dynamic assemblies and checks IsDefined before GetDeclaredName, deliberately in that order, so only the handful of annotated types pay for constructing the attribute; GetLoadableTypes (InternalCommandNameResolver.cs:81-91) degrades to the types that DID load on a ReflectionTypeLoadException, so one bad type in an assembly cannot abort the scan.
  • Why it's built this way: the lazy Where/SelectMany/FirstOrDefault chain stops at the first match instead of materializing every loaded type (InternalCommandNameResolver.cs:59-63), which matters because this reverse path is reached at most once per stored name (the caller, InternalCommandMessage.ResolveCommandType, caches the result).
  • Where it's used: InternalCommandMessage.cs (2 sites, plus 1 more).

InternalCommandSignal

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Processing · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandSignal.cs:10 · Level 1 · class

  • What it is: the production implementation of IInternalCommandSignal: a thin wrapper over the shared WakeUpSignal wake-up primitive.
  • Depends on: IInternalCommandSignal (implements), WakeUpSignal, IDisposable.
  • Concept: see IInternalCommandSignal for the smart-wait rationale; this section stays compact since the abstraction is already taught there.
  • Walkthrough: _signal = new WakeUpSignal() (InternalCommandSignal.cs:12); Signal() (InternalCommandSignal.cs:14) and WaitAsync(timeout, cancellationToken) (InternalCommandSignal.cs:17-18) and Dispose() (InternalCommandSignal.cs:21) each forward directly to the wrapped WakeUpSignal.
  • Why it's built this way: the semaphore mechanics used to live here directly; they were extracted into WakeUpSignal so this type and the outbox's own signal share one tested wake-up implementation instead of two copies of the same semaphore dance.
  • Where it's used: registered in DependencyInjection.cs (1 site) as the IInternalCommandSignal implementation consumed by InternalCommandProcessor and InternalCommandAdministration.

EnrolledCommandWake

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/EnrolledCommandWake.cs:14 · Level 1 · class (internal, static)

  • What it is: a static ledger of processor wakes that are owed to a transaction which has not committed yet: a row enrolled in an open transaction cannot be signalled at once, so the wake is recorded here and released only after the commit succeeds (EnrolledCommandWake.cs:14-54).
  • Depends on: IInternalCommandSignal (the wake being deferred), EF Core DbContext (the key), BCL ConditionalWeakTable.
  • Concept introduced, a commit-gated wake. [Rubric §6, CQRS and Event-Driven] assesses whether deferred work is delivered reliably: signalling the processor before the commit would only buy a query against a row that is not visible yet, and never signalling would leave the row waiting for the fallback poll. The ledger sits between the two: Defer records the debt, Release pays it after commit, Drop cancels it on rollback.
  • Walkthrough: Owed (EnrolledCommandWake.cs:16) is a ConditionalWeakTable<DbContext, IInternalCommandSignal>, so an entry never keeps an abandoned context alive; Defer(context, signal) (EnrolledCommandWake.cs:21-22) is an AddOrUpdate keyed by the context that holds the enrolled row; Release(committedContexts) (EnrolledCommandWake.cs:30-49) collects the signal owed by each committed context, removes the entry, de-duplicates by signal instance, then calls Signal() once per distinct signal, so many rows or contexts produce one wake; Drop(context) (EnrolledCommandWake.cs:53) forgets the entry because the transaction did not commit.
  • Why it's built this way: keying by context ties the debt to the exact transaction that holds the row, and the weak table means the rollback and abandoned-context paths cannot leak. The doc comment on Release says to call it only after every commit succeeded (EnrolledCommandWake.cs:24-28).
  • Where it's used: InternalCommandScheduler calls Defer when it enrolls a row in an open transaction (InternalCommandScheduler.cs:113, see InternalCommandScheduler); DbContextFactory calls Release after commits (DbContextFactory.cs:481, DbContextFactory.cs:634) and Drop on the rollback path (DbContextFactory.cs:853).
  • ADRs: ADR-107, ADR-114.

InternalCommandsSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Administration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Administration/InternalCommandsSettings.cs:15 · Level 2 · class

  • What it is: the bound options class for the "InternalCommands" configuration section: it controls enablement, batching, retry backoff, claim leasing, retention, and which data source new work is scheduled against.
  • Depends on: DataSource and DataSourceKey, System.ComponentModel.DataAnnotations [Range] validation.
  • Concept: the options pattern used throughout the framework, here tuned deliberately DIFFERENT from the outbox's own settings in two places the doc comments call out explicitly. [Rubric §13: Observability & Operability] assesses whether operational knobs are documented well enough to tune safely; every property here carries a rationale comment, not just a default.
  • Walkthrough: SectionName (InternalCommandsSettings.cs:18, "InternalCommands"); Enabled (InternalCommandsSettings.cs:27, default true, the outbox's own posture: a host that schedules work must drain it, and a write-only replica sets it false); BatchSize (InternalCommandsSettings.cs:30-31, [Range(1,1000)], default 50); MaxAttempts (InternalCommandsSettings.cs:38-39, [Range(1,20)], default 5, a Result.Failure counts as an attempt exactly like a thrown exception); PollingIntervalSeconds (InternalCommandsSettings.cs:49-50, [Range(1,3600)], default 2, the fallback bound behind the smart wait: a row scheduled inside a transaction is woken by a signal released after the commit succeeds, so the interval is only the backstop for a wake that never arrives, such as a row another process wrote or a commit path that released no signal, per the doc comment at InternalCommandsSettings.cs:41-48); ProcessingDelaySeconds (InternalCommandsSettings.cs:58-59, [Range(0,600)], default 0: UNLIKE the outbox's 5, because the outbox delay exists to bound a race with an in-process fast path that the job queue does not have); LeaseSeconds (InternalCommandsSettings.cs:69-70, [Range(10,3600)], default 300, how long one replica's claim on a row lasts before another replica may reclaim it; the lease is renewed before each row of a claimed batch runs, so it bounds one handler, not the whole batch, per the doc comment at InternalCommandsSettings.cs:66-67); RetryBackoffBaseSeconds (InternalCommandsSettings.cs:78-79, [Range(1,3600)], default 10, attempt n waits base * 2^(n-1) with [0.8, 1.2] jitter, then capped); MaxRetryBackoffSeconds (InternalCommandsSettings.cs:87-88, [Range(1,86400)], default 600, independent of LeaseSeconds unlike the outbox, because a job queue wants a long lease for slow handlers but a short backoff ceiling); RetentionDays (InternalCommandsSettings.cs:94-95, [Range(0,3650)], default 7, 0 keeps completed rows forever); DeadLetterRetentionDays (InternalCommandsSettings.cs:102-103, [Range(0,3650)], default 0 falls back to RetentionDays); CleanupIntervalHours (InternalCommandsSettings.cs:109-110, [Range(1,168)], default 6); DataSource (InternalCommandsSettings.cs:118, default DataSource.SQLServer, must be relational: Cosmos has no InternalCommands table, though the processor drains every relational source in use, not only this one); DatabaseName (InternalCommandsSettings.cs:126, default DataSourceKey.DefaultName, pointed at the source holding the aggregates that schedule work so the row and the aggregate change share one transaction).
  • Why it's built this way: every divergence from the outbox's settings is a deliberate consequence of the job queue having no in-process fast path to race against, documented inline rather than left implicit.
  • Where it's used: read via IOptions<InternalCommandsSettings> across DependencyInjection.Jobs.cs (5 sites), InternalCommandCleanupService (4), InternalCommandScheduler (4), the test harness (3), InternalCommandAdministration (2), InternalCommandProcessor (2), DataSourceBranchingFitnessTests (1), and one cleanup-service test.
  • ADRs: ADR-114.

InternalCommandDispatcher

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Processing · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandDispatcher.cs:21 · Level 4 · class (static)

  • What it is: a static dispatcher that resolves and invokes the decorated ICommandHandler<TCommand, Result> for a deserialized IInternalCommand, without the caller needing compile-time knowledge of the command's concrete type.
  • Depends on: ICommandHandler<TCommand, Result> and IInternalCommand (Application layer, not in this group), IServiceProvider, System.Reflection.MethodInfo, ConcurrentDictionary<TKey,TValue>.
  • Concept introduced, a compiled-reflection invoker cache. [Rubric §12: Performance & Scalability]: building one delegate per command type costs a single MakeGenericMethod + CreateDelegate (InternalCommandDispatcher.cs:23-27); every subsequent row of that type calls the cached delegate directly instead of paying MakeGenericMethod or MethodInfo.Invoke again.
  • Walkthrough: Invokers (InternalCommandDispatcher.cs:28) caches one Func<IServiceProvider, IInternalCommand, CancellationToken, Task<Result?>> per command Type; InvokeDefinition (InternalCommandDispatcher.cs:31-33) reflectively resolves the private static generic InvokeAsync by nameof, guarded by an inline #pragma warning disable S3011 explaining the target is this same class's own private method, so a rename cannot silently break it (InternalCommandDispatcher.cs:30); ExecuteAsync (InternalCommandDispatcher.cs:49-56) is the public entry point: Invokers.GetOrAdd(command.GetType(), BuildInvoker) then invokes; BuildInvoker (InternalCommandDispatcher.cs:58-62) does the one-time MakeGenericMethod(commandType).CreateDelegate<...>(); InvokeAsync<TCommand> (InternalCommandDispatcher.cs:69-82) resolves the handler via GetService, NOT GetRequiredService (line 75), so a command with no registered handler on this host resolves to null rather than throwing.
  • Why it's built this way: the doc comment on ExecuteAsync (InternalCommandDispatcher.cs:43-48) states the rationale for the null-not-throw choice directly: a missing handler is a deployment fact (the owning module is disabled here, or the row was written by a different service), and the caller records that fact on the row instead of treating it as an exception to classify.
  • Where it's used: InternalCommandProcessor (InternalCommandProcessor.cs, 1 site) calls ExecuteAsync when it executes a claimed row.
  • ADRs: ADR-114.

InternalCommandMessage

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandMessage.cs:21 · Level 4 · class (sealed)

  • What it is: the durable row of the internal-command queue: one InternalCommands table entry per scheduled deferred command, carrying its serialized payload, its execution state (attempts, lease, dead-letter instant), and the scheduling context to restore when it finally runs (InternalCommandMessage.cs:21-210).
  • Depends on: InternalCommandOrigin (the constructor argument to FromCommand), IInternalCommand and InternalCommandNameResolver (both resolve/store the command identity), InternalCommandNameAttribute (named in the doc comments as the alternate identity a command can declare), UserIdentifierType, and System.Text.Json (JsonSerializer, JsonSerializerOptions with ReferenceHandler.IgnoreCycles, :23-26). BCL: ConcurrentDictionary<string, Type?>.
  • Concept introduced, a job-queue row is the outbox's row shape, generalized. [Rubric §6, CQRS and Event-Driven] assesses whether deferred and asynchronous work is delivered reliably, and this type is why: the comment inside ResolveCommandType (:199-201) states the ordering is "load-bearing, exactly as in OutboxMessage", and the two rows share the same two-tier type-resolution idea (an explicit stored name first, a reflection scan second, a success cached) because both are the same problem: a JSON payload written under one deployed assembly must still resolve after a rename or a redeploy. The difference from the outbox is the extra columns this row carries that an outbox row does not: Attempts, LastError, DeadLetteredOn, ClaimedBy, ClaimedUntil. Those are what turn "persist then publish" into "persist, lease, retry with backoff, dead-letter", the durable job queue taught in ADR-114.
  • Walkthrough:
    • CommandTypeCache (:36): a static ConcurrentDictionary<string, Type?> keyed by the stored name, StringComparer.Ordinal. Only a successful resolution is cached, consistent with OutboxMessage (:29-35); here that is defensive rather than load-bearing, because the processor dead-letters a row whose type it cannot resolve on its first attempt (type_unresolvable), so an unresolvable name costs one assembly scan per row and is never retried.
    • Id (:67): Guid, defaulted with Guid.NewGuid() at declaration, so a caller never has to supply one.
    • CommandType (:74) and Payload (:77): both required string. CommandType is the stored identity used to resolve the CLR type back on deserialization, either the InternalCommandNameAttribute name or the assembly-qualified type name (:70-73); Payload is the JSON body.
    • ScheduledOn / CreatedOn (:83, :86): the earliest UTC instant the command may run, and the UTC instant the row was written; equal for an immediate schedule.
    • ProcessedOn, Attempts, LastError, DeadLetteredOn, ClaimedBy, ClaimedUntil (:92, :95, :101, :108, :115, :123): the processor's execution state. ClaimedBy/ClaimedUntil carry the lease: a claiming replica stamps the outcome only on a row still carrying its own token, so a replica whose lease expired mid-execution cannot overwrite the row a different replica already took over (:110-114); the same ClaimedUntil column doubles as the retry backoff, a failed attempt re-leasing the row for the computed wait (:117-122). DeadLetteredOn marks a row abandoned after InternalCommands:MaxAttempts, requeued only through Application.InternalCommands.IInternalCommandAdministration (:103-107).
    • CorrelationId, TraceId, SpanId, UserId, UserRoles, TenantId (:129, :132, :135, :142, :149, :155): the restorable half of InternalCommandOrigin, copied onto the row rather than referenced, so the scheduling context survives independently of the struct that captured it.
    • FromCommand(command, scheduledOn, createdOn, context) (:165-187, internal static): null-guards the command (:171), resolves its stored identity via InternalCommandNameResolver.GetStorageName and serializes it with SerializerOptions (:176-177), then copies every field of the passed-in InternalCommandOrigin onto the new row (:180-185).
    • DeserializeCommand() (:199-206, internal): resolves the type through ResolveCommandType, returns null if unresolvable, otherwise deserializes Payload as that type.
    • ResolveCommandType() (:192-209, private): returns a cache hit from CommandTypeCache (:194-197); on a miss, Type.GetType is tried first and InternalCommandNameResolver.FindTypeByDeclaredName is the fallback, in that order on purpose (:199-202), and only a non-null result is written back to the cache (:203-206).
    • IsCommandTypeCached(storedName) (:183, internal static): a test hook reporting whether a stored name currently has a cached resolution.
  • Why it's built this way: ReferenceHandler.IgnoreCycles on the shared serializer options (:23-26) means a command carrying a cyclic object graph serializes without a hand-written converter, at the cost of any cycle round-tripping as a broken reference on the way back; commands are expected to be flat DTOs, so that tradeoff is accepted rather than guarded against. The two-tier name resolution mirrors OutboxMessage deliberately (:199-201), so a reviewer who has read one already understands the other.
  • Where it's used: added to the caller's IDbContextFactory context by InternalCommandScheduler.ScheduleAsync (InternalCommandScheduler.cs:99-104), so it commits atomically with the caller's aggregate change inside a transaction, or saves immediately outside one; claimed, executed and stamped by InternalCommandProcessor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandProcessor.cs, 15 references); mapped by ApplicationDbContext as one InternalCommands table per relational source (ApplicationDbContext.cs, 6 references); administered by InternalCommandAdministration and swept by InternalCommandCleanupService. Pinned by InternalCommandModelTests, InternalCommandSchedulerTests, InternalCommandProcessorTests and InternalCommandAdministrationTests in MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/InternalCommands/.
  • Caveats / not-in-source: the mapped UserRoles column has a documented width (MaxRolesLength, defined on InternalCommandScheduler and shared with the outbox's own column of the same name through AmbientOrigin.MaxRolesLength); this type itself does not truncate, so an over-length role list is truncated by the caller before FromCommand sees it.

InternalCommandOriginCapture

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandOriginCapture.cs:21 · Level 9 · class (internal sealed)

  • What it is: the snapshot step of the internal-command queue: one method, Capture(), that reads the ambient request context a scheduled command must carry onto its row (the scheduling principal and roles, the tenant, the correlation id, the trace and span ids) and returns it as an InternalCommandOrigin (InternalCommandOriginCapture.cs:8-12, :21-44). The InternalCommandProcessor restores those values around the deferred execution.
  • Depends on: ICurrentUserService, ITenantContext and ICorrelationContext through the primary constructor (:21-24); InternalCommandOrigin as the value it builds; AmbientOrigin.FlattenRoles for the roles; BCL System.Diagnostics.Activity for the trace and span ids.
  • Concept introduced, capture is scoped because what it reads is scoped. [Rubric §13, Observability & Operability] assesses whether the identity and correlation of a request survive an asynchronous hop. The snapshot has to describe the request that is scheduling the command, and all three services it reads are request-scoped, so the capture is registered scoped too (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:160, rationale in the remarks at :13-17). Pulling the capture out of the scheduler into its own type gives the snapshot one owner with one dependency set, so InternalCommandScheduler takes a single InternalCommandOriginCapture argument instead of the three ambient services.
  • Walkthrough:
    • Capture() (:32-43): reads Activity.Current once (:34), then builds the InternalCommandOrigin from currentUserService.UserId, the roles flattened through AmbientOrigin.FlattenRoles(currentUserService.Roles), tenantContext.TenantId, correlationContext.CorrelationId, and the activity's TraceId/SpanId as strings, or null when no activity is running (:36-42).
  • Why it's built this way: roles go through the shared AmbientOrigin helper, which the outbox capture uses as well, so the outbox hop and the internal-command hop store roles in the same shape (:15-16). The class holds no state of its own; every value comes from the scoped services at the moment Capture() runs.
  • Where it's used: registered with TryAddScoped (DependencyInjection.Jobs.cs:160); called once per scheduled command by InternalCommandScheduler (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandScheduler.cs:88); constructed directly by InternalCommandTestHarness (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/InternalCommands/InternalCommandTestHarness.cs).

InternalCommandScheduler

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandScheduler.cs:36 · Level 13 · class (internal sealed, partial)

  • What it is: the write side of the internal-command queue, IInternalCommandScheduler's implementation: it turns a command into an InternalCommandMessage row, enrolls it in the caller's transaction if one is open (owing the processor a wake until the commit succeeds), and otherwise saves and signals the processor at once (InternalCommandScheduler.cs:36-144).
  • Depends on: IDbContextFactory, IDataSourceResolver, IOptions<InternalCommandsSettings>, InternalCommandOriginCapture, IInternalCommandSignal, ILogger<InternalCommandScheduler> and TimeProvider, all through the primary constructor (:36-43), plus EnrolledCommandWake for the deferred wake; InternalCommandMessage as the row it builds, and InternalCommandOrigin as the captured scheduling context it hands to FromCommand; Result<Guid> and Error for its return shape.
  • Concept introduced, scheduling rides the caller's own save. [Rubric §6, CQRS and Event-Driven] assesses whether deferred work is delivered reliably rather than optimistically, the same lens as the outbox. ScheduleAsync never opens a SaveChanges of its own when a transaction is already active: it resolves the caller's context through dbContextFactory.GetDbContext(...) and Adds the row onto it (:99-104), so the queued command commits or rolls back exactly with the aggregate change that scheduled it. Inside a transaction it neither saves nor signals at once (:106-116): the caller's next save writes the row, or the transactional pipeline saves it just before the commit when no save follows (:107-110), and signalling then would only buy a query against a row that has not committed yet. Instead the wake is owed: EnrolledCommandWake.Defer(context, signal) (:113) records it, the unit of work releases it once after a successful commit and drops it on rollback (see EnrolledCommandWake). Outside a transaction it saves immediately and always signals the processor (:120-125).
  • Walkthrough:
    • MaxRolesLength (:48): internal const int, aliased to AmbientOrigin.MaxRolesLength so the internal-command queue and the outbox agree on the column width for UserRoles without restating the number.
    • ScheduleAsync(command, delay, cancellationToken) (:57-64): the delay-based overload, sugar over the DateTimeOffset? overload: a positive delay becomes _timeProvider.GetUtcNow() + delay, anything else passes null through.
    • ScheduleAsync(command, runAt, cancellationToken) (:67-125), the primary path:
      • Returns NullCommandError (a Validation error, :50-51) for a null command (:72-75).
      • Normalizes a past runAt to "now" rather than rejecting it (:81): a caller computing a deadline that already slipped wants the work done immediately, not an error to handle.
      • Builds the row via InternalCommandMessage.FromCommand(command, scheduledOn, now, originCapture.Capture()) (:83-95, the call at :86) inside a try/catch (NotSupportedException): System.Text.Json reports an unserializable payload this way, and the catch turns it into a logged failure result rather than letting it take down the handler that scheduled the command. The origin snapshot itself is InternalCommandOriginCapture's job.
      • Resolves the context for _settings.DataSource/DatabaseName through dataSourceResolver.ResolveLogical (:97-98) and adds the row with the standard VSTHRD103-suppressed synchronous Add (:100-102).
      • If context.Database.CurrentTransaction is not null, defers the wake through EnrolledCommandWake.Defer, logs LogEnrolled and returns without saving or signalling (:106-116); the signal fires after the commit succeeds, and the polling interval is only the backstop.
      • Otherwise calls context.SaveChangesAsync directly, deliberately not through a user id overload, because an InternalCommandMessage is neither auditable nor an aggregate root, so there is nothing for the audit or domain-event interceptors to do here (:114-116). It then signals the processor whether or not the row is due yet (:118-121): a due row runs on the wake, and a not-yet-due row costs one fetch but re-arms the processor's smart wait on its ScheduledOn, which the processor would otherwise only learn at the next polling interval and so run the command late.
    • SerializationError(commandType) (:127-130): the Error.Failure factory for the not-serializable branch.
    • LogEnrolled, LogScheduled, LogSerializationFailed (:132-139): source-generated [LoggerMessage] partials at Debug, Debug, and Error respectively.
  • Why it's built this way: internal sealed partial because it is the one implementation of IInternalCommandScheduler in the framework and callers depend on the interface, not the class; the no-op-on-open-transaction branch is the same ordering discipline DeferredDispatch enforces for domain events, applied here to a queued command instead of an in-process dispatch. See ADR-114.
  • Where it's used: registered in DI (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs, 1 reference); the entry point every caller of IInternalCommandScheduler.ScheduleAsync goes through. Pinned by InternalCommandSchedulerTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/InternalCommands/InternalCommandSchedulerTests.cs), exercised through InternalCommandTestHarness, and referenced by DbContextFactoryTransactionTests.
  • Caveats / not-in-source: a null command returns Result.Failure with a Validation error (:50-51, :72-75) rather than throwing ArgumentNullException; the source does not explain the choice in a comment.

InternalCommandAdministration

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Administration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Administration/InternalCommandAdministration.cs:34 · Level 16 · class

  • What it is: the operator-facing administration surface for the internal-commands queue: count pending work, list and paginate dead letters, requeue them, and purge completed rows, fanned out across every relational data source (and tenant) this host owns.
  • Depends on: IServiceScopeFactory, IOptions<InternalCommandsSettings> (InternalCommandsSettings), FrameworkTableTargets, IInternalCommandSignal, TimeProvider, IDbContextFactory, ApplicationDbContext, InternalCommandMessage, TenantDataSourceTarget, Error/Result (Domain layer).
  • Concept: the same operator-lever shape as IOutboxAdministration: count / list-dead-letters / requeue / purge, fanned out per owned data source. [Rubric §8: Data Architecture] (assesses how a system handles database-per-service and multi-tenant fan-out): every operation here resolves its own target set rather than assuming a single database. [Rubric §13: Observability & Operability]: requeue and purge are explicit, logged operator actions, not silent background behavior.
  • Walkthrough: the primary constructor (InternalCommandAdministration.cs:34-40) takes six required parameters, with target selection delegated to an injected FrameworkTableTargets rather than a registry, a resolver and optional tenancy options; MaxPageSize (InternalCommandAdministration.cs:43, 500) bounds one page so an admin call cannot pull the whole table; SkipError/TakeError/OlderThanError (InternalCommandAdministration.cs:45-52) are pre-built Error.Validation instances; every operation returns Error.NotFoundError from UnknownSourceError (InternalCommandAdministration.cs:236-239) when the requested source name matches no owned target; CountPendingAsync (InternalCommandAdministration.cs:58-79) sums a LongCountAsync per target using the processor's pending predicate (ProcessedOn and DeadLetteredOn null, Attempts < MaxAttempts); ListDeadLettersAsync (InternalCommandAdministration.cs:82-138) paginates across the MERGED result of every target: each target returns at most skip + take rows (ordered by ScheduledOn then Id), because "skip 50" must mean the same thing whether this host owns one database or four, then the merged list is re-ordered and re-paged in memory (InternalCommandAdministration.cs:132-135); RequeueAsync (InternalCommandAdministration.cs:141-196) resets Attempts to 0 and clears DeadLetteredOn/ClaimedUntil/ClaimedBy via ExecuteUpdateAsync (InternalCommandAdministration.cs:171-177) while deliberately preserving LastError ("the only evidence of WHY this row needed requeuing"), then calls signal.Signal() (InternalCommandAdministration.cs:192) to wake the processor immediately instead of waiting for the next poll; PurgeProcessedAsync (InternalCommandAdministration.cs:199-234) ExecuteDeleteAsyncs processed rows at or before a caller-supplied cutoff; SelectTargets (InternalCommandAdministration.cs:246-247) is a one-line call to tableTargets.Named(DataSource, DatabaseName, dataSource), recomputed per call because module assemblies can register entities after startup, and optionally narrowed to one name; VisitAsync<T> (InternalCommandAdministration.cs:254-265) opens a fresh DI scope via the shared scopeFactory.CreateTenantScope(target) extension (InternalCommandAdministration.cs:261), which sets the tenant BEFORE the IDbContextFactory context is resolved, because the tenant is both what routes the scoped factory to the right database and what the query filter reads.
  • Why it's built this way: the tenant-before-context ordering and the deliberate LastError preservation are both stated inline as design decisions, not incidental; LogRequeued/LogPurged (InternalCommandAdministration.cs:267-271) give operators a durable audit trail of manual interventions on the queue.
  • Where it's used: registered in DependencyInjection.Jobs.cs (1 site); referenced from ApplicationDbContext.cs (1); exercised by InternalCommandAdministrationTests.cs (4); SoftDeleteEnforcementTests in both MMCA.Common.Architecture.Tests and MMCA.ADC.Architecture.Tests (1 each) check its hard ExecuteDeleteAsync/ExecuteUpdateAsync calls against the restrict-delete-by-default convention.
  • ADRs: ADR-119.

InternalCommandCleanupService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Administration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Administration/InternalCommandCleanupService.cs:40 · Level 16 · class

  • What it is: a PeriodicBackgroundService that periodically purges completed and dead-lettered InternalCommands rows past their retention windows, across every relational data source (and tenant) this host owns.
  • Depends on: PeriodicBackgroundService, IServiceScopeFactory, IOptions<InternalCommandsSettings> (InternalCommandsSettings), FrameworkTableTargets, TimeProvider, IDbContextFactory, ApplicationDbContext, InternalCommandMessage, TenantDataSourceTarget.
  • Concept: the same "sweep every owned target, isolate one source's failure from the rest" shape as InternalCommandAdministration's own purge path; cross-reference rather than re-teach the tenant-before-context ordering. [Rubric §29: Resilience & Business Continuity]: an unreachable database on one source does not stop the sweep of the others. The hour-scale loop mechanics themselves (interval wait, cancellation, error logging) now come from the shared PeriodicBackgroundService base rather than being hand-rolled here, so this section covers only what this type overrides.
  • Walkthrough: the type extends PeriodicBackgroundService(timeProvider, logger) (InternalCommandCleanupService.cs:40-46) and takes five required constructor parameters, with target selection delegated to an injected FrameworkTableTargets; it overrides Interval (InternalCommandCleanupService.cs:55, TimeSpan.FromHours(CleanupIntervalHours)), StartupDelay (InternalCommandCleanupService.cs:61, equal to Interval, so the first sweep waits one full interval before competing with startup or migration work), IsEnabled (InternalCommandCleanupService.cs:64-76, false and logs LogCleanupDisabled when RetentionDays <= 0), ExecuteCycleAsync (InternalCommandCleanupService.cs:79, forwards straight to PurgeAsync), and LogCycleFailure (InternalCommandCleanupService.cs:82, forwards to LogCleanupError); PurgeAsync (InternalCommandCleanupService.cs:89-133, internal so a test can drive one sweep without advancing the hour-scale timer) computes cutoff = now - RetentionDays and a separate deadLetterCutoff using DeadLetterRetentionDays when set, else RetentionDays (InternalCommandCleanupService.cs:91-97); for each target from GetTargets (InternalCommandCleanupService.cs:160-161, a one-line call to tableTargets.Relational(DataSource, DatabaseName), the same set the processor drains) it opens a scope via scopeFactory.CreateTenantScope(target) (InternalCommandCleanupService.cs:105, sets the tenant before the context is resolved), resolves the ApplicationDbContext through IDbContextFactory.GetDbContext(target.Source), ExecuteDeleteAsyncs processed rows past cutoff (InternalCommandCleanupService.cs:110-113, LogPurged), then calls SweepDeadLettersAsync (InternalCommandCleanupService.cs:139-154) which deletes rows keyed on DeadLetteredOn < deadLetterCutoff (LogDeadLetterPurged); a per-source exception is caught and logged via LogSourcePurgeError rather than aborting the whole sweep.
  • Why it's built this way: keying the dead-letter sweep on DeadLetteredOn rather than a completion stamp reflects that a dead-lettered row never got one; the independent DeadLetterRetentionDays window lets operators keep abandoned commands around longer for diagnosis than ordinary completed ones; moving onto PeriodicBackgroundService retires a hand-rolled interval loop that was nearly identical to the outbox and internal-commands processors' own loops.
  • Where it's used: registered as a hosted service alongside InternalCommandProcessor in DependencyInjection.Jobs.cs (1 site); its retention posture is what InternalCommandsDisabledNoticeService's log message calls out as the second job a Enabled=false host does not run; exercised directly by InternalCommandCleanupServiceTests.cs (4).
  • ADRs: ADR-114.

InternalCommandProcessor

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.InternalCommands.Processing · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandProcessor.cs:43 · Level 16 · class

  • What it is: the BackgroundService at the center of the internal-commands job queue: it drains due rows across every relational data source (and tenant) this host owns, executes each via InternalCommandDispatcher, and records the outcome, using a smart wait instead of a fixed polling interval.
  • Depends on: BackgroundService, PollingLoop, IServiceScopeFactory, IOptions<InternalCommandsSettings> (InternalCommandsSettings), IInternalCommandSignal, FrameworkTableTargets, TimeProvider, InternalCommandCycleResult, InternalCommandMetrics, InternalCommandDispatcher, InternalCommandMessage, TenantDataSourceTarget, IDbContextFactory, ApplicationDbContext, System.Diagnostics.ActivitySource.
  • Concept: the smart-wait polling loop itself now lives in the shared PollingLoop (cross-reference rather than re-teach): this type supplies its own settings, signal, and per-source work, and PollingLoop runs the loop, computes the wait, and aggregates targets. [Rubric §12: Performance & Scalability]: instead of a fixed poll interval, the loop computes how long until the earliest known upcoming row is due, floors that to avoid a hot loop, caps it at the configured fallback interval, and lets IInternalCommandSignal wake it early. [Rubric §13: Observability & Operability]: poll spans are emitted under their own suppressible activity so an idle fleet's constant polling does not dominate telemetry ingestion, and InternalCommandMetrics is fed every cycle.
  • Walkthrough: the primary constructor (InternalCommandProcessor.cs:43-49) takes six required parameters, with target selection delegated to an injected FrameworkTableTargets; constants: PollActivityName (InternalCommandProcessor.cs:57, "InternalCommandPoll", must stay in sync with OutboxPollFilterProcessor in MMCA.Common.Aspire, which has no project reference here and so duplicates the string), MaxErrorLength (InternalCommandProcessor.cs:60, 4000, the column width LastError is truncated to), PrincipalAuthenticationType (InternalCommandProcessor.cs:66, "InternalCommand", the auth type stamped on the identity rebuilt from a claimed row); ExecuteAsync (InternalCommandProcessor.cs:75-89) just calls PollingLoop.RunAsync, passing GetTargets().Count > 0 as hasTargets, LogNoRelationalSources as onNoTargets, a lambda that runs ProcessDueCommandsAsync and maps its result to the (HasMoreWork, EarliestUpcoming) tuple PollingLoop expects, LogCycleError as onCycleError, the configured delay/interval seconds, and signal.WaitAsync as the wait delegate; ComputeWaitTime (InternalCommandProcessor.cs:101-106) is a one-line forward to PollingLoop.ComputeWaitTime, kept as a wrapper so its own unit tests still compile against this type; GetTargets (InternalCommandProcessor.cs:114-115) is a one-line call to tableTargets.Relational(DataSource, DatabaseName), recomputed every cycle so a module assembly registered after startup is picked up; ProcessDueCommandsAsync (InternalCommandProcessor.cs:125-141, internal for direct test invocation) forwards the per-target fan-out to PollingLoop.DrainAllAsync, supplying ProcessSourceAsync as the per-target delegate and LogSourceError as the per-source error handler, then publishes the aggregated depth through InternalCommandMetrics.SetPendingDepth; ProcessSourceAsync (InternalCommandProcessor.cs:147-219) per target opens a DI scope via CreateTenantScope (InternalCommandProcessor.cs:153), so the tenant is set before the ApplicationDbContext is resolved through IDbContextFactory (same ordering rule as the admin and cleanup types), fetches up to BatchSize runnable rows via FetchCandidatesAsync, derives the pending-depth gauge input from that same fetch via CountPendingAsync (no extra query unless the batch is saturated), publishes the oldest-due-age gauge from the fetch's own first row (no extra query), splits the ordered rows into a due prefix and an upcoming remainder, returns early with no claim when nothing is due or when another replica claimed the whole prefix, otherwise claims the due prefix under a fresh claimToken and, for each claimed row, first calls RenewClaimAsync (InternalCommandProcessor.cs:204) and skips the row with LogLeaseLostBeforeExecution when the claim is no longer this replica's, else runs ExecuteClaimedAsync; it reports HasMoreDueWork when the due count exactly filled BatchSize and at least one row progressed (InternalCommandProcessor.cs:216, so a saturated batch triggers an immediate re-poll); FetchCandidatesAsync (InternalCommandProcessor.cs:229-248) runs inside its own suppressible Activity, queries AsNoTracking rows with ProcessedOn/DeadLetteredOn null, Attempts < MaxAttempts, and no unexpired ClaimedUntil, ordered by ScheduledOn then Id; CountPendingAsync (InternalCommandProcessor.cs:256-278) short-circuits to the fetched row count when it is below BatchSize (the fetch already saw the whole backlog) and pays for a COUNT query only when the batch is saturated; ClaimDueAsync (InternalCommandProcessor.cs:286-327) stamps ClaimedUntil = now + LeaseSeconds and ClaimedBy = claimToken on the due prefix in one guarded ExecuteUpdateAsync, and on a partial claim re-reads which ids carry this replica's token so only owned rows run; RenewClaimAsync (InternalCommandProcessor.cs:334-348) pushes ClaimedUntil forward by LeaseSeconds in a statement guarded on ClaimedBy == claimToken and returns whether a row was updated, which is what makes LeaseSeconds bound one handler rather than the whole batch; ExecuteClaimedAsync (InternalCommandProcessor.cs:355-426) dead-letters a row on the first attempt when its payload throws JsonException or NotSupportedException on deserialization (InternalCommandProcessor.cs:369-378, reason payload_invalid, logged by LogPayloadInvalid), when its type cannot be resolved (type_unresolvable), or when no handler is registered on this host (handler_missing), records lag and duration histograms around the invocation, completes the row on a successful Result, and otherwise hands off to RecordFailedAttemptAsync (InternalCommandProcessor.cs:435-462), which treats a Result.Failure and a thrown exception alike: dead-letter with attempts_exhausted once MaxAttempts is reached, else schedule a backoff retry.
  • Why it's built this way: the loop, wait-time computation, and per-target aggregation moved into PollingLoop because the outbox processor had nearly identical code; keeping ComputeWaitTime as a thin forwarding wrapper on this type avoids a breaking change to its own existing unit tests. An un-suppressed poll span would swamp telemetry for an idle fleet, and a COUNT on every cycle would be wasted work in the (common) unsaturated case. Cite ADR-114 for the queue itself; the executed handler goes through the same decorated ICommandHandler<TCommand, Result> pipeline any other command uses, per ADR-014.
  • Where it's used: registered as a hosted service in DependencyInjection.Jobs.cs (1 site); ADC's ScoreEventSessionsInternalCommand (Conference.Application, 1 site) is a concrete IInternalCommand this processor drains; InternalCommandProcessorTests.cs and the shared test harness exercise ProcessDueCommandsAsync/ComputeWaitTime directly; OutboxPollFilterProcessor.cs (MMCA.Common.Aspire, 2 sites) reads PollActivityName to filter the matching spans from export.
  • Caveats / not-in-source: InvokeAsync, ScheduleRetryAsync, CompleteAsync and DeadLetterAsync (from InternalCommandProcessor.cs:464 onward, through the end of the type at InternalCommandProcessor.cs:712) were not re-read line-by-line for this section; read that range for the retry-write details. The principal-restore in InvokeAsync (InternalCommandProcessor.cs:473-510) opens a fresh DI scope and calls AmbientOrigin.Restore (InternalCommandProcessor.cs:483), whose returned IDisposable is now held by using var origin, so the restored origin is undone when the call ends rather than left on the ambient context (AmbientOrigin.cs:156,240-242).
  • ADRs: ADR-014, ADR-114.

GroupedCount<TKey>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:186 · Level 0 · record (private sealed, nested)

  • What it is: a two-property carrier for one GROUP BY row: the grouping key and the number of rows carrying it. It is declared private sealed record class inside EFReadRepository<TEntity, TIdentifierType>, so it exists only for the lifetime of one query translation and is invisible to every caller (EFReadRepository.cs:183-186).
  • Depends on: nothing first-party. TKey is the caller's grouping key type, constrained to notnull at the CountByAsync<TKey> declaration (EFReadRepository.cs:137).
  • Concept introduced, a named projection target so the provider can translate the grouping. [Rubric §12, Performance and Scalability] assesses whether aggregation happens in the database rather than after materialization. CountByAsync composes query.GroupBy(keySelector).Select(group => new GroupedCount<TKey>(group.Key, group.Count())) (EFReadRepository.cs:146-150), so the COUNT and the GROUP BY are both pushed to SQL and only one row per key comes back. The reason a named type appears here at all rather than an anonymous type or a tuple is translatability: EF Core needs a constructor-shaped projection it can bind by parameter name, and a positional record gives it exactly that with a single line of code. The result is flattened into an IReadOnlyDictionary<TKey, int> immediately afterwards (:146), so the record never escapes the method.
  • Walkthrough
    • Key (EFReadRepository.cs:186): the grouping key, projected from group.Key.
    • Value (EFReadRepository.cs:186): the int row count, projected from group.Count().
    • The record has no methods of its own. Being a positional record class, it gets value equality and a Deconstruct free, neither of which the one call site uses; the shape is the whole point.
  • Why it's built this way: nesting it privately inside the repository keeps a purely mechanical translation helper out of the assembly's type surface, while still giving EF a real named type to bind to. It is the counterpart of GroupedSum<TKey>, and the two are deliberately separate rather than one generic Grouped<TKey, TValue>, because the int and decimal aggregate shapes translate through different provider paths.
  • Where it's used: only in EFReadRepository.CountByAsync<TKey> (EFReadRepository.cs:148, flattened at :146). The public capability it backs is consumed in MMCA.ADC by BookmarkCountService (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/Services/BookmarkCountService.cs:38) and GetAttendanceStatsHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetAttendanceStats/GetAttendanceStatsHandler.cs:32), and covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/EFReadRepositoryReadSurfaceTests.cs.

GroupedSum<TKey>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:191 · Level 0 · record (private sealed, nested)

  • What it is: the summing twin of GroupedCount<TKey>: one GROUP BY row carrying the key and the decimal total for that key (EFReadRepository.cs:188-191). Same nesting, same privacy, same single-method lifetime.
  • Depends on: nothing first-party; see GroupedCount<TKey> for the concept.
  • Concept: introduced by GroupedCount<TKey>. What is worth reading here is the element selector on the grouping. SumByAsync calls query.GroupBy(keySelector, sumSelector) with two expressions and then group.Sum() with none (EFReadRepository.cs:174-178). The comment at :165-167 explains the choice: choosing the summed column inside the grouping keeps the caller's expression tree intact all the way to the provider, whereas summing over the grouping with a separately supplied expression would need that tree spliced in by hand. [Rubric §12, Performance and Scalability] again: the whole aggregation is one statement.
  • Walkthrough
    • Key (EFReadRepository.cs:191): the grouping key.
    • Value (EFReadRepository.cs:191): the decimal total, projected from group.Sum() over the element-selected column.
    • Flattened to IReadOnlyDictionary<TKey, decimal> at EFReadRepository.cs:180.
  • Why it's built this way: identical rationale to its twin. decimal rather than a generic numeric parameter matches the framework's money and points columns, which are the values callers actually total.
  • Where it's used: only in EFReadRepository.SumByAsync<TKey> (EFReadRepository.cs:176). The capability is consumed in MMCA.ADC by GetLeaderboardHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:55) and covered by EFReadRepositoryReadSurfaceTests.cs.

LookupRow<TId, TName>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:735 · Level 0 · class (internal sealed)

  • What it is: the intermediate row a non-string lookup projection materializes: the entity's identifier plus the name property carried in its own CLR type, rather than pre-formatted to a string (EFReadRepository.cs:727-742). Declared at file scope in EFReadRepository.cs, alongside the class it serves, rather than nested private the way GroupedCount<TKey> and GroupedSum<TKey> are: EFReadRepository<TEntity, TIdentifierType>'s ExecuteRawLookupAsync<TName> is public static and needs LookupRow<TId, TName> in its own signature, which a private nested type could not appear in.
  • Depends on: nothing first-party. TId and TName are the caller's identifier and raw name types, closed over by EFReadRepository<TEntity, TIdentifierType>'s BuildLookupSelector (EFReadRepository.cs:354) and ExecuteRawLookupAsync<TName> (:282).
  • Concept, why the projection stops short of a string. [Rubric §12, Performance and Scalability] assesses whether a translation failure is designed out rather than caught after the fact. GetAllForLookupAsync's selector used to call ToString() inside the Select for a non-string name property, which has no server-side translation for a value-object property (an Email, say): appending it to the projection threw InvalidOperationException for the whole query and surfaced as an HTTP 500 on a property QueryFieldService had already approved as sortable. LookupRow<TId, TName> is the fix: the projection stops at the raw value, in whatever CLR type EF materializes it through the property's own value converter, and the formatting to a display string happens in memory afterwards, in ExecuteRawLookupAsync<TName>, where ToString() is plain C# rather than an expression tree a provider has to translate.
  • Walkthrough
    • Id (EFReadRepository.cs:738): the entity identifier, TId, defaulted to default! like the other lightweight projection carriers in this file.
    • Name (EFReadRepository.cs:741): the raw name value, TName?, exactly as the provider materialized it, with no coalescing or formatting applied at this stage.
  • Why it's built this way: a generic carrier rather than one record per name type is what lets BuildLookupSelector build the projection once per (TEntity, TName) pair by reflection (Expression.MemberInit over Expression.New(rowType), EFReadRepository.cs:354-358) without a compile-time-known type for every possible name property; sealed and two auto-properties keep it a pure carrier with nothing else to reason about.
  • Where it's used: only inside EFReadRepository.cs. BuildLookupSelector closes LookupRow<,> over (TIdentifierType, nameAccess.Type) for a non-string name property (EFReadRepository.cs:354); ExecuteRawLookupAsync<TName> takes the resulting Expression<Func<TEntity, LookupRow<TIdentifierType, TName>>> as its selector parameter (:282), projects, orders and caps with it, then formats each row's Name to the returned BaseLookup<TIdentifierType> (:292-296).
  • Caveats / not-in-source: no test file targets LookupRow<TId, TName> by name; it is exercised indirectly through EFReadRepositoryLookupProjectionTests.cs (listed among EFReadRepository<TEntity, TIdentifierType>'s usage sites) whenever a non-string name property is looked up.

ValueHolder<T>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/KeysetQueryBuilder.cs:291 · Level 0 · class (private sealed, nested)

  • What it is: a single-field carrier declared private sealed class ValueHolder<T>(T value) inside KeysetQueryBuilder, whose only member is a get-only Value property assigned from the primary constructor (KeysetQueryBuilder.cs:291-295).
  • Depends on: nothing. T is the caller's boundary-value type, supplied by KeysetQueryBuilder.Capture.
  • Concept introduced, making a literal look like a captured local so EF parameterizes it. [Rubric §12, Performance and Scalability] assesses whether a hot query path reuses compiled plans instead of minting a fresh one per call. A bare Expression.Constant is translated by every EF provider as an inlined literal, which is wrong for a keyset cursor boundary: the value changes on every page of every cursor, so each page would get its own statement text, missing EF's compiled-query cache and filling the server's plan cache with thousands of single-use plans. Reading the value off a member of a constant holder instance is the same expression shape the C# compiler emits for a captured local, so EF's parameter extraction evaluates the subtree once and binds the result as a query parameter instead, leaving the statement text identical across pages.
  • Walkthrough
    • Value (KeysetQueryBuilder.cs:294): the one carried value, assigned once from the constructor parameter, never mutated.
  • Why it's built this way: a whole class for one field is deliberate rather than minimal: KeysetQueryBuilder.Capture needs a MemberExpression reading a property off a ConstantExpression, and a generic single-property class is the smallest shape that produces one, closed over the boundary's declared type via MakeGenericType.
  • Where it's used: only by KeysetQueryBuilder.Capture(value, type), which constructs one instance through Activator.CreateInstance and returns Expression.Property reading its Value member; both BuildSeekPredicate boundary constants (the identifier boundary and the sort-key boundary) go through Capture rather than a bare Expression.Constant.
  • Caveats / not-in-source: private and nested, so it has no existence outside KeysetQueryBuilder; the null-boundary comparisons deliberately bypass it and keep real Expression.Constant(null, ...) nodes, because IS NULL is a shape EF must see as a constant, not a parameterized value.

WakeUpSignal

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Polling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Polling/WakeUpSignal.cs:16 · Level 0 · class

  • What it is: the shared wake-up primitive behind both queue signals: an internal, single-slot SemaphoreSlim(0, 1) wrapper used purely as a flag, not a resource lock.
  • Depends on: System.Threading.SemaphoreSlim, IDisposable.
  • Concept: the semaphore is capped at one permit because a drain cycle empties a whole batch per pass, so a burst of signals carries no more information than a single one; extracting it here lets both queue signals share the exact same wake-up mechanics instead of each owning a copy.
  • Walkthrough: _semaphore = new(0, 1) (WakeUpSignal.cs:27); Signal() (WakeUpSignal.cs:30-40) releases the semaphore and swallows SemaphoreFullException, because a wake-up already pending means "one batch drains everything: nothing to add"; WaitAsync(timeout, cancellationToken) (WakeUpSignal.cs:37-47) awaits the semaphore with the given timeout and re-throws OperationCanceledException only when the token itself requested cancellation, so shutdown propagates but a plain timeout returns normally; Dispose() (WakeUpSignal.cs:50) disposes the semaphore.
  • Why it's built this way: pulling this out of InternalCommandSignal means the internal-commands queue and the outbox share one tested wake-up implementation instead of two independent copies of the same semaphore dance.
  • Where it's used: wrapped by InternalCommandSignal (2 sites) and by OutboxSignal (2 sites, MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxSignal.cs).

UpdatePropertySetterBuilder<TEntity>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/UpdatePropertySetterBuilder.cs:14 · Level 1 · class (internal sealed)

  • What it is: the recorder that sits behind the persistence-agnostic IUpdatePropertySetter<TEntity>. Application code describes a set-based update as a series of Set(property, value) calls; this class collects those calls as deferred actions and replays them onto EF Core 10's UpdateSettersBuilder<TSource> at the moment ExecuteUpdateAsync runs (UpdatePropertySetterBuilder.cs:7-14).
  • Depends on: IUpdatePropertySetter<TEntity> (the Application-layer contract it implements, declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUpdatePropertySetter.cs:13), System.Linq.Expressions for the property selectors, and EF Core's Microsoft.EntityFrameworkCore.Query.UpdateSettersBuilder<TSource> (UpdatePropertySetterBuilder.cs:1-3).
  • Concept introduced, the adapter that keeps EF Core out of the Application layer. [Rubric §3, Clean Architecture] assesses whether the inner layers stay free of infrastructure types, and [Rubric §2, Design Patterns] assesses whether a named pattern is used where it earns its place. EF's ExecuteUpdate API takes a lambda over an EF-owned builder type. Exposing that lambda directly would put Microsoft.EntityFrameworkCore in the signature of every Application-layer command that wants a set-based update, which is precisely the dependency the layer rules forbid. The fix is a two-step: the Application layer sees only IUpdatePropertySetter<TEntity>, and this Infrastructure class buffers each described assignment as an Action<UpdateSettersBuilder<TEntity>> (UpdatePropertySetterBuilder.cs:16) that is not executed until EF hands over a real builder. The expression trees themselves cross the boundary unchanged, because Expression<Func<TEntity, TProperty>> is a BCL type, not an EF type. [Rubric §12, Performance and Scalability] also applies: ExecuteUpdate is the set-based path that updates matching rows in one statement without loading or tracking them, which is what makes the adapter worth having at all.
  • Walkthrough
    • Two fields, both collection-initialized: _assignments holds the deferred replay actions in call order, and _assignedProperties is the set of top-level property names already assigned (UpdatePropertySetterBuilder.cs:16-17).
    • Set<TProperty>(property, value) (UpdatePropertySetterBuilder.cs:20-28) is the constant-value overload. It null-guards the selector, records the property name, appends a closure calling builder.SetProperty(property, value), and returns this so calls chain fluently.
    • Set<TProperty>(property, valueFactory) (UpdatePropertySetterBuilder.cs:31-40) is the computed-value overload: the second argument is itself an expression over the entity, which is how SetProperty(x => x.Count, x => x.Count + 1) style updates are expressed. Same guards, same recording, same fluent return.
    • IsEmpty (UpdatePropertySetterBuilder.cs:43) reports whether anything was described at all. EFRepository<TEntity, TIdentifierType> turns a true here into an ArgumentException rather than issuing a no-op UPDATE (EFRepository.cs:138-139).
    • SetsProperty(propertyName) (UpdatePropertySetterBuilder.cs:49) is the audit-stamping guard: it answers "did the caller already assign this column?" so the automatic LastModifiedOn and LastModifiedBy stamp never overwrites an explicit value.
    • Apply(builder) (UpdatePropertySetterBuilder.cs:52-58) is the replay: iterate the recorded actions, invoke each against EF's real setters builder. It is passed as a method group straight into EF (EFRepository.cs:159).
    • TrackPropertyName (UpdatePropertySetterBuilder.cs:60-66) only records a name when the selector body is a MemberExpression, which is the shape of a simple e => e.Property lambda. A more complex selector contributes no name, so SetsProperty answers false for it and the audit stamp is applied. That is the safe direction of the two.
  • Why it's built this way: recording rather than adapting live is what makes the type useful in two directions at once. The Application layer never sees EF, and the repository gets a chance to inspect and augment the described update before it is issued, which is exactly what the audit stamping in EFRepository<TEntity, TIdentifierType> needs. internal sealed keeps the class invisible outside the Infrastructure assembly: consumers only ever hold the interface. The repository plus specification contract this member belongs to is recorded in ADR-055.
  • Where it's used: constructed once per call in EFRepository.ExecuteUpdateAsync (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:136), fed by the caller's Action<IUpdatePropertySetter<TEntity>> (:114), interrogated for the audit stamp (:120, :126), and finally replayed into EF (:131). The profiled path forwards the same delegate through EFRepositoryDecorator<TEntity, TIdentifierType> (EFRepositoryDecorator.cs:58-63).
  • Caveats / not-in-source: the framework's own set-based updates in the outbox and the scheduler call EF's ExecuteUpdateAsync directly on a DbSet rather than through this abstraction (for example MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxFinalizer.cs:41), which is legitimate because those types are already inside Infrastructure. The abstraction exists for the Application layer, so do not read those call sites as the intended usage pattern.

QueryTags

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/QueryTags.cs:18 · Level 2 · class (internal static)

  • What it is: the shared helper that composes and applies EF Core query tags (TagWith), so a generated SQL statement can be traced back to the use case and the query-builder detail that produced it (QueryTags.cs:18-68).
  • Depends on: QueryTagScope for the ambient use-case half of the tag (Tag<TEntity>, QueryTags.cs:42), and EF Core's IQueryable<T>.TagWith extension. No other framework types.
  • Concept introduced, a two-half tag composed from an ambient scope and a local detail. [Rubric §13, Observability and Operability] assesses whether a slow query in a provider's query store can be traced back to the code that issued it. A tag has two independent halves: the ambient use case (set by QueryTagScope around a handler) and a detail the query-building code itself knows, such as which specification or which keyset sort ran. Compose (QueryTags.cs:55-67) handles all four presence combinations, joining both halves with a space when both are known, using whichever one is known alone, and returning null when neither is (:60-66). The null case is deliberate rather than an omission: Tag<TEntity> (:39-47) checks for it explicitly because EF throws on an empty tag, so an untagged query is the correct outcome, not a bug to guard against (:44-46).
  • Walkthrough
    • HandlerPrefix = "handler:" (QueryTags.cs:21), SpecificationPrefix = "spec:" (:24), and KeysetPrefix = "keyset:" (:27) are the three internal const prefixes that make a tag's origin recognizable at a glance in a query store.
    • Tag<TEntity>(query, detail) (QueryTags.cs:39-47) composes the ambient scope with the caller's already-prefixed detail and applies TagWith when the result is non-null, otherwise returns the query untouched.
    • Compose(handlerName, detail) (QueryTags.cs:55-67) is the pure string-composition function, kept separate from Tag so it is trivially unit-testable without a real IQueryable.
  • Why it's built this way: centralizing composition here is what lets SpecificationEvaluator and KeysetQueryBuilder tag their queries with the same prefix vocabulary rather than each inventing its own string format, so a query store search for spec: or keyset: finds every occurrence regardless of which builder produced it.
  • Where it's used: SpecificationEvaluator.Apply tags with SpecificationPrefix + specification.GetType().Name (SpecificationEvaluator.cs:52-54); KeysetQueryBuilder.ApplyOrdering tags with KeysetPrefix + (sortProperty?.Name ?? "Id") (KeysetQueryBuilder.cs:71); EFReadRepository<TEntity, TIdentifierType> reaches both through the two composition helpers rather than calling QueryTags directly.
  • Caveats / not-in-source: internal, so it is not part of the framework's public surface; a host wanting its own tag vocabulary composes its own prefix strings before calling TagWith directly.

PollingLoop

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Polling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Polling/PollingLoop.cs:12 · Level 3 · class (static)

  • What it is: the smart-wait polling loop extracted out of the outbox and internal-commands processors so both share one implementation: run cycles until cancelled, re-poll immediately when a cycle reports more work, otherwise wait on a signal bounded by a computed smart wait; plus the retry-backoff and per-source fan-out helpers both processors also share.
  • Depends on: System.TimeProvider, TenantDataSourceTarget.
  • Concept: this is the smart-wait loop, backoff formula, and per-target fan-out originally described under InternalCommandProcessor/OutboxProcessor, now factored into one shared static class so the two processors cannot drift out of sync on the same shape. [Rubric §12: Performance & Scalability] still applies here directly: ComputeWaitTime is what turns a fixed poll into a smart wait, and ComputeRetryBackoffSeconds's jitter (PollingLoop.cs:193) is what keeps a batch that failed together from retrying in lockstep against the same dependency.
  • Walkthrough: StartupDelay (PollingLoop.cs:18, 5 seconds) and MinimumWait (PollingLoop.cs:21, 1 second) are the shared constants; RunAsync (PollingLoop.cs:40-91) waits StartupDelay, returns via onNoTargets when hasTargets() is false, then loops calling runCycle, re-polling immediately on HasMoreWork and otherwise awaiting waitForSignal for ComputeWaitTime's result, with a cycle exception reported through onCycleError and treated as no pending work; ComputeWaitTime (PollingLoop.cs:104-122) returns the fallback pollingInterval when earliestUpcoming is null, otherwise earliestUpcoming + processingDelay - utcNow, floored at MinimumWait and capped at pollingInterval; DrainAllAsync (PollingLoop.cs:135-171, takes an IReadOnlyList<TenantDataSourceTarget> (PollingLoop.cs:136) plus a drainSource delegate) aggregates HasMoreWork (OR), EarliestUpcoming (min) and a summed PendingDepth across targets, isolating one source's exception via onSourceError from the others; ComputeRetryBackoffSeconds (PollingLoop.cs:183-197) computes base * 2^(attempts-1), clamps the exponent at 16 before Math.Pow (PollingLoop.cs:188, so a future settings change cannot overflow it), applies [0.8, 1.2] jitter BEFORE the cap so a capped backoff lands exactly at the ceiling, then caps at capSeconds.
  • Why it's built this way: the two callers (internal-commands, outbox) had nearly identical loop, wait-time, drain, and backoff code; extracting it here is a straight de-duplication, not a new abstraction over a different concept, so each caller keeps its own settings, signal, and per-source work delegate and passes them in as parameters.
  • Where it's used: OutboxProcessor.cs (7 sites) and InternalCommandProcessor (InternalCommandProcessor.cs, 5 sites).

SpecificationEvaluator

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/SpecificationEvaluator.cs:21 · Level 4 · class (internal static)

  • What it is: the one place a specification becomes an IQueryable<T>. It always applies the specification's criteria, and when the specification is a QuerySpecification<TEntity, TIdentifierType> it also applies that specification's includes, ordering, and paging (SpecificationEvaluator.cs:11-21).
  • Depends on: ISpecification<TEntity, TIdentifierType> and QuerySpecification<TEntity, TIdentifierType> for the input shape, OrderExpression for each ordering key, IBaseEntity<TIdentifierType> as the entity constraint (SpecificationEvaluator.cs:44), QueryTags for tagging the query with the specification's type name, EF Core's Include and AsSplitQuery extensions, plus System.Reflection and System.Collections.Concurrent for the two caches (:1-6).
  • Concept introduced, the evaluator half of the specification pattern. [Rubric §2, Design Patterns] assesses whether patterns are implemented completely rather than named; the specification pattern has two halves, a declarative object describing what to select and an evaluator that turns it into a provider query, and this class is the second half. [Rubric §3, Clean Architecture] also applies: the specification types live in the Domain layer and know nothing about EF, so all EF knowledge is concentrated here. The most important thing to internalize is stated in the class doc at SpecificationEvaluator.cs:15-19, and it is a boundary, not an omission. Tracking and soft-delete scope are deliberately not applied here. Those two choices select the base queryable (Table versus TableNoTracking, with or without the named soft-delete filter dropped), which only a repository holding the DbContext can do. The evaluator composes on top of whatever base it is handed, which is why BaseQueryFor lives in EFReadRepository<TEntity, TIdentifierType> (EFReadRepository.cs:575-584) and not here.
  • Walkthrough
    • Apply<TEntity, TIdentifierType>(source, specification, applyShape = true) (SpecificationEvaluator.cs:40-69) is the entry point. It applies specification.Criteria, then tags the resulting query through QueryTags with SpecificationPrefix + specification.GetType().Name (:52-54), tagging first so the label survives whatever shape composes on top of it, because EF carries tags on the query rather than on the operator they were attached to (:50-51). It then returns early when either applyShape is false or the specification is not a QuerySpecification (:56-57). Otherwise it layers includes, then ordering, then Skip when it is a positive integer, then Take (:59-66). Skip before Take, ordering before both: that is the only order in which paging is meaningful.
    • The applyShape parameter earns its own doc paragraph (SpecificationEvaluator.cs:30-35) and it is a real correctness argument, not a micro-optimization. Aggregate reads such as count and exists pass false, because joining includes in to count rows costs a join per navigation, and counting "page 3 of the matches" is never what a caller means.
    • ApplyIncludes<TEntity>(query, includes) (SpecificationEvaluator.cs:85-102) applies each non-blank dot-separated path with EF's string-based Include (:87-91), tracking whether any path touches a collection navigation, and switches the whole query to AsSplitQuery() when one does (:93). That is the cartesian-explosion guard: two sibling collections loaded in one JOIN multiply rows, so EF issues separate queries instead.
    • ApplyOrdering<TEntity>(query, orderBy) (SpecificationEvaluator.cs:113-127) walks the ordering keys in order and delegates each to ApplyOrderingStep, passing isFirst: i == 0 (:113-116). An empty list leaves the query untouched, which preserves any ordering the caller already applied (:98-99).
    • ApplyOrderingStep<TEntity>(query, keySelector, descending, isFirst) (SpecificationEvaluator.cs:138-158) is where the untyped LambdaExpression is bound back to a concrete key type. The four-way (isFirst, descending) switch picks one of OrderBy, OrderByDescending, ThenBy, or ThenByDescending (:140-146), then closes the cached MethodInfo over (TEntity, keySelector.ReturnType) and invokes it (:148-149). This is the member KeysetQueryBuilder reuses, which is why the specification path and the keyset path can never produce differently-shaped ORDER BY clauses.
    • Two static caches keep the reflection cost off the hot path. OrderingMethods (SpecificationEvaluator.cs:164-178) resolves the four two-generic-argument, two-parameter Queryable overloads exactly once at type initialization; the per-call cost is a dictionary hit plus a MakeGenericMethod. CollectionIncludeCache (:177) is a ConcurrentDictionary keyed by (entity type, include path) memoizing the collection-navigation walk in IsCollectionNavigationPath (:179-197).
    • IsCollectionNavigationPath walks the path segment by segment. An unknown segment returns false and defers to EF's own include validation rather than guessing (SpecificationEvaluator.cs:194-195); a segment whose type is IEnumerable but not string returns true (:190-191). The string exclusion matters: string implements IEnumerable<char> and would otherwise force every string-valued path into split-query mode.
  • Why it's built this way: internal static with no state beyond two caches makes it trivially safe to share across every repository instance in the process. The <remarks> at SpecificationEvaluator.cs:77-80 records the deliberate consolidation: EFReadRepository.ApplyIncludes delegates here rather than duplicating the logic, so the string-include path and the specification path cannot drift apart. The overall contract is ADR-055; the expectation that a specification's criteria translate on more than one engine is ADR-018.
  • Where it's used: exclusively inside this group. EFReadRepository<TEntity, TIdentifierType> calls Apply from FirstOrDefaultAsync(specification) (EFReadRepository.cs:126-127), from CountAsync(specification) with applyShape: false (:348-349), from both ListAsync overloads (:457-458, :474-475), and from GetPageByCursorAsync again with applyShape: false (:516); its ApplyIncludes is a thin forwarder to this class (:426-429). KeysetQueryBuilder calls ApplyOrderingStep three times (KeysetQueryBuilder.cs:77, :73, :74). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/SpecificationEvaluatorTests.cs and .../EFReadRepositorySpecificationTests.cs.

KeysetQueryBuilder

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/KeysetQueryBuilder.cs:22 · Level 5 · class (internal static)

  • What it is: the expression-tree factory for keyset (also called seek) pagination. It resolves the requested sort column, builds the (sortKey, Id) ordering, builds the seek predicate that selects the rows strictly after a cursor's boundary row, and renders and parses cursor values with invariant culture (KeysetQueryBuilder.cs:9-22).
  • Depends on: IBaseEntity<TIdentifierType> for the Id member it orders and seeks on (KeysetQueryBuilder.cs:66, :67), SpecificationEvaluator for the actual OrderBy and ThenBy calls, QueryTags for tagging a keyset page in the generated SQL, its own private ValueHolder<T> for parameterizing cursor boundary values, and the BCL: System.Linq.Expressions, System.Reflection, System.Globalization, and System.ComponentModel.TypeDescriptor (:1-5).
  • Concept introduced, keyset pagination and why the tie-break is mandatory. [Rubric §12, Performance and Scalability] assesses whether reads stay cheap as the table grows, and [Rubric §9, API and Contract Design] assesses whether the paging contract a client sees is honest. Offset paging (OFFSET 40000 ROWS FETCH NEXT 20) makes the database count past every skipped row, so page 2000 costs far more than page 20. Keyset paging instead orders by (sortKey, Id) and asks for the rows after the last row of the previous page, which the database can satisfy with a single index seek regardless of depth. The class doc states the second half of the idea, which is the part people skip (KeysetQueryBuilder.cs:12-15): the tie-break on Id is what makes the order total. Without it, two rows sharing a sort value can swap places between requests and be returned twice or never. The restriction to exactly one sort key is also deliberate and documented (:18-20): multi-key keyset paging needs a comparison whose size grows quadratically in the key count, and every provider translates it differently.
  • Walkthrough
    • TryResolveSortProperty<TEntity>(sortColumn, out property) (KeysetQueryBuilder.cs:35-47) returns true with a null property when no column was requested (paging by Id alone, :39-40), and otherwise does a case-insensitive public-instance property lookup (:42-44). A false return means the client named something that does not exist, which the caller turns into a validation failure rather than an exception.
    • ApplyOrdering<TEntity, TIdentifierType>(query, sortProperty, descending) (KeysetQueryBuilder.cs:62-82) first tags the query through QueryTags with KeysetPrefix + (sortProperty?.Name ?? "Id") (:71), so every keyset page, specification-driven or not, names itself in the generated SQL. It then builds an e => e.Id selector from the shared parameter (:73-74), then either orders by Id alone in the requested direction (:76-77) or orders by the sort key in the requested direction and then by Id always ascending (:79-81). The identifier tie-break always ascends, which is exactly what the seek predicate assumes.
    • BuildSeekPredicate<TEntity, TIdentifierType>(sortProperty, sortValue, lastId, descending, nullsSortFirstAscending = true) (KeysetQueryBuilder.cs:118-180) is the heart of the type. The last parameter (:123) says where the engine puts nulls: SQL Server and SQLite sort them first ascending and last descending, PostgreSQL the reverse (ASC NULLS LAST, DESC NULLS FIRST), as the remarks at :102-106 state. Before any null handling the method folds the engine and the page direction into one flag, nullsFirst = nullsSortFirstAscending ? !descending : descending (:143-144), meaning "null keys come before the non-null ones in this page's traversal order". It then has four branches:
      • No sort key (:131-136): the predicate is Id > lastId, flipped to < when the page descends, because there the identifier is the sort key.
      • Null boundary on a non-nullable key (:148-152): impossible by construction, so it degrades to the plain id seek rather than emitting a comparison against null.
      • Null boundary on a nullable key (:154-162): stated explicitly rather than left to SQL. Where nulls come first in the traversal, what remains is "any non-null value, or a null with a larger id"; where nulls come last, what remains is "a null with a larger id" (:156-160). The remarks at :94-101 give exactly this reasoning, and the reason it is needed: SQL comparisons against NULL are unknown and would silently drop rows.
      • Normal boundary (:165-179): the classic composite sort > v OR (sort == v AND Id > lastId) (:166-168), with the sort half flipped to < for a descending page. When the key is nullable and nulls come last in the traversal (!nullsFirst), an extra sort == null OR (...) is prepended (:170-177), because a comparison against null is unknown and the nulls still ahead would otherwise vanish. On SQL Server and SQLite that is the descending page; on PostgreSQL it is the ascending one.
    • Both boundary constants, the identifier (:129) and the sort key (:165), are built through the private Capture(value, type) (:255-261, reasoning in the remarks at :234-251) rather than a bare Expression.Constant. Capture wraps the value in a ValueHolder<T> instance and returns a MemberExpression reading its Value property, which is the same shape the compiler emits for a captured local, so EF's parameter extraction binds it as a query parameter instead of inlining it as a literal. The null-boundary comparisons deliberately keep real Expression.Constant(null, ...) nodes instead, because IS NULL is a shape, not a value that changes per page.
    • ToInvariantString(value) (KeysetQueryBuilder.cs:189-199) renders a value for the cursor. The four date and time types use the round-trip "O" format specifically so a cursor never loses sub-second precision and re-seeks onto the wrong row (:192-195); strings pass through (:196); anything IFormattable gets invariant formatting (:197); everything else falls back to ToString() (:198).
    • TryFromInvariantString(targetType, text, out value) (KeysetQueryBuilder.cs:211-257) is the inverse: unwrap Nullable<T> (:215), short-circuit for string (:216-220), parse DateTime and DateTimeOffset with DateTimeStyles.RoundtripKind (:222-242) so the "O" text ToInvariantString wrote comes back with its kind and offset intact (a UTC stamp stays UTC instead of being converted to the host's local time), otherwise use TypeDescriptor.GetConverter and ConvertFromInvariantString (:244-251, returning false up front when the converter cannot read a string, :245-246), catching only FormatException, NotSupportedException, and ArgumentException and turning them into false (:253-256). A malformed cursor is a validation result, never an exception escaping the repository.
    • Compare(left, right, greaterThan) (KeysetQueryBuilder.cs:308-342) is the provider-translatability layer, and the remarks at :272-282 explain why it cannot just call Expression.GreaterThan: that factory only exists for types that have the operator, which excludes string. So strings compare through string.Compare(string, string), which EF translates into a native > or < (:287-293); types with a relational operator use it directly (:295-300); and anything else with an IComparable<T> implementation (a Guid key, for instance) compares through CompareTo (:302-316), whose translation is provider-specific. A type with none of the three gets a NotSupportedException naming the type (:303-307), which is a loud failure at query-build time rather than a wrong result.
    • SupportsRelationalOperator (KeysetQueryBuilder.cs:344-353) enumerates the primitive, enum, decimal, date and time, and TimeSpan cases, and falls back to reflecting for a public static op_GreaterThan. StringCompareMethod and ZeroConstant (:330-333) are resolved once as statics.
  • Why it's built this way: keyset paging is declared as a repository-level capability, deliberately not part of the HTTP query contract of ADR-034, and ADR-055 records that split along with the cursor format. Building the predicate as an expression tree rather than as SQL text is what keeps the same code working on more than one provider (ADR-018); reusing SpecificationEvaluator's ordering step rather than reimplementing the ordering call means the keyset ORDER BY and the specification ORDER BY are produced by the same code.
  • Where it's used: only by EFReadRepository<TEntity, TIdentifierType>'s GetPageByCursorAsync, which calls TryResolveSortProperty (EFReadRepository.cs:639), ApplyOrdering (:669), ToInvariantString twice when encoding the next cursor (:684-685), and, through the private TryBuildSeekPredicate, TryFromInvariantString (:706, :715) and BuildSeekPredicate (:720-721), passing nullsSortFirstAscending from the engine behind the context. Covered by EFReadRepositoryKeysetPagingTests.cs under MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Repositories/Read/, and by KeysetQueryBuilderSqlTests.cs and KeysetQueryBuilderNullOrderingTests.cs under MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Repositories/.
  • Caveats / not-in-source: the NotSupportedException for an unorderable key type and the provider-specific translation of CompareTo are both real limits of the design, and neither is discoverable until a query is built for that key type. nullsSortFirstAscending defaults to true (KeysetQueryBuilder.cs:123), so a caller that omits it gets SQL Server and SQLite null placement, which is the wrong side of the boundary for PostgreSQL; the one production caller always passes the engine's value (EFReadRepository.cs:720-721). No first-party call site in MMCA.ADC or MMCA.Store calls GetPageByCursorAsync today (the only references outside MMCA.Common are the test-support fakes in MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Support/TestSupport.cs and its Identity sibling), so which key and sort types have actually been exercised against a real engine is established by the test suite rather than by production usage.

EFReadRepositoryDecorator<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepositoryDecorator.cs:17 · Level 6 · class (internal)

  • What it is: a pass-through decorator over IReadRepository<TEntity, TIdentifierType> that wraps every asynchronous operation in a MiniProfiler timing step, for per-query performance visibility in development (EFReadRepositoryDecorator.cs:10-17).
  • Depends on: IReadRepository<TEntity, TIdentifierType> (both the interface it implements and the inner instance it holds, EFReadRepositoryDecorator.cs:17-23) and ProfilingHelper for the timing steps (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/ProfilingHelper.cs:9, :11-12, :26-30).
  • Concept introduced, the decorator as an opt-in cross-cutting wrapper. [Rubric §2, Design Patterns] assesses whether the decorator pattern is used where behavior must compose without modifying the decorated type, [Rubric §13, Observability and Operability] assesses whether the system can be asked where its time goes, and [Rubric §1, SOLID] covers the open-closed consequence: EFReadRepository<TEntity, TIdentifierType> contains no profiling code and does not know profiling exists. Because both types implement the same interface, the decision to profile is a composition decision made once at construction time, not a branch inside every method. This is the same decorator idea the CQRS pipeline uses for logging, caching, and transactions, applied one layer lower.
  • Walkthrough
    • ClassName is a const set to the string "EFReadRepository", not to the decorator's own name (EFReadRepositoryDecorator.cs:22). That is deliberate: the profiler timeline should name the operation being measured, and the decorator is an implementation detail of measuring it. ProfilingHelper.BeginStep prefixes it with the assembly name to produce the label MMCA.Common.Infrastructure.EFReadRepository: {method} (ProfilingHelper.cs:11-12).
    • _inner is guarded at construction (EFReadRepositoryDecorator.cs:23).
    • Every asynchronous member is one expression-bodied forwarder of the same shape: ProfilingHelper.ProfileAsync(ClassName, nameof(Member), () => _inner.Member(...)). The full set is GetAllAsync (:25-34), GetProjectedAsync (:36-43), both FirstOrDefaultAsync overloads (:45-52, :54-58), CountByAsync (:60-66), SumByAsync (:68-75), FindIncludingDeletedAsync (:77-83), GetAllForLookupAsync (:85-91), GetByIdsAsync (:93-100), both GetByIdAsync overloads (:102-104, :106-108), all three CountAsync overloads (:110-112, :114-116, :118-122), both ExistsAsync overloads (:124-126, :128-130), both ListAsync overloads (:132-136, :138-143), AnyAsync (:145-149), and GetPageByCursorAsync (:151-156). Overloads share one nameof, so they appear under one label in the profile.
    • The four queryable properties are not profiled: Table, TableNoTracking, TableNoTrackingSingleQuery, and TableNoTrackingSplitQuery are plain forwarders (EFReadRepositoryDecorator.cs:158-161). That is correct rather than an omission: those properties only build a queryable, and the time that matters is spent wherever the caller finally materializes it.
    • The class is internal and, unlike its sibling, not sealed, because EFRepositoryDecorator<TEntity, TIdentifierType> derives from it.
  • Why it's built this way: an explicit hand-written decorator over a generic interface with a stable member list keeps the profiling surface obvious and debuggable, at the cost of one forwarder per member. The wrapping is conditional at composition time, so a host with profiling off pays nothing at all, not even a delegate hop. ProfilingHelper.BeginStep itself null-conditionals off MiniProfiler.Current, so even a wrapped call is a no-op when no profiler is running (ProfilingHelper.cs:11-12).
  • Where it's used: applied by RepositoryFactory's CreateReadOnly only when UseMiniProfiler is true on the application settings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:58-63, with the doc stating the condition at :45-49). Extended by EFRepositoryDecorator<TEntity, TIdentifierType>. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/EFReadRepositoryDecoratorTests.cs and .../EFReadRepositoryDecoratorAdditionalTests.cs.

EFRepositoryDecorator<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepositoryDecorator.cs:14 · Level 7 · class (internal sealed)

  • What it is: the read-write half of the profiling decorator. It extends EFReadRepositoryDecorator<TEntity, TIdentifierType> and adds forwarders for the mutation members of IRepository<TEntity, TIdentifierType>: add, update, row-version, execute-delete, and execute-update (EFRepositoryDecorator.cs:7-14).
  • Depends on: IRepository<TEntity, TIdentifierType>, its base class EFReadRepositoryDecorator<TEntity, TIdentifierType>, ProfilingHelper, IRowVersioned for the child-entity concurrency overload (EFRepositoryDecorator.cs:45), and IUpdatePropertySetter<TEntity> in the ExecuteUpdateAsync signature (:56).
  • Concept, decorating a derived contract by passing the same instance twice. [Rubric §1, SOLID]. The declaration is the interesting line: EFRepositoryDecorator(IRepository<...> inner) deriving from EFReadRepositoryDecorator<...>(inner) while implementing IRepository<...> (EFRepositoryDecorator.cs:14-18). The single inner argument is handed to the base constructor as an IReadRepository and stored again in this class's own _inner as an IRepository (:21). One object, two typed references: the inherited read members forward through the base's field, the write members through this one. That works precisely because IRepository<TEntity, TIdentifierType> extends IReadRepository<TEntity, TIdentifierType> and IWriteRepository<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:497), so the read decorator can be reused verbatim rather than re-forwarded.
  • Walkthrough
    • ClassName is "EFRepository" here (EFRepositoryDecorator.cs:20), so writes appear under the read-write repository's label while inherited reads keep the read repository's label.
    • Asynchronous writes follow the base's shape: AddAsync (:23-25), AddRangeAsync (:27-29), UpdateAsync (:31-33), ExecuteDeleteAsync (:48-52), and ExecuteUpdateAsync (:54-59).
    • The one synchronous profiled member uses the other helper shape: UpdateRange opens a using var step = ProfilingHelper.BeginStep(...) and then calls through (EFRepositoryDecorator.cs:35-39).
    • Both SetOriginalRowVersion overloads, the entity one and the IRowVersioned child one, are unprofiled straight pass-throughs (EFRepositoryDecorator.cs:41-46). They only stamp an original value on a change-tracker entry, so there is no I/O to time.
    • TouchConcurrencyToken(entity) (EFRepositoryDecorator.cs:49-50) is a third unprofiled pass-through, forwarding straight to _inner.TouchConcurrencyToken(entity) for the same reason: no I/O to time until the entity is actually saved.
    • sealed, unlike its base: nothing derives from it.
  • Why it's built this way: inheriting the read decorator rather than composing a second one avoids duplicating twenty-one forwarders, and keeps the two class-name labels distinct so a profile distinguishes a read issued through the write repository from one issued through a read-only repository.
  • Where it's used: applied by RepositoryFactory's Create when UseMiniProfiler is true (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:34-39, documented at :21-25). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/EFRepositoryDecoratorTests.cs and .../EFRepositoryDecoratorAdditionalTests.cs.
  • Caveats / not-in-source: there is no Save or SaveChangesAsync forwarder here, and none is missing: flushing is IUnitOfWork's job, not the repository's, so IWriteRepository<TEntity, TIdentifierType> declares no save member at all (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:369-484).

EFReadRepository<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:21 · Level 12 · class (internal)

  • What it is: the EF Core implementation of IReadRepository<TEntity, TIdentifierType>, the read half of the repository contract: get by id, get many, projected reads, first-or-default, grouped counts and sums, soft-delete-aware finds, lookups, counts, existence checks, specification-driven reads, and keyset pages, with no mutation surface at all (EFReadRepository.cs:16-21).
  • Depends on: EF Core's DbContext and DbSet<TEntity>, taken as its one primary-constructor parameter (EFReadRepository.cs:21-33); IReadRepository<TEntity, TIdentifierType> as the contract (declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:332); AuditableBaseEntity<TIdentifierType> as the entity constraint (:24); SpecificationEvaluator, KeysetQueryBuilder, and QueryTags as its composition helpers; ApplicationDbContext for the soft-delete filter name (:41) and for the engine behind the context, whose DataSourceEngineCapabilities drive the existence check and keyset null ordering (:513-514); EntityQueryPipeline.MaxUnboundedResultLimit for the lookup row ceiling; LookupRow<TId, TName> as the intermediate projection shape for a non-string lookup name; and Result, Error, KeysetPageRequest, KeysetCollectionResult<T>, KeysetCursor, and BaseLookup<TIdentifierType> for the shapes it returns.
  • Concept introduced, naming the query filter you drop. [Rubric §11, Security] assesses whether isolation boundaries hold under every code path, and [Rubric §8, Data Architecture] assesses whether the query-filter design is deliberate. EF 10 gives global query filters names, and the framework registers two on the model: SoftDelete and Tenant (ADR-073). EF's parameterless IgnoreQueryFilters() drops all of them. That means a caller who only wanted to see soft-deleted rows would, with the parameterless call, also read across every tenant. So the repository declares a one-element array naming exactly the filter it is willing to drop (EFReadRepository.cs:35-41, resolving ApplicationDbContext.SoftDeleteFilterName from MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:475) and every ignoreQueryFilters: true path passes it (:58, :87, :108, :204, :394, :471, :485, :582). This is the kind of detail worth copying wherever you add a filter-bypassing read.
  • Concept, tracked versus no-tracking as an explicit repository decision. [Rubric §12, Performance and Scalability]. The four queryable properties at EFReadRepository.cs:526-535 are the vocabulary: Table (tracked), TableNoTracking, TableNoTrackingSingleQuery, and TableNoTrackingSplitQuery. Read paths default to no-tracking to avoid change-tracker overhead, but two exceptions are load-bearing and both are commented in place, see the walkthrough below.
  • Walkthrough
    • MaxLookupSelectorCacheEntries = 512 (EFReadRepository.cs:31) bounds the lookup-selector cache; past it a selector is built per request rather than cached, which the source calls correct and only slightly slower.
    • The protected _context field is guarded at construction (EFReadRepository.cs:33), and Entities is a virtual property resolving _context.Set<TEntity>() (:43). Every member goes through one of those two, which is what makes the class subclassable by EFRepository<TEntity, TIdentifierType>.
    • The private BaseQuery(asTracking) helper (EFReadRepository.cs:550-551) picks Table or TableNoTracking and tags the result through QueryTags with the ambient use-case tag. It backs every specification-less read (GetAllAsync, GetProjectedAsync, FirstOrDefaultAsync(where, ...), FindIncludingDeletedAsync, GetAllForLookupAsync, GetByIdsAsync, GetByIdAsync(id, includes, asTracking), and a specification-less keyset page); CountByAsync and SumByAsync call it with asTracking: false explicitly. Its doc (:537-547) records why Table and TableNoTracking stay untagged: they are the public, overridable extension point a consumer composes its own queries on, and the specification path is tagged inside SpecificationEvaluator instead, so no query is tagged twice.
    • GetAllAsync (EFReadRepository.cs:46-72) is the widest read: pick tracked or untracked (:55), optionally drop the named soft-delete filter (:57-58), apply includes (:60), then optional where (:62-63), then optional orderBy (:65-66), then materialize with or without a select (:68-71). Every parameter is optional, which is what makes it the general-purpose read the older query paths call.
    • GetProjectedAsync<TResult> (EFReadRepository.cs:75-93) is the projection-first read: no includes at all, because a projection selects its own columns, and the Select is applied in the database rather than after materialization (:92).
    • FirstOrDefaultAsync(where, includes, ...) (EFReadRepository.cs:96-115) is the predicate overload, and the comment at :113 records the point: the predicate goes into EF's FirstOrDefaultAsync so the database issues a TOP 1, rather than materializing a set and narrowing it in memory.
    • FirstOrDefaultAsync(specification) (EFReadRepository.cs:118-130) is the specification overload, and unlike the aggregate reads it applies the full shape, ordering included. The comment at :124-125 is the reason: "first" is only meaningful against a defined order, and the specification is where that order is declared.
    • CountByAsync<TKey> (EFReadRepository.cs:133-153) and SumByAsync<TKey> (:156-181) are the two grouped aggregates. Both run untracked, apply the optional predicate, group in the database, and project into the private records GroupedCount<TKey> (:148) and GroupedSum<TKey> (:176) before flattening to a dictionary (:152, :180). The comment at :171-173 explains why SumByAsync passes the sum selector as GroupBy's element selector: it keeps the caller's expression tree intact all the way to the provider.
    • FindIncludingDeletedAsync (EFReadRepository.cs:194-220) returns a named tuple of active and soft-deleted matches. The comment at :202-203 is the design point: it drops the soft-delete filter and reads once, then partitions in memory (:211-217), because two separate queries would let a concurrent delete land between them and report the same row in neither half.
    • GetAllForLookupAsync (EFReadRepository.cs:223-262) returns id and name pairs ordered by name, where "name" is a runtime property name. GetOrBuildLookupSelector (:321-342) returns an untyped LambdaExpression rather than a fixed Expression<Func<TEntity, BaseLookup<TIdentifierType>>>, because BuildLookupSelector (:344-375) builds one of two projection shapes depending on the resolved property's CLR type: a string property projects straight to BaseLookup<TIdentifierType> with its name coalesced to empty (:366-374), while anything else projects to LookupRow<TId, TName> carrying the raw value (:350-364), because appending ToString() to the projection has no server-side translation for a value-object property and throws InvalidOperationException for the whole query (the comment at :251-255). GetAllForLookupAsync pattern-matches the returned LambdaExpression (:241): the string leg runs Select/OrderBy/Take inline (:243-248); the non-string leg resolves the raw name's CLR type by reflection (:256) and dispatches, through the cached ExecuteRawLookupMethod (:268-269), to the public static ExecuteRawLookupAsync<TName> (:281-298), which runs the same ordered, capped projection against LookupRow<TIdentifierType, TName> and formats each row's name in memory afterward (:293-297). Both legs keep .Take(EntityQueryPipeline.MaxUnboundedResultLimit) (:246, :289) BEFORE materialization, and the comment above the branch names why (:236-240, tagged SEC-Common-24): this path never enters EntityQueryPipeline, so the framework's row ceiling has to be applied here or a lookup stays the one unpaginated, uncapped read in the framework. Underneath, GetOrBuildLookupSelector first resolves the property name to its CANONICAL spelling (:325-327, the SEC-Store-14 remark at :306-313) before keying LookupSelectorCache (:314), checks the cache (:331-332), builds uncached rather than admitting a new entry once MaxLookupSelectorCacheEntries is reached (:336-337), and otherwise memoizes through GetOrAdd (:339-341). Canonicalizing the name first is a fix, not a cosmetic change: reflection resolves a property name case-insensitively, so every case permutation of a real column used to mint its own never-evicted cache entry, 2^N spellings of an N-letter name, from an anonymous endpoint; resolving the PropertyInfo first collapses them onto one entry.
    • GetByIdsAsync (EFReadRepository.cs:378-400) materializes the id sequence once, short-circuits on an empty set with an empty result (:387-389), and translates to a single Contains (in other words a SQL IN) rather than N round trips (:399).
    • GetByIdAsync(id) (EFReadRepository.cs:403-415) carries the single most important comment in the file (:409-413) and it encodes two separate bug fixes. First, it is a filtered query, not FindAsync: FindAsync serves a tracked instance straight out of the identity map without evaluating the global soft-delete filter, so an entity soft-deleted earlier in the same scope came back as if it were live. Second, it queries Table (tracked) on purpose: EFRepository<TEntity, TIdentifierType> inherits this member and the generic delete and update handlers load through it, mutate the instance, and save, so a no-tracking query would turn those writes into silent no-ops.
    • GetByIdAsync(id, includes, asTracking) (EFReadRepository.cs:418-431) is the include-carrying overload and defaults to no-tracking, because a caller asking for includes is normally reading for display.
    • The three CountAsync overloads (EFReadRepository.cs:434-458) cover no predicate, an expression predicate, and a specification. The specification overload composes through SpecificationEvaluator with applyShape: false (:455), so includes, ordering, and paging never reach a COUNT.
    • The two ExistsAsync overloads (EFReadRepository.cs:463-488) both funnel into the private AnyAsync helper (:500-506), which is engine-aware. The remarks at :490-499 are worth reading before you touch it: the non-relational engine (Cosmos DB) gets CountAsync because its provider generates invalid SQL (an unresolved root identifier) when translating a predicated AnyAsync into a subquery; every relational engine gets AnyAsync, which short-circuits at the first match. The cost of the workaround is stated honestly in the same remark: CountAsync reads every matching row, so on a wide predicate the Cosmos path is proportional to the number of matches. The switch is a capability, not a provider-name test: EngineCapabilities (:513-514, private protected so EFRepository<TEntity, TIdentifierType> can read the same capabilities for its row-version stamp) reads DataSourceEngineCapabilities off the ApplicationDbContext's engine, and TranslatesAny (:517) is its IsRelational flag. A plain DbContext that is not a framework context (a directly constructed test double) yields null, which the doc at :508-512 reads as a relational engine that sorts nulls first, so both TranslatesAny and NullsSortFirstAscending (:523) fall back to true.
    • ApplyIncludes (EFReadRepository.cs:562-565) is a protected static forwarder to SpecificationEvaluator, including the collection-navigation split-query auto-switch. The doc at :553-561 says why: the logic lives once so the string-include path and the specification path cannot drift apart.
    • BaseQueryFor (EFReadRepository.cs:575-584) is the boundary SpecificationEvaluator refuses to cross. It reads AsTracking and IgnoreQueryFilters off a QuerySpecification<TEntity, TIdentifierType> when the specification is one, and gives a plain ISpecification the untracked, filtered default.
    • The two ListAsync overloads (EFReadRepository.cs:587-615) apply the specification to that base. In the projecting overload the comment at :608-609 records the ordering rule: Select comes last, so ordering and paging run over entity rows and only the resulting page is projected.
    • AnyAsync(specification) (EFReadRepository.cs:618-629) uses criteria only, through the same engine-aware existence check the predicate overloads use (:625-628).
    • GetPageByCursorAsync (EFReadRepository.cs:632-689) is the keyset page, and it is the one read that returns a Result rather than a bare value, because two of its failures are caller errors rather than exceptions. An unknown sort column returns Error.InvalidEntityField with the column and type named (:639-648); a malformed cursor returns a validation error with code Error.InvalidCursor (:654-664). Between those it starts from BaseQuery or applies the optional specification with applyShape: false (:650-652), then the seek predicate (:666), and the (sortKey, Id) ordering (:669). The Take(request.PageSize + 1) at :673 is the next-page probe, and the comment at :671-672 explains the choice: the extra row is never returned, it only says whether a next page exists, which is cheaper and more honest than a COUNT over the whole set. The probe row is trimmed (:675-677), and a next cursor is encoded from the last surviving row only when there is more (:679-686).
    • TryBuildSeekPredicate (EFReadRepository.cs:696-724) is the private decode-and-build, an instance method because it reads the engine's null ordering: reject a cursor that fails KeysetCursor.TryDecode (:703-704), reject an id segment that does not parse to TIdentifierType (:706-710), reject a sort segment that does not parse to the sort property's type (:712-718), and otherwise hand the parsed values to KeysetQueryBuilder together with nullsSortFirstAscending: NullsSortFirstAscending (:720-721). That flag is what makes a nullable sort key page correctly on PostgreSQL, which sorts nulls last ascending where SQL Server and SQLite sort them first.
  • Why it's built this way: internal rather than public, and every member virtual, is what lets EFRepository<TEntity, TIdentifierType> extend it with writes while consumers hold only the interface. Concentrating the tracking and filter-scope decisions here and pushing pure composition into SpecificationEvaluator and KeysetQueryBuilder is what keeps this class about policy and those about mechanism. The contract and its evolution are recorded in ADR-055; the interface segregation into IEntityReader<TEntity, TIdentifierType> and IEntityQuerier<TEntity, TIdentifierType> beneath IReadRepository is what makes a read-only consumer unable to write at all (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:21, :80, :332).
  • Where it's used: constructed by RepositoryFactory's CreateReadOnly through a cached ActivatorUtilities factory taking the DbContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:55-56), optionally wrapped in EFReadRepositoryDecorator<TEntity, TIdentifierType> (:58-63). Application code reaches it through IUnitOfWork's GetReadRepository, implemented at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:68-81. Covered by EFReadRepositoryReadSurfaceTests.cs, EFReadRepositoryKeysetPagingTests.cs, EFReadRepositorySpecificationTests.cs, EFReadRepositoryGetByIdFilterTests.cs, EFReadRepositoryProjectedFilterTests.cs, EFReadRepositoryLookupProjectionTests.cs, and EFReadRepositoryLookupSecurityTests.cs under MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Repositories/Read/.
  • Caveats / not-in-source: GetAllForLookupAsync resolves nameProperty by reflection at runtime (EFReadRepository.cs:325-327), so an unknown name fails from the expression build rather than at compile time; the source contains no validation of that argument beyond what Expression.Property itself enforces. It is capped by EntityQueryPipeline.MaxUnboundedResultLimit on both the string and the raw-value leg (:246, :289), so unlike FindIncludingDeletedAsync, which still partitions in memory and materializes every matching row including the deleted ones with no upper bound on that set, a lookup can no longer read an unbounded table.

EFRepository<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:24 · Level 13 · class (internal sealed)

  • What it is: the read-write EF Core repository. It extends EFReadRepository<TEntity, TIdentifierType> with add, update, concurrency-token, and set-based delete and update operations, implementing IRepository<TEntity, TIdentifierType> (EFRepository.cs:11-30). It stages changes and never saves: the class doc says so outright at :13.
  • Depends on: the base read repository, constructed with the same DbContext (EFRepository.cs:25, :27); a required TimeProvider (:25) and one optional constructor dependency, ICurrentUserService (:26); UpdatePropertySetterBuilder<TEntity> for set-based updates (:135); AuditableAggregateRootEntity<TIdentifierType> as the entity constraint (:28); and AuditableBaseEntity<TIdentifierType>, IRowVersioned, and IAuditableEntity for the concurrency and audit member names.
  • Concept introduced, the two write paths and why one of them must stamp its own audit fields. [Rubric §8, Data Architecture] assesses whether audit metadata is reliably captured, and [Rubric §12, Performance & Scalability] assesses whether a cross-cutting policy holds on every path rather than the common one. The framework's normal write path is change-tracked: load, mutate, save, and the save interceptors stamp LastModifiedOn and LastModifiedBy and dispatch domain events. The second path is set-based: ExecuteUpdate and ExecuteDelete issue one SQL statement over matching rows without loading or tracking them, which is far cheaper on a wide update but bypasses the save pipeline entirely, interceptors included. Rather than pretend the two paths are the same, this class closes the gap explicitly for audit fields on ExecuteUpdateAsync (EFRepository.cs:141-155). The class-level <remarks> (:17-22) states what the required TimeProvider and the optional ICurrentUserService are for: both serve ExecuteUpdateAsync's own audit stamping since that path bypasses the save pipeline. The code goes one step further than those remarks: with no current user (no user service, or a background or system scope with no user id), LastModifiedBy is stamped with the identifier type's default sentinel rather than left alone (:148-154), exactly as the save pipeline attributes a system save, because leaving the column untouched would keep crediting the change to the previous human editor.
  • Concept, the optional dependency as a testability affordance. [Rubric §14, Testability]. Only ICurrentUserService defaults to null (EFRepository.cs:27); TimeProvider is a required constructor argument (:25), so a test must pass one explicitly (typically TimeProvider.System or a fake), while production construction through ActivatorUtilities fills both from the container. Taking TimeProvider at all, rather than calling DateTime.UtcNow, is what makes the timestamp deterministic under test, which is exactly what EFRepositoryAuditStampTests exercises.
  • Walkthrough
    • AddAsync and AddRangeAsync (EFRepository.cs:33-44) are thin guarded forwarders to Entities.AddAsync and AddRangeAsync. Nothing is saved here; persistence happens at the unit of work's save.
    • UpdateAsync (EFRepository.cs:53-66) has the one non-obvious body in the mutation set. It first asks the change tracker whether this key is already tracked, and if so copies values onto the tracked entry with CurrentValues.SetValues instead of attaching a second instance, which would throw an "already tracked" exception (:46-51, :58-60). The comment at :56-57 explains the lookup choice: Entities.Local.FindEntry(entity.Id) is an O(1) key lookup against the identity map that never falls back to the database, replacing a linear scan of the LocalView. Untracked entities go through Entities.Update, which attaches and marks modified (:62). The method returns Task.CompletedTask (:64): it is asynchronous only for signature compatibility.
    • UpdateRange (EFRepository.cs:69-73) is the guarded batch forwarder.
    • The two SetOriginalRowVersion overloads (EFRepository.cs:76-95) implement optimistic concurrency by writing the client's known row version into the tracked entry's original value, so EF's generated UPDATE carries it in the WHERE clause and a concurrent modification raises a concurrency exception instead of silently winning. Both null-guard their arguments and then assign unconditionally (:77-82, :88-93). The second overload takes an IRowVersioned child entity and casts to object for Entry (:91), which is how a child of the aggregate gets the same protection without being an aggregate root itself.
    • TouchConcurrencyToken(entity) (EFRepository.cs:98-116) forces a concurrency check on a root that a caller wants to protect even though nothing on it changed. It is a no-op unless the tracked entry's state is exactly Unchanged: a root the applier already modified emits its own UPDATE carrying the concurrency predicate already, and marking a property modified on a Deleted or Detached entry would be wrong rather than merely redundant. When the entry is unchanged, it marks the LastModifiedOn audit property IsModified = true, which is what puts the row in the generated UPDATE at all; the audit interceptor then fills in the real value, so the write is a genuine edit record rather than a no-op touch, and EF appends WHERE RowVersion = @original from the concurrency token.
    • ExecuteDeleteAsync (EFRepository.cs:119-125) is the set-based delete: Entities.Where(where).ExecuteDeleteAsync(...), returning the affected row count (:123).
    • ExecuteUpdateAsync (EFRepository.cs:128-160) is the flagship. It guards both arguments (:132-133), constructs an UpdatePropertySetterBuilder<TEntity> (:135), lets the caller describe the assignments against the persistence-agnostic interface (:136), and throws ArgumentException when nothing was described (:137-138). Then it stamps: LastModifiedOn from timeProvider.GetUtcNow().UtcDateTime unless the caller already set it (:142-146), and LastModifiedBy unless the caller already set it (:151-154), with currentUserService?.UserId ?? default, so a scope with no current user records the default sentinel as the editor (the comment at :148-150). Both guards go through builder.SetsProperty, so an explicit caller assignment always wins. It then calls the private StampRowVersion (:157, defined at :162-176), and finally the recorded assignments are replayed into EF as a method group (:159). StampRowVersion exists because on an engine with no server-generated row version (PostgreSQL, SQLite) the token is written by the audit interceptor, which ExecuteUpdate also bypasses: without a fresh value a client still holding the pre-update token would pass its If-Match check. So when EngineCapabilities?.RowVersion is RowVersionStrategy.ClientStamped (:170, the private protected capability property inherited from EFReadRepository<TEntity, TIdentifierType>), the caller did not set RowVersion (:171), and the EF model actually maps that property for the entity (:172), it assigns Guid.NewGuid().ToByteArray() (:174). SQL Server's rowversion moves by itself, so nothing is stamped there.
    • sealed, and the mutation members are non-virtual: this is the end of the inheritance chain.
  • Why it's built this way: keeping the write members on a subclass of the read repository rather than on a parallel type means the read behavior a write handler relies on (the tracked GetByIdAsync, most of all) is literally the same code a query handler runs. The contract is ADR-055, and the split between staging here and flushing in IUnitOfWork is what makes one transaction span several repositories.
  • Where it's used: constructed by RepositoryFactory's Create through the same cached ActivatorUtilities factory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:31-32), optionally wrapped in EFRepositoryDecorator<TEntity, TIdentifierType> (:34-39). Application code reaches it through IUnitOfWork's GetRepository (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:48). Covered by EFRepositoryAdditionalTests.cs, EFRepositoryAuditStampTests.cs, EFRepositoryConcurrencyTouchTests.cs, and EFRepositoryIntegrationTests.cs under MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Repositories/.
  • Caveats / not-in-source: ExecuteUpdateAsync and ExecuteDeleteAsync bypass the save pipeline, so beyond the two audit columns stamped here they raise no domain events and run no other interceptor, the tenant guard included. The source stamps the two audit columns, plus a fresh RowVersion on client-stamped engines (:157, :168-176), and nothing else; whether a given set-based call site needed an event is a judgement the framework does not make for you. The class-level remarks still say that without a user service "the user stamp is skipped" (EFRepository.cs:21-22), which no longer matches the body: LastModifiedBy is always stamped unless the caller set it, with default when no user id is available (:151-154). Trust the method body over the remark.

CosmosIntIdValueGenerator

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.ValueGenerators · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/ValueGenerators/CosmosIntIdValueGenerator.cs:16 · Level 0 · class (sealed)

  • What it is: a nine-line EF Core value generator that hands out int ids on the client, for the one engine that cannot generate them on the server. Cosmos DB has no identity column, so something has to produce the key before the document is written, and this is that something (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/ValueGenerators/CosmosIntIdValueGenerator.cs:6-9).
  • Depends on: EF Core's ValueGenerator<int> base and EntityEntry (CosmosIntIdValueGenerator.cs:1-2, :16), plus the BCL Interlocked and DateTimeOffset. No first-party type at all.
  • Concept introduced, who assigns the key. [Rubric §8, Data Architecture] assesses whether persistence mechanics, key strategy included, are deliberate rather than accidental. The framework keeps one identifier alias per module (an int or a Guid, see the primer's identifier-type aliases and ADR-048) and then has to honor that alias on three engines. SQL Server and SQLite both offer a server-side identity column, so the entity configuration asks for one (EntityTypeConfiguration<TEntity, TIdentifierType> at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:72-77 for SQL Server and :74-78 for SQLite). Cosmos offers nothing equivalent, so the same switch installs this generator instead (:83-91). The alias stays int everywhere; only the mechanism that fills it changes per engine, which is the polyglot-persistence bargain of ADR-018.
  • Walkthrough
    • _seed (CosmosIntIdValueGenerator.cs:18): a private static int initialized to (int)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() % int.MaxValue). Seeding from the clock rather than from zero means a restarted process does not begin re-issuing ids it already used; the modulo keeps the seconds value inside int range instead of overflowing.
    • GeneratesTemporaryValues => false (CosmosIntIdValueGenerator.cs:21): the value this generator returns is the real stored key, not an EF placeholder to be replaced after the insert. That distinction matters elsewhere in this group: DbContextFactory reads the temporary flag on SQL Server keys to tell an application-supplied id from an EF-assigned one, and only the non-temporary ones need an IDENTITY_INSERT round (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:409-413).
    • Next(EntityEntry entry) (CosmosIntIdValueGenerator.cs:24-25): Interlocked.Increment(ref _seed). Lock-free and thread-safe, which is what you want on a member called once per inserted entity. The entry argument is ignored, so every Cosmos entity type in the process draws from the same counter.
  • Why it's built this way: the counter is deliberately process-local. A durable sequence would need a round trip to the database per insert, which is exactly the cost a Cosmos-shaped workload is trying to avoid, and the class remarks accept the trade-off explicitly (CosmosIntIdValueGenerator.cs:11-15).
  • Where it's used: installed by CosmosDataSourceEngine.ApplyKeyAndTableMapping (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/CosmosDataSourceEngine.cs:98-99), which EntityTypeConfiguration.ApplyEngineConventions reaches through DataSourceEngines.For(engine) (EntityTypeConfiguration.cs:77), for every entity whose id is value-generated; pinned by CosmosIntIdValueGeneratorTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/ValueGenerators/CosmosIntIdValueGeneratorTests.cs:6).
  • Caveats / not-in-source: the class remarks state the limit plainly (CosmosIntIdValueGenerator.cs:14): two processes seeded within the same second, or two processes whose counters drift into each other, can mint the same id, and the suggested remedy is a Guid alias for entities that need true uniqueness. Whether any deployed host currently stores entities in Cosmos is Not determinable from source: SQL Server is the engine every host in this workspace configures.

TenantDataSourceOverrideSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Tenancy · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:138 · Level 0 · class (sealed)

  • What it is: one tenant's connection override for one physical data source, bound from Tenancy:Tenants:{tenantId}:DataSources:{sourceName}. It is the entire configuration surface of database-per-tenant (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:138-157).
  • Depends on: nothing. It is the value type of TenantEntrySettings.DataSources (TenancySettings.cs:129), validated by TenancySettingsValidator and consumed by DbContextFactory.
  • Concept introduced, two isolation models behind one switch. [Rubric §8, Data Architecture] assesses how tenant data is partitioned. Shared-schema isolation (a tenant column plus a global query filter and TenantSaveChangesInterceptor) needs no entry here at all: the sibling doc says declaring a tenant is only required for the database-per-tenant case, because shared-schema isolation comes from the filter and the interceptor rather than from configuration (TenancySettings.cs:109-114). Adding an entry upgrades exactly one source for exactly one tenant to its own database, and every other source stays shared. [Rubric §11, Security]: the strongest isolation available (a separate database) becomes a configuration decision per tenant per source rather than an architectural fork, which is what ADR-073 set out to make possible. The deliberate omission teaches as much as the members: there is no migrations-assembly override, because a tenant database has the same schema as the shared one (TenancySettings.cs:132-137). One schema, N connection strings.
  • Walkthrough
    • Five nullable { get; init; } strings, one per engine plus the Cosmos database name: SQLServerConnectionString (TenancySettings.cs:141), PostgreSQLConnectionString (:144), SqliteConnectionString (:147), CosmosConnectionString (:150).
    • CosmosDatabaseName (:153) is optional: when omitted the shared source's database name is kept, which is how one Cosmos account can serve per-tenant databases (:149-152).
    • The read path is a record with clone, and it is the interesting part. DbContextFactory.ResolveTenantOverride bails out unless there is a resolved tenant, bound tenancy settings, an entry for that tenant, and an entry for that source name (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:171-177), picks the connection string for the source's engine through TenancySettingsValidator.ConnectionStringFor (:154, resolver at TenancySettingsValidator.cs:123-124), and returns null when the tenant overrides this source on a different engine only (DbContextFactory.cs:180-184). Otherwise it clones the shared PhysicalDataSource with the new connection string and, if supplied, the new Cosmos database name (:161-168).
    • The clone keeps the ORIGINAL DataSourceKey, and the comment above it explains why (DbContextFactory.cs:163-168): EF's model cache is keyed on that key, so replacing only the connection string is what lets one compiled model serve every tenant's database.
  • Why it's built this way: [Rubric §12, Performance & Scalability] is the reason for the key-preserving clone. A per-tenant DataSourceKey would build and cache a separate EF model per tenant, which multiplies startup cost and memory by the tenant count for no schema difference.
  • Where it's used: DbContextFactory at context-creation time (DbContextFactory.cs:105-113), and TenancySettingsValidator at startup, which rejects an entry that declares no connection string at all (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:77-87) and an entry whose key is not a real physical source name for the engine it declares (:93-104, with the round-trip test at :117-119). The second check exists because the alternative is a silent fall back to the shared database, which is precisely the failure database-per-tenant is bought to prevent.
  • Caveats / not-in-source: an override is keyed by physical source name (the name IDataSourceResolver produces), not the logical name a module uses (TenancySettings.cs:123-128). That distinction is invisible in a single-database host, where everything collapses onto Default.

TenantResolutionStrategy

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Tenancy · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:6 · Level 0 · enum

  • What it is: how the request pipeline looks for the tenant on an inbound request: from a signed claim, from a request header, or (declared but not implemented) from the host name (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:6-28).
  • Depends on: nothing. Ordered into a list by TenancySettings.ResolutionOrder (TenancySettings.cs:73), checked by TenancySettingsValidator, and switched on by TenantResolutionMiddleware.
  • Concept introduced, trusted versus asserted identity of a tenant. [Rubric §11, Security] assesses whether an authorization-relevant value can be forged by the caller. The two implemented members are not equivalent and the XML doc says so. Claim reads the tenant from the authenticated principal (TenancySettings.cs:8-12): the claim was signed by the token issuer, so a caller cannot pick its own tenant. Header reads it from a request header (:15-19): fine for service-to-service calls behind a trusted gateway, and a public edge honoring it lets any caller name any tenant. The default order is claim first, then header (TenancySettings.cs:56-57), so the trustworthy source always wins when both are present.
  • Walkthrough
    • Claim = 0 (TenancySettings.cs:13), read via context.User?.FindFirst(settings.ClaimType)?.Value (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:111); the claim type defaults to tenant_id (TenancySettings.cs:83).
    • Header = 1 (:20), read via context.Request.Headers[settings.HeaderName].FirstOrDefault() (TenantResolutionMiddleware.cs:112); the header defaults to X-Tenant-Id (TenancySettings.cs:89).
    • Host = 2 (:27), which maps to null in the middleware switch (TenantResolutionMiddleware.cs:113) and is skipped rather than guessed at.
    • The order is read through EffectiveResolutionOrder, not the bound list: an empty ResolutionOrder falls back to the framework default pair (TenancySettings.cs:76-77). That indirection exists because the configuration binder ADDS to a pre-populated collection rather than replacing it, so a non-empty default would leave a host that configured its own order also running the framework's entries (:42-48).
  • Concept, a declared-but-unimplemented member that cannot silently no-op. [Rubric §9, API & Contract Design]: the member exists so the configuration contract is stable when host-based resolution ships (TenancySettings.cs:22-26). [Rubric §15, Best Practices & Code Quality]: selecting it is not accepted quietly. TenancySettingsValidator walks the effective resolution order and fails options validation with a message naming the value as defined but not implemented (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:55-66), so the host refuses to boot instead of resolving nothing on every request. That is the fail-fast configuration contract (ADR-070) applied to a rule data annotations cannot express.
  • Why it's built this way: shipping the enum member ahead of the implementation keeps the JSON contract additive, and pairing it with a boot-time rejection removes the only real risk of doing that.
  • Where it's used: TenantResolutionMiddleware (TenantResolutionMiddleware.cs:111-113), TenancySettings.EffectiveResolutionOrder (TenancySettings.cs:76-77), and TenancySettingsValidator (TenancySettingsValidator.cs:57-65).

DesignTimeDbContextOptions

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Design · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextOptions.cs:11 · Level 1 · class (public sealed)

  • What it is: the configuration carrier a migrations project fills in so DesignTimeDbContextHelper can build a context for dotnet ef without the application's DI container. It holds the connection settings, the named data sources, the explicit list of entity-configuration assemblies, and three flags that decide which framework-owned tables belong to the scaffolded model (DesignTimeDbContextOptions.cs:11-108).
  • Depends on: ConnectionStringSettings and DataSourceEntrySettings from MMCA.Common.Infrastructure.Settings, plus System.Reflection.Assembly.
  • Concept introduced, the design-time model has to be assembled by hand. [Rubric §17, DevOps and Deployment] assesses whether the build and release machinery is deliberate rather than incidental, and [Rubric §33, Developer Experience] assesses how much ceremony a routine task costs. At run time the framework discovers entity configurations by scanning loaded assemblies, and it reads flags such as Scheduler:Enabled out of the host's configuration. dotnet ef has neither: it loads the migrations project, calls IDesignTimeDbContextFactory<T>.CreateDbContext, and there is no host, no IConfiguration, and no AppDomain full of module assemblies to scan. Every input the model needs must therefore be stated explicitly, and this class is the shape of that statement. The doc comment says exactly that for the assemblies (DesignTimeDbContextOptions.cs:89-92): they must be listed explicitly, because the runtime scan sees nothing at design time.
  • Walkthrough
    • DataSourceName (DesignTimeDbContextOptions.cs:18) names the logical source to build for. When it is left null the helper parses --datasource from the design-time arguments and finally falls back to Default (:13-17).
    • ConnectionStrings (DesignTimeDbContextOptions.cs:24) is the top-level (Default) settings object, and DataSources (:27) is the dictionary mirroring the DataSources configuration section. Between them they reproduce, in code, exactly what a host's appsettings.json would supply.
    • EnableScheduler (DesignTimeDbContextOptions.cs:41) puts the ScheduledJobs table in the design-time model. It defaults to false so dotnet ef keeps producing the migrations it produced before the scheduler shipped (:29-34), and its remarks pin the scope: set it in the migrations project of the Default data source of a host that calls AddScheduledJobs, and only there, because a second project enabling it would create a second copy of a host-scoped table (:35-40).
    • EnableAuditTrail (DesignTimeDbContextOptions.cs:55) does the same for AuditTrailEntries, with the opposite scope rule: set it in every data source whose entities are audited, because a trail row is written to the database holding the entity that changed (:49-54).
    • EnableRefreshSessions (DesignTimeDbContextOptions.cs:73) does the same for RefreshSessions, back to single-database scope: sessions are one module's data (:63-72). Its remarks name the one twist that separates it from the other two, and it is a real trap: there is no data-source setting to pass alongside the flag, because the helper registers the source the context actually resolved to, which is not always the logical name asked for.
    • EnableStoredPermissionGrants (DesignTimeDbContextOptions.cs:87) puts the PermissionGrants table (ADR-116) in the design-time model, with the same single-database scope as EnableRefreshSessions: set it in the migrations project of the database AddStoredPermissionGrants targets, normally Identity (:79-81). Its remarks describe the same two-part gate the helper wires: the framework's model gate is registered behind the flag, and Authentication:PermissionGrants:DataSourceName is pointed at the source this context resolved to, so the flag opens the gate for exactly the context --datasource selected (:81-85).
    • ConfigurationAssemblies (DesignTimeDbContextOptions.cs:93) is the explicit assembly list, and AddConfigurationAssembly (:98-107) is the chaining adder: null-guard, add only when not already present (:101-104), return this (:106).
    • Each of the four flags carries the same warning in its remarks: the flag must match the host's configuration, or the scaffolded migrations and the running model disagree, which is what dotnet ef migrations has-pending-model-changes reports.
  • Why it's built this way: database-per-service (ADR-006) means one migrations project per database, so the design-time entry point has to be told which database it is scaffolding rather than inferring it. Making the framework-table flags default to false is the conservative choice: a consumer that never adopted the scheduler, the audit trail, multi-device sessions (ADR-097) or stored permission grants (ADR-116) scaffolds exactly what it scaffolded before, and adopting a feature is an explicit one-line opt-in in one migrations project.
  • Where it's used: constructed and handed to the caller's callback inside DesignTimeDbContextHelper (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:122-123). Every consumer's migrations projects configure it: MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/DesignTimeSQLServerDbContextFactory.cs:30-55 (the one project in ADC that sets EnableRefreshSessions, at :44), the Conference, Engagement and Notification siblings, MMCA.Store/Source/Hosting/MMCA.Store.Migrations.SqlServer.Identity/DesignTimeSQLServerDbContextFactory.cs:50, and MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.Migrations.SqlServer.Tickets/DesignTimeSQLServerDbContextFactory.cs:32-45 (which enables the audit trail and the scheduler but not sessions).

ExplicitAssemblyProvider

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Design · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:223 · Level 1 · class (private sealed nested)

  • What it is: a four-line IEntityConfigurationAssemblyProvider that returns the exact list of assemblies it was constructed with, nested inside DesignTimeDbContextHelper (DesignTimeDbContextHelper.cs:223-226).
  • Depends on: IEntityConfigurationAssemblyProvider and System.Reflection.Assembly.
  • Concept: this is the design-time counterpart of DefaultEntityConfigurationAssemblyProvider, and the contrast is the whole point. [Rubric §14, Testability] assesses whether a component's inputs can be supplied directly rather than discovered ambiently, and [Rubric §1, SOLID] assesses whether the dependency inversion actually buys substitutability. Because the assembly list sits behind an interface rather than a hard-coded scan, "scan the AppDomain" and "here are exactly these three assemblies" are two implementations of one contract, and the rest of the model-building stack cannot tell them apart. That is what lets dotnet ef build a real model in a process where a scan would find nothing.
  • Walkthrough: a primary constructor takes IReadOnlyList<Assembly> and GetConfigurationAssemblies() returns it unchanged (DesignTimeDbContextHelper.cs:223-225). No caching, no filtering, no ordering: the caller already decided.
  • Why it's built this way: private sealed and nested means it exists only for the helper's own call path and cannot become a public extension point by accident. It is constructed from the caller's ConfigurationAssemblies list with a spread so later mutation of the options object cannot change the model (DesignTimeDbContextHelper.cs:129).
  • Where it's used: built once per design-time context and used three ways, registered as the DI-visible IEntityConfigurationAssemblyProvider (DesignTimeDbContextHelper.cs:193), passed directly to the context constructor (:57, :78), and handed to the EntityDataSourceRegistry that decides which entities belong to which physical source (:110).

TenantEntrySettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Tenancy · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:121 · Level 1 · class (sealed)

  • What it is: one declared tenant, bound from Tenancy:Tenants:{tenantId}. It holds exactly one member: that tenant's per-data-source connection overrides (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:118-130).
  • Depends on: TenantDataSourceOverrideSettings, the value type of its dictionary (TenancySettings.cs:129, declared at :138). Held by TenancySettings.Tenants (:115).
  • Concept introduced, declaring a tenant is only required for the database-per-tenant case. This is the single most important thing to read off this class, and it is stated in its own doc (TenancySettings.cs:109-114). A shared-schema tenant needs NO entry here at all, because its isolation comes from the global query filter and the TenantSaveChangesInterceptor, which AddInfrastructure registers unconditionally with a comment saying why (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:67-70). Configuration is therefore only how a tenant is PROMOTED to its own database. [Rubric §8, Data Architecture] assesses how deliberately data is partitioned. The dictionary is keyed by PHYSICAL data source name (the name IDataSourceResolver produces, for example Default or Conference), and the key choice is load-bearing enough that TenancySettingsValidator fails the boot on a key that does not round-trip. A tenant can override some sources and share others: a source with an entry is routed to the tenant's own database, a source without one stays shared (TenancySettings.cs:123-128).
  • Walkthrough
    • DataSources (TenancySettings.cs:129): a get-only Dictionary<string, TenantDataSourceOverrideSettings> bound from Tenancy:Tenants:{tenantId}:DataSources:{sourceName}. Get-only is the correct shape for a bound dictionary, since the configuration binder populates an existing instance rather than assigning a new one.
  • Why it's built this way: keeping the per-tenant overrides one level below the tenant, rather than flattening connection strings onto the tenant entry, is what lets a single tenant be physically separated on one source while remaining shared on the others, which is the mixed model ADR-073 records.
  • Where it's used: read by DbContextFactory.ResolveTenantOverride, which looks up the current tenant then the requested source and clones the shared PhysicalDataSource with the tenant's connection string while keeping the ORIGINAL DataSourceKey, so one compiled EF model serves every tenant's database (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:169-194). Also expanded by TenantDataSourceTargets.Expand, which turns the shared sources plus every (tenant, overridden source) pair into the list the outbox and cleanup background services sweep (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:69-79), and validated per entry by TenancySettingsValidator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:72-107).

NullDomainEventDispatcher

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Design · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:228 · Level 2 · class (private sealed nested)

  • What it is: a no-op IDomainEventDispatcher whose DispatchAsync returns Task.CompletedTask, nested inside DesignTimeDbContextHelper and used only there (DesignTimeDbContextHelper.cs:228-232).
  • Depends on: IDomainEventDispatcher and IDomainEvent.
  • Concept: the Null Object pattern. [Rubric §2, Design Patterns] assesses whether a named pattern is used where it genuinely fits rather than decoratively. A DbContext in this framework is constructed with a live service provider and resolves interceptors from it, one of which (DomainEventSaveChangesInterceptor) needs a dispatcher. Design time has no message bus, no handlers and no host, and it never calls SaveChanges anyway, so the dependency has to exist but must never do anything. Supplying a harmless implementation is cheaper and safer than making the dependency optional in the production type, where "optional" would mean a nullable field that every runtime path has to defend against.
  • Walkthrough: the single member is an expression-bodied DispatchAsync(IEnumerable<IDomainEvent>, CancellationToken) returning Task.CompletedTask (DesignTimeDbContextHelper.cs:230-231). It ignores both arguments, which is the whole contract.
  • Why it's built this way: it is private sealed and nested so it can never be resolved by a production host by mistake. Registering it as the singleton IDomainEventDispatcher (DesignTimeDbContextHelper.cs:146) is what lets the design-time container build the same interceptor set the runtime builds, which is the property that keeps a scaffolded migration honest: the design-time pipeline differs from the runtime pipeline in nothing that touches the model.
  • Where it's used: registered once in BuildDesignTimeServices (DesignTimeDbContextHelper.cs:146); nowhere else in the codebase.

TenancySettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Tenancy · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:50 · Level 2 · class (sealed)

  • What it is: the Tenancy section, bound by AddMultiTenancy(configuration): whether tenant resolution runs, how the tenant is found on a request, whether a request without one is rejected, which paths bypass resolution, and which tenants are declared for database-per-tenant routing (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:30-34).
  • Depends on: TenantResolutionStrategy (the strategy enum at the top of the same file, :6-28) and TenantEntrySettings (the value type of Tenants, :115). No externals, and deliberately no relational data annotations: the checks that matter here are relational, so they live in TenancySettingsValidator.
  • Concept introduced, "empty means the default" for bound collections. This is a genuine configuration-binder trap and the class documents it explicitly (TenancySettings.cs:41-48). The .NET configuration binder ADDS to a pre-populated collection rather than replacing it. If ResolutionOrder shipped pre-filled with [Claim, Header], a host that configured its own order would end up running the framework's entries as well. The resolution is a pair of properties: the bound list starts empty (:73, :103), and the framework reads a computed Effective* projection that substitutes a private static default when the bound list is empty (:76-77, :106-107). [Rubric §11, Security] assesses where trust boundaries are drawn. The two implemented strategies are not equivalent and the enum says so: Claim is the trustworthy source because the claim was signed by the token issuer, so a caller cannot pick its own tenant (:8-13), while Header is intended for service-to-service calls behind a trusted gateway, and a public edge that honors it lets any caller name any tenant (:15-20). The default order tries Claim first (:56-57). RequireTenant defaults to true and is documented as failing closed, because with tenancy switched on an unscoped request would read across every tenant (:91-96). [Rubric §15, Best Practices & Code Quality] assesses whether a new capability is additive for existing hosts. Enabled gates RESOLUTION, not isolation: the global query filter and the TenantSaveChangesInterceptor are always registered and are inert whenever no tenant is resolved (:35-40, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:67-70, and the same point restated on AddMultiTenancy itself at :505-509). A host that never enables tenancy keeps exactly the behavior it had before tenancy shipped, and a host that does enable it can never accidentally leave the write-side guard off.
  • Walkthrough
    • SectionName = "Tenancy" (TenancySettings.cs:53), the one public static field.
    • DefaultResolutionOrder (:56-57): private static [Claim, Header]. DefaultExcludedPathPrefixes (:60-61): private static ["/health", "/alive", "/.well-known"], the probe and discovery endpoints that must answer before any tenant exists.
    • Enabled (:67): default false.
    • ResolutionOrder (:73) get-only list plus EffectiveResolutionOrder (:76-77), the empty-means-default projection.
    • ClaimType (:83), default "tenant_id", and HeaderName (:89), default "X-Tenant-Id", the two per-strategy lookup keys. HeaderName is only honored when Header is in the order.
    • RequireTenant (:96): default true.
    • ExcludedPathPrefixes (:103) plus EffectiveExcludedPathPrefixes (:106-107), the same projection shape.
    • Tenants (:115): a get-only Dictionary<string, TenantEntrySettings> bound from Tenancy:Tenants:{tenantId}, needed only for database-per-tenant.
  • Why it's built this way: shared-schema isolation plus optional database-per-tenant promotion is the model ADR-073 records, and this class is its whole operator-facing surface. Defaulting Enabled to false while keeping the filter and interceptor always-on is what makes the feature safe to ship into an existing host, and keeping every relational check in a separate IValidateOptions<T> (rather than in attributes) is what lets the validator reach the resolved data sources.
  • Where it's used: bound with ValidateOnStart in AddMultiTenancy, alongside the TryAddEnumerable registration of TenancySettingsValidator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:277-289). Read at request time by TenantResolutionMiddleware, which short-circuits when disabled or on an excluded path (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:62), walks EffectiveResolutionOrder reading the claim or the header (:107-118), lets the request through unscoped when RequireTenant is off (:75-81), and otherwise answers 400 Bad Request (:83). Read at persistence time by DbContextFactory for per-tenant connection routing (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:48, :144-168), and by the background services through TenantDataSourceTargets.Expand so they sweep every tenant database too (OutboxProcessor.cs:67, OutboxCleanupService.cs:198, OutboxAdministration.cs:42, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:52-82). Startup database initialization uses the same expansion to create each tenant's database (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:203, :138), and the design-time helper supplies a default instance so dotnet ef needs no tenancy configuration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:156). All of the runtime consumers take the options as NULLABLE, so a host that never called AddMultiTenancy resolves null and behaves exactly as before.

TenancySettingsValidator

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Tenancy · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:24 · Level 13 · class (sealed, internal)

  • What it is: the startup validator for TenancySettings. It rejects a resolution order naming an unimplemented strategy, and rejects a per-tenant data-source override that declares no connection string or names a source that does not exist (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:9-24).
  • Depends on: IDataSourceResolver as an OPTIONAL primary-constructor parameter (TenancySettingsValidator.cs:24), plus DataSource and DataSourceKey (:3-4, :28-29, :104), the engine registry DataSourceEngines and its IDataSourceEngine entries (:5, :29, :124), and the two settings shapes TenancySettings and TenantDataSourceOverrideSettings. Externals: Microsoft.Extensions.Options for IValidateOptions<T> and ValidateOptionsResult (:2), and System.Globalization for the CultureInfo.InvariantCulture message formatting (:1, :80).
  • Concept introduced, IValidateOptions<T> when validation needs other services. Data annotations and IValidatableObject are the right tools when a rule only involves the settings object itself. This class is the other half of the options-validation story: IValidateOptions<T> is a DI-resolved service, so it can inject collaborators. Here that collaborator is the data-source resolver, which is the only thing that knows whether Conference is a real physical source in this host. Registration is TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TenancySettings>, TenancySettingsValidator>()) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:285-286), with TryAddEnumerable because two modules calling AddMultiTenancy must not run the same validation twice (DependencyInjection.cs:284). The same shape is used by ConnectionStringSettingsValidator; these two are the framework's only custom IValidateOptions<T> implementations. [Rubric §11, Security] assesses whether misconfiguration can degrade silently. The class doc states the rationale outright: every failure here would otherwise surface as silent cross-tenant behavior at run time, which is exactly the class of bug tenancy exists to prevent, so it is worth a failed boot (TenancySettingsValidator.cs:9-14). The concrete danger is the override key: an unknown logical name resolves to Default, so a mistyped source name would quietly leave that tenant on the shared database instead of its own (:113-117). [Rubric §15, Best Practices & Code Quality] assesses the quality of failure messages. Each message names the exact configuration path that is wrong and tells the operator what to do: the missing-connection-string message lists the four acceptable properties and notes that removing the entry keeps the source shared (:79-85); the unknown-source message explains that override keys are physical names (:96-104); the Host strategy message names the two supported values (:61-63).
  • Walkthrough
    • Engines (:28-29): private static, built from DataSourceEngines.All ordered by each engine's SubstitutionPriority and projected to its Engine value. With the shipped priorities (SQL Server 0, PostgreSQL 1, Sqlite 2, Cosmos 3: MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:29, PostgreSQLDataSourceEngine.cs:27, SqliteDataSourceEngine.cs:26, CosmosDataSourceEngine.cs:28) that yields [SQLServer, PostgreSQL, Sqlite, CosmosDB], the engines an override can carry a connection string for, in substitution-priority order (:27). The list is no longer hand-written, so a newly registered engine is validated without touching this class.
    • Validate(string? name, TenancySettings options) (:32-48): null-guards the options, collects failures into a list, runs the resolution-order check once and the tenant check per declared tenant, then returns ValidateOptionsResult.Success or Fail(failures). Note that it accumulates ALL failures rather than returning on the first, so one boot attempt reports the whole set.
    • ValidateResolutionOrder (:55-66): walks EffectiveResolutionOrder (so the framework default is validated too, not just an explicitly configured order) and fails on TenantResolutionStrategy.Host. That enum member exists so the configuration contract is stable for when host-based resolution ships, but selecting it today must not read as "resolve nothing" (:50-54, and the enum's own doc at TenancySettings.cs:22-26).
    • ValidateTenant (:72-107): for each (sourceName, override) pair, computes which engines the override actually declares a connection string for. Zero engines is a failure and the loop moves on (:77-87). If resolver is null the source-existence check is skipped entirely (:89-92), which is what makes the validator usable in a container that never registered persistence (:15-19). Otherwise every declared engine whose source name is unknown produces a failure (:94-105).
    • DeclaredEngines (:110-111): a collection expression over Engines filtered by ConnectionStringFor being non-blank.
    • IsKnownPhysicalSource (:118-120): the round-trip test. Default always passes; otherwise the name must survive resolver.ResolveLogical(engine, sourceName).Name unchanged, because an unknown logical name collapses onto Default and therefore comes back different.
    • ConnectionStringFor(DataSource, TenantDataSourceOverrideSettings) (:123-124): internal static, a one-line delegation to DataSourceEngines.For(engine).GetConnectionString(over), so each engine answers for its own override property (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/IDataSourceEngine.cs:59). For throws InvalidOperationException for an unregistered engine rather than returning null (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/DataSourceEngines.cs:40-43). It is internal rather than private because it is reused outside validation, which is the detail worth noting next.
  • Why it's built this way: the engine-to-property mapping is needed in three places (validation, target expansion, and connection routing). The mapping itself now lives on each engine (GetConnectionString(TenantDataSourceOverrideSettings), IDataSourceEngine.cs:59), and this validator's ConnectionStringFor is the single entry point the runtime paths call back into rather than resolving the engine themselves (ADR-073). Deriving Engines from the registry follows the same rule: DataSourceEngines documents that a fifth engine is one new class plus one registry line (DataSourceEngines.cs:14-17), and this validator picks it up with no edit. Making the resolver optional rather than required is what keeps the validator constructible in a host that binds tenancy without registering AddInfrastructure, at the cost of skipping the source-existence check there; the strategy and connection-string checks still run.
  • Where it's used: registered by AddMultiTenancy alongside ValidateOnStart on the options (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:277-289). Its ConnectionStringFor helper is called at run time by TenantDataSourceTargets.Expand when deciding whether a (tenant, source) pair is really overridden (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:74) and by DbContextFactory.ResolveTenantOverride when cloning the physical source for a tenant (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:179). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Tenancy/AddMultiTenancyTests.cs, which asserts the single-registration shape (:97-98) and drives the validator both without a resolver (:168) and with one (:206).
  • Caveats / not-in-source: the type is internal, so it is not part of the framework's public API and a consumer cannot subclass or replace it; a host needing extra tenancy rules registers its own additional IValidateOptions<TenancySettings>.

DesignTimeDbContextHelper

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Design · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:43 · Level 13 · class (public static)

  • What it is: the whole design-time story in one static class. It builds a real SQLServerDbContext, PostgreSQLDbContext or SqliteDbContext for dotnet ef commands without the application's host or DI container, so a consumer's migrations project is a factory class of a few lines (DesignTimeDbContextHelper.cs:19-42, with the worked example in the doc comment at :22-32; CreatePostgreSQL and CreateSqlite are its symmetric entry points, :36-40).
  • Depends on: DesignTimeDbContextOptions (the caller's input), DataSourceResolver and EntityDataSourceRegistry (routing), ExplicitAssemblyProvider and NullDomainEventDispatcher (its two nested stand-ins), the four interceptors (AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, TenantSaveChangesInterceptor, AuditTrailSaveChangesInterceptor), PermissionGrantModelGate (registered only behind EnableStoredPermissionGrants), OutboxSignal, and the settings types TenancySettings, SchedulerSettings, AuditTrailSettings, RefreshSessionSettings and PermissionGrantSettings. Externally: Microsoft.Extensions.DependencyInjection, Microsoft.Extensions.Options, and the null-logger family from Microsoft.Extensions.Logging.Abstractions.
  • Concept introduced, the design-time container. [Rubric §17, DevOps and Deployment] assesses whether migrations are a repeatable, per-database operation rather than a manual one, and [Rubric §33, Developer Experience] assesses how much a routine task costs the next engineer. dotnet ef runs outside the application: it loads an assembly, finds an IDesignTimeDbContextFactory<T>, and asks for a context. A DbContext in this framework is not newable in isolation, because it resolves interceptors, a data-source resolver and an entity registry from a service provider. This helper builds the smallest provider that satisfies all of that, and the design principle running through every line of BuildDesignTimeServices is fidelity: register the same services the runtime registers, defaulted to inert, so the model dotnet ef sees is the model the application will run. Every gated service here is registered unconditionally with a disabled default rather than being omitted, and the inline comments say so at :150-154 (tenant), :157-159 (scheduler), :162-164 (audit trail), :168-172 (refresh session) and :179-182 (stored permission grants).
  • Walkthrough
    • CreateSqlServer(args, configure) (DesignTimeDbContextHelper.cs:52-61), CreatePostgreSQL(args, configure) (:74-83) and CreateSqlite(args, configure) (:95-104) are the three entry points. Each calls the shared builder for its engine and then constructs the context with an empty DbContextOptions, the built provider, the assembly provider and the resolved physical source.
    • BuildDesignTimeServices (DesignTimeDbContextHelper.cs:116-198) is the substance. Its doc comment states why it is shared: a difference between the engines' pipelines would surface as a migration that differs by engine for reasons that have nothing to do with the engine (:106-110).
    • Name resolution is a three-step fallback (DesignTimeDbContextHelper.cs:125-127): the explicit DataSourceName, then --datasource parsed from the arguments, then DataSourceKey.DefaultName.
    • The routing stack is built by hand: an ExplicitAssemblyProvider over a copy of the caller's assembly list (DesignTimeDbContextHelper.cs:129), a DataSourceResolver over the caller's connection settings with a null logger (:130-133), and an EntityDataSourceRegistry over the two (:134).
    • The physical source is resolved before the registrations (DesignTimeDbContextHelper.cs:140), and the comment at :136-139 explains why the ordering matters: the refresh-session gate is keyed on the PHYSICAL source name, which is not always the logical one asked for. A logical name whose connection matches the top-level one collapses onto Default, and names sharing a connection collapse onto the alphabetically-first of them.
    • The container (DesignTimeDbContextHelper.cs:142-195) registers TimeProvider.System, a NullLoggerFactory and open-generic NullLogger<> (:143-145), the NullDomainEventDispatcher (:146), an OutboxSignal (:147), the audit and domain-event interceptors (:148-149), the tenant interceptor with a default (disabled) TenancySettings (:155-156), and the four table gates.
    • The four gates are the part a migrations author actually tunes. SchedulerSettings.Enabled comes from EnableScheduler (DesignTimeDbContextHelper.cs:160-161), AuditTrailSettings.Enabled from EnableAuditTrail along with the interceptor that writes to that table (:165-167), RefreshSessionSettings from EnableRefreshSessions plus physical.Key.Name (:173-178), and, newest, a two-part gate for the stored permission-grants table (ADR-116, :179-192): PermissionGrantModelGate is registered only when EnableStoredPermissionGrants is set (:183-186), and PermissionGrantSettings.DataSourceName is always stamped with physical.Key.Name, so the source that opens the gate is always the one this context actually resolved to. That last pairing is the point of the early resolve for both the session and the permission-grant gates: the comments record that registering the logical name instead would silently miss on every collapse, and the scaffold would keep omitting a table the runtime model has.
    • Finally the assembly provider, resolver and registry are registered under their interfaces (DesignTimeDbContextHelper.cs:193-195) and the triple is returned (:197).
    • ParseDataSourceName (DesignTimeDbContextHelper.cs:203-221) is internal so it can be tested directly. It accepts both --datasource Name and --datasource=Name, case-insensitively, and throws InvalidOperationException with a usage hint when the flag is present with no value (:209-211). A missing flag returns null, which is the fallback path, not an error.
  • Why it's built this way: database-per-service (ADR-006) means one migrations project per database, and each of them needs a context whose model contains only that source's entities. Centralizing the plumbing here keeps each of those projects declarative, and centralizing it also means the fidelity rules (register everything, default to disabled, key the session and permission-grant gates on the resolved physical name) are stated once instead of once per consumer. Supporting all three engines through one private builder is the polyglot posture of ADR-018 and ADR-113.
  • Where it's used: every consumer's design-time factories. MMCA.ADC has four (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/DesignTimeSQLServerDbContextFactory.cs:15, plus the Conference, Engagement and Notification projects), MMCA.Store has three (MMCA.Store/Source/Hosting/MMCA.Store.Migrations.SqlServer.Identity/DesignTimeSQLServerDbContextFactory.cs:17, Catalog at :16, Sales at :16), MMCA.Helpdesk has one (MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.Migrations.SqlServer.Tickets/DesignTimeSQLServerDbContextFactory.cs:25), and MMCA.ECommerce has two. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/DesignTimeDbContextHelperTests.cs, which exercises the engines and the refresh-session gate.
  • Caveats / not-in-source: whether the flags in a given migrations project actually match that host's appsettings.json is not checkable from this file; the guard is dotnet ef migrations has-pending-model-changes, named in the options remarks (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextOptions.cs:71-72).

IEntityTypeConfigurationBase<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/IEntityTypeConfigurationBase.cs:14 · Level 4 · interface (public)

  • What it is: the root of this framework's entity-configuration interface family. It extends EF Core's own IEntityTypeConfiguration<TEntity> and redeclares Configure with the new modifier, so the three engine-specific marker interfaces below it can redeclare it in turn (IEntityTypeConfigurationBase.cs:14-18).
  • Depends on: EF Core's IEntityTypeConfiguration<TEntity> and EntityTypeBuilder<TEntity> (NuGet, IEntityTypeConfigurationBase.cs:1-2); AuditableBaseEntity<TIdentifierType> as the entity constraint (IEntityTypeConfigurationBase.cs:15).
  • Concept introduced, a marker-interface family used as a discovery filter. EF Core has exactly one configuration contract, IEntityTypeConfiguration<TEntity>, and ModelBuilder.ApplyConfiguration accepts it. That is enough when a solution has one model. This framework builds one model per physical data source and has to answer a different question first: which configurations belong in this engine's model. The answer is a family of interfaces that all remain assignable to EF's contract (so ApplyConfiguration still accepts them) while carrying an engine tag in their type identity. ModelBuilderExtensions.ApplyAllConfigurations scans an assembly for concrete types implementing the open generic interface it was handed, reads the entity type out of the interface's first generic argument, instantiates the configuration through DI, and invokes ModelBuilder.ApplyConfiguration<TEntity> reflectively (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ModelBuilderExtensions.cs:42-64). This interface is the shared parent that keeps that whole scheme compatible with EF. [Rubric §2, Design Patterns] assesses whether a pattern earns its place; the marker interface here is not decoration, it is the type-level index the scan runs on. [Rubric §1, SOLID] assesses interface segregation and substitutability: every member of the family is still an IEntityTypeConfiguration<TEntity>, so nothing downstream of EF has to know the family exists.
  • Walkthrough
    • Declaration (IEntityTypeConfigurationBase.cs:14): IEntityTypeConfigurationBase<TEntity, TIdentifierType> : IEntityTypeConfiguration<TEntity>. Note the second type parameter, which EF's own contract does not have: it carries the identifier type so the constraint below can be expressed.
    • Constraints (IEntityTypeConfigurationBase.cs:15-16): TEntity : AuditableBaseEntity<TIdentifierType> and TIdentifierType : notnull. Only audited entities are configurable through this family, which is why the base class can safely assume audit members exist.
    • new void Configure(EntityTypeBuilder<TEntity> builder) (IEntityTypeConfigurationBase.cs:18): the redeclaration. The doc gives the reason directly (IEntityTypeConfigurationBase.cs:8-10): declaring Configure explicitly here is what lets the provider-specific interfaces redeclare it with new as well. A single public Configure method on an implementing class satisfies every declaration in the chain.
  • Why it's built this way: the alternative is an attribute-only scheme, where every configuration is annotated and discovery is a reflection sweep over attributes. Putting the engine in the type system instead means the scan is a plain GetInterfaces() match (ModelBuilderExtensions.cs:48-50), the compiler tells a consumer immediately when a configuration implements two engines' contracts, and the entity type is available as a generic argument rather than as something else to look up.
  • Where it's used: implemented by EntityTypeConfigurationBase<TEntity, TIdentifierType> (EntityTypeConfigurationBase.cs:19-20) and extended by the three engine markers, IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, IEntityTypeConfigurationSqlite<TEntity, TIdentifierType>, and IEntityTypeConfigurationCosmos<TEntity, TIdentifierType>.

EntityTypeBuilderExtensions

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeBuilderExtensions.cs:13 · Level 5 · class (public static, extension container)

  • What it is: a generic extension<TOwner>(EntityTypeBuilder<TOwner>) block with two members. OwnsMoney(...) maps a Money property as an owned type flattened into two columns on the owner's table: a decimal amount and an ISO 4217 currency code (EntityTypeBuilderExtensions.cs:13, :28-29, member at :63-93). OwnsAddress(...) does the same for an Address property, flattened into six columns (:131-177).
  • Depends on: Money and Currency from MMCA.Common.Shared.ValueObjects, Address and AddressInvariants from the same namespace, EF Core's EntityTypeBuilder<TEntity> and OwnsOne, and System.Linq.Expressions.
  • Concept introduced, the round-trip contract of a value converter. [Rubric §4, Domain-Driven Design] assesses whether value objects survive the trip to storage intact, and [Rubric §15, Best Practices and Code Quality] assesses whether edge cases are handled where they arise. A value converter has two legs, write and read, and correctness means every value the write leg can produce is a value the read leg can materialize. The doc comment at EntityTypeBuilderExtensions.cs:37-46 documents a real failure of that contract and its fix. An aggregate can seed a zero total with Money.Zero(), whose currency Code is the empty string, and leave it there; the write leg persists that empty code faithfully. On the way back, Currency.FromCode("") fails, and a bare .Value! turned those rows into a null Currency inside a Money, which is a materialization-time NullReferenceException waiting for the first read. The same applies to any code that has since dropped out of Currency.All. So the read leg coalesces to a sentinel instead.
  • Walkthrough
    • NoCurrency (EntityTypeBuilderExtensions.cs:26) is that sentinel, obtained as Money.Zero().Currency. The comment at :14-18 explains the indirection: the "no currency" instance is internal to MMCA.Common.Shared, so a zero Money is the only public handle on it.
    • The extension is generic over the owner with a class constraint (EntityTypeBuilderExtensions.cs:28-29), so it applies to any entity that owns a Money.
    • The signature takes the navigation expression, the two column names, and a required flag defaulting to true (EntityTypeBuilderExtensions.cs:63-67). The parameter doc at :56-61 is honest about the design rule behind that: required is the only facet that differs across the existing call sites, so it is the only one parameterized beyond the two column names.
    • All four arguments are guarded, the strings with ArgumentException.ThrowIfNullOrWhiteSpace (EntityTypeBuilderExtensions.cs:69-72).
    • OwnsOne maps the two members (EntityTypeBuilderExtensions.cs:74-89): Amount gets its column name, an explicit HasPrecision(18, 2) (:78, the SQL Server default made explicit so PostgreSQL and SQLite store and round amounts the same way; documented at :47-51) and IsRequired; Currency gets the two-leg conversion (write currency => currency.Code, read code => Currency.FromCode(code).Value ?? NoCurrency), plus HasMaxLength(3), IsUnicode(false), its column name, and IsRequired. That is the ISO 4217 code shape exactly: three non-Unicode characters.
    • builder.Navigation(navigationExpression).IsRequired(required) (EntityTypeBuilderExtensions.cs:91) is a separate call from the OwnsOne body because it configures the navigation rather than the owned entity's properties. The builder is returned for chaining (:93).
    • OwnsAddress(...) (EntityTypeBuilderExtensions.cs:131-177) maps Address's six fields (AddressLine1, AddressLine2, City, State, ZipCode, Country) to columns built by the private helper AddressColumn(columnPrefix, propertyName) (:191-194). Each column is columnPrefix + propertyName, except the redundant leading word Address is dropped from the two line properties first (AddressPropertyPrefix, :19), so the default "Address" prefix yields AddressLine1/AddressLine2 rather than AddressAddressLine1/AddressAddressLine2, and AddressCity, AddressState, AddressZipCode, AddressCountry for the rest; a second address on the same owner passes a different columnPrefix (for example "ShippingAddress") and gets ShippingAddressLine1, ShippingAddressCity, and so on. That is exactly the mapping the framework's own hand-written configurations already declared, so adopting the helper is a zero-diff model change: no migration, no snapshot churn.
    • Max lengths on the six columns come from AddressInvariants (EntityTypeBuilderExtensions.cs:144,150,155,160,165,170), the same constants the Address value object validates against, so a column can never be shorter than what the domain accepts; every column is non-Unicode varchar, and only AddressLine1 is required, matching the value object, whose other five fields are optional for international address formats. required on the navigation itself defaults to false (:134), matching the existing call sites where an owner may have no address at all.
  • Why it's built this way: value objects are validated primitives (ADR-068), which means construction is guarded but persistence must still round-trip every value that construction allowed, including the Money zero sentinel. Flattening Money into two columns and Address into six on the owner's table rather than a separate table keeps a price, a total, or a location a single-row read. Putting each mapping behind one extension member means the read-leg fallback for Money, and the column-naming and invariant-length rules for Address, are each written once and cannot be forgotten by the next configuration that maps one.
  • Where it's used: OwnsMoney has three call sites, all in MMCA.Store's Sales and Catalog modules: MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Persistence/EntityConfiguration/OrderConfiguration.cs:26 (Total, with required: false), .../OrderLineConfiguration.cs:28 (UnitPrice), and MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Infrastructure/Persistence/EntityConfiguration/ProductVariantConfiguration.cs:31 (Price); covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/OwnsMoneyTests.cs. OwnsAddress is covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/OwnsAddressTests.cs.

EntityTypeConfigurationBase<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationBase.cs:19 · Level 5 · class (public abstract)

  • What it is: the engine-agnostic root class of the configuration hierarchy. It implements exactly one cross-cutting rule: an aggregate root's in-memory DomainEvents collection must never be mapped to the database (EntityTypeConfigurationBase.cs:19-33).
  • Depends on: IEntityTypeConfigurationBase<TEntity, TIdentifierType> (implements it, EntityTypeConfigurationBase.cs:20); AuditableBaseEntity<TIdentifierType> (constraint, :21); AuditableAggregateRootEntity<TIdentifierType> and IAggregateRoot (the runtime test and the ignored member, :29-31); EF Core's EntityTypeBuilder<TEntity> (NuGet, :1).
  • Concept introduced, keeping a domain-only collection out of the model. An aggregate root collects domain events in memory so the dispatcher can drain them after a save. That collection is not state: it has no column, no table, and no meaning after the transaction closes. EF Core's convention-based model builder does not know that and would happily try to map it, which fails outright or (worse) invents a shadow table. One builder.Ignore call at the root of the hierarchy makes the exclusion automatic for every entity in every consumer, rather than a rule each configuration author has to remember. [Rubric §4, Domain-Driven Design] assesses whether the aggregate's event mechanism stays a domain concern; ignoring the collection is what keeps events a domain construct rather than a persisted one. [Rubric §9, API & Contract Design] assesses whether concerns that apply everywhere are handled once in a shared place; this is the smallest possible example of that, applied at the base class every configuration inherits.
  • Walkthrough
    • Declaration (EntityTypeConfigurationBase.cs:19-22): abstract, generic over the entity and its identifier type, implementing the base interface. Being abstract means it is never discovered as a configuration itself: the scan skips abstract types (ModelBuilderExtensions.cs:44).
    • Configure is virtual (EntityTypeConfigurationBase.cs:25): the derived engine-aware class overrides it and calls base.Configure(builder) first, so this rule always runs before engine conventions (see EntityTypeConfiguration<TEntity, TIdentifierType>, EntityTypeConfiguration.cs:41).
    • The aggregate-root test (EntityTypeConfigurationBase.cs:29): typeof(IAggregateRoot).IsAssignableFrom(typeof(TEntity)). The rule is applied only to aggregate roots, because only they carry the collection; a plain child entity configured through the same hierarchy is untouched.
    • The exclusion (EntityTypeConfigurationBase.cs:31): builder.Ignore(nameof(AuditableAggregateRootEntity<>.DomainEvents)). The unbound-generic nameof form is worth noticing: it names the member without having to close the generic, so the string stays refactor-safe.
    • What this class deliberately leaves to someone else (EntityTypeConfigurationBase.cs:10-15): the doc records that entity-to-data-source mapping is not registered here as a model-building side effect. EntityDataSourceRegistry derives that mapping eagerly from the configuration class's attributes (UseDataSourceAttribute for the engine, UseDatabaseAttribute or the module namespace for the database), which is what lets routing be known before any model is built.
  • Why it's built this way: model building is the wrong place to accumulate a registry, because a model is built lazily and per data source, so "which entity lives where" would only be known after the first context of each kind had been constructed. Splitting the two (this class owns model rules, the registry owns routing) is what makes the routing table eagerly available at startup.
  • Where it's used: extended by EntityTypeConfiguration<TEntity, TIdentifierType>, and through it by every consumer configuration. Its two behaviors are pinned directly: Configure_AggregateRootEntity_ExcludesDomainEvents and Configure_NonAggregateEntity_MapsEntity (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/EntityTypeConfigurationBaseTests.cs:18, :38).

IEntityTypeConfigurationCosmos<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/IEntityTypeConfigurationCosmos.cs:13 · Level 5 · interface (internal)

  • What it is: the engine marker for Azure Cosmos DB configurations (IEntityTypeConfigurationCosmos.cs:13). It adds no members of its own beyond the new redeclaration of Configure (:17); its whole job is to be the type CosmosDbContext scans for.
  • Depends on: IEntityTypeConfigurationBase<TEntity, TIdentifierType> (extends it, IEntityTypeConfigurationCosmos.cs:13); AuditableBaseEntity<TIdentifierType> (constraint, :14); EF Core's EntityTypeBuilder<TEntity> (NuGet, :1).
  • Concept reinforced: the marker-interface-as-discovery-filter idea taught at IEntityTypeConfigurationBase<TEntity, TIdentifierType>. The selection happens in one switch inside ApplicationDbContext: DataSource.CosmosDB maps to typeof(IEntityTypeConfigurationCosmos<,>) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:955), and that open generic is handed to the assembly scan.
  • Walkthrough
    • internal (IEntityTypeConfigurationCosmos.cs:13): unlike its parent, this interface is not part of the public API. Consumers are not meant to implement it directly; they derive from EntityTypeConfigurationCosmos<TEntity, TIdentifierType> (or annotate with UseDataSourceAttribute), which implements it for them.
    • new void Configure(...) (IEntityTypeConfigurationCosmos.cs:17): the redeclaration the base interface exists to enable.
  • Caveats / not-in-source: implementing this interface directly, without the attributed base class, is a supported but degraded path. EntityDataSourceRegistry skips configurations that carry no UseDataSourceAttribute (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/EntityDataSourceRegistry.cs:174-178), so such an entity lands in the engine's Default model but is not routable through IUnitOfWork; the code comments call this legacy behavior (ApplicationDbContext.cs:958-962).
  • Where it's used: matched by ApplicationDbContext.ApplyConfigurationsForEntitiesInContext for the Cosmos engine (ApplicationDbContext.cs:953-960) and implemented by EntityTypeConfiguration<TEntity, TIdentifierType> (EntityTypeConfiguration.cs:32).

IEntityTypeConfigurationPostgreSQL<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/IEntityTypeConfigurationPostgreSQL.cs:13 · Level 5 · interface (internal)

  • What it is: the engine marker for PostgreSQL configurations (IEntityTypeConfigurationPostgreSQL.cs:13), the fourth member of the marker-interface family alongside Cosmos, Sqlite, and SQL Server; structurally identical to its siblings.
  • Depends on: IEntityTypeConfigurationBase<TEntity, TIdentifierType> (extends it, IEntityTypeConfigurationPostgreSQL.cs:13); AuditableBaseEntity<TIdentifierType> (constraint, :14); EF Core's EntityTypeBuilder<TEntity> (NuGet, :1).
  • Concept reinforced: engine selection by interface identity, taught at IEntityTypeConfigurationBase<TEntity, TIdentifierType>. DataSource.PostgreSQL maps to typeof(IEntityTypeConfigurationPostgreSQL<,>) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:956), the case added alongside the other three when PostgreSQL became a first-class engine (ADR-113).
  • Where it's used: matched by PostgreSQLDbContext through the shared discovery method, and implemented by EntityTypeConfiguration<TEntity, TIdentifierType> (EntityTypeConfiguration.cs:30).

IEntityTypeConfigurationSqlite<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/IEntityTypeConfigurationSqlite.cs:13 · Level 5 · interface (internal)

  • What it is: the engine marker for SQLite configurations (IEntityTypeConfigurationSqlite.cs:13), structurally identical to its Cosmos and SQL Server siblings.
  • Depends on: IEntityTypeConfigurationBase<TEntity, TIdentifierType> (IEntityTypeConfigurationSqlite.cs:13); AuditableBaseEntity<TIdentifierType> (:14); EF Core's EntityTypeBuilder<TEntity> (:1).
  • Concept reinforced: engine selection by interface identity, taught at IEntityTypeConfigurationBase<TEntity, TIdentifierType>. DataSource.Sqlite maps to typeof(IEntityTypeConfigurationSqlite<,>) (ApplicationDbContext.cs:957).
  • Where it's used: matched by SqliteDbContext through the shared discovery method, implemented by EntityTypeConfiguration<TEntity, TIdentifierType> (EntityTypeConfiguration.cs:31), and used directly by the framework's own tests as the scan target: the ApplyAllConfigurations tests pass typeof(IEntityTypeConfigurationSqlite<,>) as the interface to match (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/ModelBuilderExtensionsTests.cs:93, :148), and two test types implement it directly to exercise the unattributed path (ModelBuilderExtensionsTests.cs:108, MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/EntityDataSourceRegistryTests.cs:233).

IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/IEntityTypeConfigurationSQLServer.cs:13 · Level 5 · interface (internal)

  • What it is: the engine marker for SQL Server configurations (IEntityTypeConfigurationSQLServer.cs:13). It is the one of the three that matters in production today, because every deployed entity in ADC and Store routes to SQL Server.
  • Depends on: IEntityTypeConfigurationBase<TEntity, TIdentifierType> (IEntityTypeConfigurationSQLServer.cs:13); AuditableBaseEntity<TIdentifierType> (:14); EF Core's EntityTypeBuilder<TEntity> (:1).
  • Concept reinforced: engine selection by interface identity. DataSource.SQLServer maps to typeof(IEntityTypeConfigurationSQLServer<,>) (ApplicationDbContext.cs:958); anything the switch does not recognize throws InvalidOperationException rather than silently building an empty model (ApplicationDbContext.cs:959).
  • Where it's used: matched by SQLServerDbContext through ApplyConfigurationsForEntitiesInContext, and implemented by EntityTypeConfiguration<TEntity, TIdentifierType> (EntityTypeConfiguration.cs:29), which is how all 28 ADC configurations and all 12 Store configurations reach it through the EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> shim.

ReadRepositoryExtensions

MMCA.Common.Application · MMCA.Common.Application.Extensions · MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ReadRepositoryExtensions.cs:10 · Level 6 · class (public static)

  • What it is: a static class that adds one member, GetByIdOrFailAsync, to every IReadRepository<TEntity, TIdentifierType> (ReadRepositoryExtensions.cs:10-12). It turns the repository's null-returning lookup into a Result<T> that already carries a typed NotFound error, so handlers stop writing the same "load, null-check, build a 404" block.
  • Depends on: IReadRepository<TEntity, TIdentifierType> (the receiver, ReadRepositoryExtensions.cs:12); AuditableBaseEntity<TIdentifierType> (the generic constraint, :13); Result and Error (:3, :43-47).
  • Concept reinforced, C# extension(T) members over an infrastructure abstraction. The extension-member syntax itself is taught once in the primer (primer, extension types); what matters here is where it is applied. IReadRepository is an Application-layer abstraction, and this file is in Application, so the helper enriches the contract without widening the interface every implementation would then have to satisfy (including the mocks in the test suite). [Rubric §1, SOLID] assesses whether types are open for extension but closed for modification; adding the convenience member as an extension rather than an interface method is precisely that trade. [Rubric §15, Best Practices & Code Quality] assesses whether repeated boilerplate has been factored out; the null-check-then-fail block collapses to a single call.
  • Walkthrough
    • extension<TEntity, TIdentifierType>(IReadRepository<TEntity, TIdentifierType> repository) (ReadRepositoryExtensions.cs:12-14): a generic extension block whose receiver is the repository; the constraints mirror the interface exactly (TEntity : AuditableBaseEntity<TIdentifierType>, TIdentifierType : notnull).
    • GetByIdOrFailAsync(id, source, includes, asTracking, cancellationToken) (ReadRepositoryExtensions.cs:27-32): note the parameters. source is a string the caller passes (typically its own type name) so the resulting error can name who produced it; includes and asTracking are passed straight through, and asTracking defaults to true, matching the command-handler case where the loaded entity is about to be modified.
    • The lookup (ReadRepositoryExtensions.cs:34-38): it calls GetAllAsync with where: e => e.Id.Equals(id) rather than a keyed fetch. That is deliberate: GetAllAsync is the overload that takes the includes collection plus tracking (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:85-92, declared on IEntityQuerier<TEntity, TIdentifierType>, which IReadRepository composes at IRepository.cs:332-333), so the helper participates in the full eager-loading pipeline. includes ?? [] keeps the parameter optional.
    • The failure branch (ReadRepositoryExtensions.cs:40-45): entities.FirstOrDefault(), and when it is null, Error.NotFound.WithSource(source).WithTarget(typeof(TEntity).Name). Error.NotFound is the shared static instance (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Error.cs:23) and the two With* calls return copies (Error.cs:132, :126), so the shared instance is never mutated and the caller gets an error that names both the caller and the entity type.
    • The success branch (ReadRepositoryExtensions.cs:47): Result.Success(entity).
  • Why it's built this way: handlers in this codebase compose with Result, never exceptions, so a lookup that returns null forces every call site to translate. Doing the translation once, in the layer that owns the abstraction, keeps the error code, source, and target consistent across every module and keeps the 404 mapping at the API edge working off one well-known ErrorType.
  • Where it's used: the only current callers in the workspace are its own tests (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Extensions/ReadRepositoryExtensionsTests.cs:15, :40); no ADC or Store handler calls it today, and handlers there still do the explicit null check. It is available to any handler that resolves a read repository through IUnitOfWork.GetReadRepository.
  • Caveats / not-in-source: the method loads through GetAllAsync, so it materializes a collection and takes the first element rather than issuing a keyed FindAsync; whether that costs anything at the database depends on the provider's translation of the Id.Equals(id) predicate and is not determinable from source.

IndexBuilderExtensions

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:10 · Level 2 · class (public static, extension container)

  • What it is: a single C# extension(IndexBuilder) block exposing one member, HasSoftDeleteFilter(...), which attaches the IsDeleted = 0 predicate to a hand-authored index in an entity type configuration (IndexBuilderExtensions.cs:10-12, member at :50-64).
  • Depends on: SoftDeleteFilterSql (the shared predicate builder, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:28), the DataSource engine enum, and EF Core's Microsoft.EntityFrameworkCore.Metadata.Builders.IndexBuilder.
  • Concept introduced, the filtered (partial) index under soft delete. [Rubric §8, Data Architecture] assesses whether the storage design accounts for the consequences of its own conventions, and [Rubric §12, Performance and Scalability] assesses whether indexes match the queries they serve. Soft delete (ADR-005) means a "deleted" row is still physically present with IsDeleted = 1, and the global query filter on ApplicationDbContext hides it from every read. An index that does not carry the same predicate therefore indexes rows no query will ever return: the deleted rows still occupy index pages, and the optimizer's row estimates include them. The doc comment states this in one sentence (IndexBuilderExtensions.cs:14-18). Adding WHERE [IsDeleted] = 0 to the index makes it a filtered index (SQL Server's term; SQLite calls the same thing a partial index) that matches the shape of every query the application actually issues.
  • Walkthrough
    • The extension receiver is the IndexBuilder returned by builder.HasIndex(...), so the call chains directly off the index declaration (IndexBuilderExtensions.cs:12, usage sample at :23-26).
    • engine defaults to DataSource.SQLServer (IndexBuilderExtensions.cs:53), which matches the majority case of a configuration deriving from EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>; a SQLite or PostgreSQL configuration passes DataSource.Sqlite or DataSource.PostgreSQL explicitly. PostgreSQL needs it for a real reason, not just symmetry: its soft-delete flag is a genuine boolean column, so the predicate the engine produces is "IsDeleted" = false rather than SQL Server's [IsDeleted] = 0 (IndexBuilderExtensions.cs:37-43).
    • additionalFilter is the optional second predicate for an index that is already conditional on something else, for example "[ExternalSessionId] IS NOT NULL" (IndexBuilderExtensions.cs:45-50). The two are joined as {additionalFilter} AND {filterSql} in that exact order (:62-65), which is deliberate: it reproduces the SQL of the hand-authored literal the helper replaced, so switching to the helper does not force a migration. It is also the order SoftDeleteUniqueIndexConvention uses when it appends its own clause, so the two paths yield byte-identical SQL for the same pair of predicates.
    • The body null-guards the receiver, asks SoftDeleteFilterSql to build the predicate from the model's own metadata, and returns the builder unchanged when the builder returns null (IndexBuilderExtensions.cs:56-60). That null is the Cosmos no-op and the "this entity is not soft-deletable" case, so the call is always safe to write.
  • Why it's built this way: the doc comment gives the rationale directly (IndexBuilderExtensions.cs:27-29). A literal HasFilter("[IsDeleted] = 0") hard-codes both the column name and SQL Server's bracket quoting; reading the column name from the model means a HasColumnName rename follows automatically, and delegating the quoting to the engine keeps the same configuration body valid on SQLite. This is the portability posture of ADR-018. The division of labour with SoftDeleteUniqueIndexConvention is explicit: the convention covers every UNIQUE index automatically, and this extension point exists for the case the convention deliberately leaves alone, a hand-authored NON-unique index (IndexBuilderExtensions.cs:20-22). The pair of them is ADR-095.
  • Where it's used: entity configurations across both applications. In MMCA.Store: MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Persistence/EntityConfiguration/OrderConfiguration.cs:44,51,58 (the last one with additionalFilter: "[StripeSessionId] IS NOT NULL"), MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:69,83, .../EntityConfiguration/CustomerConfiguration.cs:82, MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Infrastructure/Persistence/EntityConfiguration/ProductConfiguration.cs:43, .../ProductImageConfiguration.cs:54, .../ProductVariantConfiguration.cs:46, .../CategoryConfiguration.cs:33. In MMCA.ADC's Engagement module: MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:51,56,62,66,69, .../PointsEntryConfiguration.cs:48,52, .../LivePollVoteConfiguration.cs:37, .../LeaderboardOptInConfiguration.cs:35, .../SessionQuestionUpvoteConfiguration.cs:34, .../UserSessionBookmarkConfiguration.cs:34,39. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/IndexBuilderExtensionsTests.cs.
  • Caveats / not-in-source: the ordering constraint is called out in the doc comment (IndexBuilderExtensions.cs:31-35). Unlike the convention, which runs at model finalizing, this member reads the column name at the moment it is called, so a HasColumnName on the soft-delete property must be declared before the index. That only bites a model that renames the column.

EntityTypeConfiguration<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:27 · Level 12 · class (public abstract)

  • What it is: the engine-aware configuration base and the busiest type in this family. It reads the target engine off a UseDataSourceAttribute on the concrete configuration class (or on an inherited shim base) and hands that engine's table/container mapping plus key generation to the matching engine strategy, so a consumer's Configure body only ever describes columns, indexes, and relationships (EntityTypeConfiguration.cs:27-79). It also exposes EffectiveEngine, the engine of the model actually being built, for the few configurations that write engine-specific SQL text themselves (:54-61).
  • Depends on: EntityTypeConfigurationBase<TEntity, TIdentifierType> (base, EntityTypeConfiguration.cs:28); all four engine markers, IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, IEntityTypeConfigurationPostgreSQL<TEntity, TIdentifierType>, IEntityTypeConfigurationSqlite<TEntity, TIdentifierType>, IEntityTypeConfigurationCosmos<TEntity, TIdentifierType> (:29-32); AuditableBaseEntity<TIdentifierType> as the entity constraint (:4, :33); UseDataSourceAttribute and the DataSource enum (:3, :43, :61, :69); DataSourceEngines (:5, :77), which returns the registered IDataSourceEngine for an engine value (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/DataSourceEngines.cs:40-43); the internal EngineAnnotation constant of ApplicationDbContext (:48, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:67); System.Reflection and EF Core's EntityTypeBuilder<TEntity> (:1-2).
  • Concept introduced, one configuration body that is portable across storage engines. The naive way to support four engines is four configuration classes per entity, or one class littered with if (engine == ...). This class removes both. The engine is declared once, as an attribute, and everything that actually differs between engines is owned by one strategy per engine: this class looks the strategy up with DataSourceEngines.For(engine) and calls its ApplyKeyAndTableMapping (EntityTypeConfiguration.cs:77, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/IDataSourceEngine.cs:95). SQLServerDataSourceEngine and PostgreSQLDataSourceEngine apply the identical mapping, a table in a module schema (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:100-106, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/PostgreSQLDataSourceEngine.cs:98-101); SqliteDataSourceEngine gives a bare table, since SQLite has no schemas (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SqliteDataSourceEngine.cs:97-102); CosmosDataSourceEngine gives a per-module container with the entity id as partition key (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/CosmosDataSourceEngine.cs:92-101). Moving an entity from SQL Server to Cosmos, or from SQL Server to PostgreSQL, is therefore a one-line attribute change. Two other pieces of the framework complete the portability claim rather than this class alone: CosmosDbContext strips every relational index from the built model, because the Cosmos provider rejects them and indexes every property itself (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/CosmosDbContext.cs:129-137), and CrossDataSourceDegradeConvention removes FK constraints and navigations that would span physical sources while keeping the declared scalar FK column and a compensating index (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/CrossDataSourceDegradeConvention.cs:10-22). A dedicated test builds a Cosmos model offline from a configuration that keeps a filtered index and a cross-source relationship, and asserts the indexes are gone, the FK is gone, the scalar FK column survives, and the foreign principal is not in the model (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/CosmosConfigurationPortabilityTests.cs:70-76). [Rubric §8, Data Architecture] assesses how deliberately the storage shape is chosen and how tightly the model is bound to one engine. [Rubric §7, Microservices Readiness] assesses whether a module can be lifted out without a rewrite; being able to re-point an entity's engine and database with attributes is a precondition for that (ADR-018, ADR-006, ADR-113). [Rubric §15, Best Practices & Code Quality] assesses whether one decision lives in one place; each engine's mapping lives in exactly one strategy, and this file names no concrete engine value at all. That last property is enforced: DataSourceBranchingFitnessTests scans Source/**/*.cs and fails on any concrete engine named outside an exact allow-list, which lists the four shim bases but not this class (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/DataSourceBranchingFitnessTests.cs:24-68, :71-80; ADR-130).
  • Concept introduced, discovery and routing that agree by construction. This class implements all four engine marker interfaces (EntityTypeConfiguration.cs:29-32), so a configuration derived from it is discovered during every engine's model pass. That sounds wrong until you see the filter: ApplyConfigurationsForEntitiesInContext applies a discovered configuration only when EntityDataSourceRegistry says the entity's DataSourceKey equals this context instance's key (ApplicationDbContext.cs:979-981). Both sides read the same attributes: the registry derives the engine from UseDataSourceAttribute and the logical database from UseDatabaseAttribute, falling back to the entity's module namespace and then to Default (EntityDataSourceRegistry.cs:180-182), while this class reads the same attribute for its conventions. Discovery is broad, routing is exact, and there is no second source of truth to drift. [Rubric §1, SOLID] assesses whether behavior is driven from one declaration; here the attribute is that declaration.
  • Concept introduced, declared engine versus effective engine. The declared engine and the engine of the model being built usually agree, but not always: when a host substitutes an unconfigured engine (the doc comment's example is a SQL Server configuration applied to a PostgreSQL-only host), the configuration's own attribute is the wrong input for hand-written SQL such as an index filter (EntityTypeConfiguration.cs:54-60). ApplyConfigurationsForEntitiesInContext therefore stamps the context's engine onto the model as an annotation for the duration of the configuration pass and removes it in a finally, so the finished model and every migration snapshot are unchanged (ApplicationDbContext.cs:965-970, :983-986). Configure copies that annotation into EffectiveEngine, falling back to the declared engine when no annotation is present (EntityTypeConfiguration.cs:48-49). The key and table mapping still uses the declared engine (:51); EffectiveEngine is for SQL text only. The framework's own consumers show the use: PushNotificationConfiguration builds a filtered unique index whose predicate is quoted for EffectiveEngine through SoftDeleteFilterSql (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/Notifications/PushNotificationConfiguration.cs:72-73), and UserNotificationConfiguration passes it to HasSoftDeleteFilter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/Notifications/UserNotificationConfiguration.cs:45).
  • Walkthrough
    • Declaration (EntityTypeConfiguration.cs:27-34): abstract, extends the base class, implements the four markers, constrained to an audited entity with a non-null identifier type.
    • Configure override (EntityTypeConfiguration.cs:37-52): null-guards the builder (:39), calls base.Configure(builder) so the DomainEvents exclusion runs first (:41), then reads the engine.
    • The attribute read and its failure mode (EntityTypeConfiguration.cs:43-46): GetType().GetCustomAttribute<UseDataSourceAttribute>()?.DataSource on the runtime type, which is the concrete configuration. UseDataSourceAttribute is declared with Inherited = true (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/UseDataSourceAttribute.cs:12), which is exactly why a class deriving from one of the shim bases inherits its engine without repeating the annotation. When the attribute is missing entirely the code throws InvalidOperationException naming the offending configuration class, rather than defaulting to an engine and mapping the entity somewhere surprising.
    • EffectiveEngine (EntityTypeConfiguration.cs:48-49, :61): protected with a private set, assigned from the model's engine annotation or the declared engine, then ApplyEngineConventions(builder, engine) runs with the declared engine (:51). The doc comment states it is valid inside Configure after the base call (:58-59).
    • ApplyEngineConventions(builder, engine), protected static (EntityTypeConfiguration.cs:69-78): extracted as a static helper so the provider shims share the identical logic. After its own null guard (:71) it is a single delegation, DataSourceEngines.For(engine).ApplyKeyAndTableMapping<TEntity, TIdentifierType>(builder) (:77); the inline comment records that each engine owns its mapping, that PostgreSQL takes the SQL Server mapping unchanged (ADR-113), and that Cosmos puts a module's entities in one container partitioned by id (:73-76). An engine value with no registered strategy throws InvalidOperationException ("DataSource ... not implemented") from DataSourceEngines.For instead of silently producing an unmapped entity (DataSourceEngines.cs:40-43).
    • What the strategies apply: every engine calls HasKey(p => p.Id) and reads typeof(TEntity).IsIdValueGenerated, the presence test for IdValueGeneratedAttribute via EntityTypeExtensions (MMCA.Common/Source/Core/MMCA.Common.Domain/Extensions/EntityTypeExtensions.cs:19). SQL Server and PostgreSQL map ToTable(entityName, moduleName ?? "dbo"), so the SQL schema is the module name derived from the entity namespace and unmoduled entities land in dbo, then ValueGeneratedOnAdd() when the entity opts into generated ids and ValueGeneratedNever() otherwise (SQLServerDataSourceEngine.cs:101-106). That last branch is what lets a domain factory assign an id itself without EF overwriting it. SQLite maps ToTable(entityName) with no schema and adds .UseIdentityColumn(1, 1) in the generated-id case (SqliteDataSourceEngine.cs:97-102). Cosmos maps ToContainer(moduleName ?? entityName).HasPartitionKey(p => p.Id) and uses CosmosIntIdValueGenerator for generated ids (CosmosDataSourceEngine.cs:94-101).
    • NamespaceConventions.GetModuleName (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/NamespaceConventions.cs:20-21): an internal one-line delegation to ModuleNameConventions.GetModuleName, documented as taking the namespace segment immediately preceding Domain (MMCA.Store.Sales.Domain.Orders yields Sales) and returning null when there is no Domain segment (NamespaceConventions.cs:13-19). The engine strategies use it for the schema and container name (SQLServerDataSourceEngine.cs:101, CosmosDataSourceEngine.cs:95), and the same helper feeds the logical database name in the registry (EntityDataSourceRegistry.cs:181), which is the point of it being shared: schema and database name can never drift apart.
  • Why it's built this way: the framework's stated multi-database model is one context class per engine and one instance per database (ADR-006), with the engine as an orthogonal axis (ADR-018). That only works if the mapping conventions for an engine live in one place that every context can call. This class is the single entry point and the engine strategy is that place (ADR-130), so adding an engine means one more strategy in the DataSourceEngines registry (DataSourceEngines.cs:22-28) rather than an edit here. Keeping ApplyEngineConventions static and protected rather than inlining it in Configure is what allows the shims to exist as empty declarations.
  • Where it's used: extended by the four shim bases below (EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, EntityTypeConfigurationPostgreSQL<TEntity, TIdentifierType>, EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, EntityTypeConfigurationCosmos<TEntity, TIdentifierType>), and directly by any configuration that prefers to carry its own [UseDataSource(...)] annotation, as one portability test does (CosmosConfigurationPortabilityTests.cs:101-103). EffectiveEngine is read by the framework's notification configurations (PushNotificationConfiguration.cs:72-73, UserNotificationConfiguration.cs:45). Its conventions are pinned against a real SQLite model in SqliteConfig_Configure_SetsTableNameAndKey and SqliteConfig_Configure_SetsValueGeneratedNever_WhenNoAttribute (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/EntityTypeConfigurationTests.cs:22, :37).

EntityTypeConfigurationCosmos<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationCosmos.cs:18 · Level 7 · class (public abstract)

  • What it is: a body-less shim over EntityTypeConfiguration<TEntity, TIdentifierType> whose only content is the class-level [UseDataSource(DataSource.CosmosDB)] annotation (EntityTypeConfigurationCosmos.cs:17-21). Deriving from it is equivalent to deriving from the engine-aware base and annotating the concrete class by hand, as its own doc says (EntityTypeConfigurationCosmos.cs:10-13).
  • Depends on: EntityTypeConfiguration<TEntity, TIdentifierType> (base, EntityTypeConfigurationCosmos.cs:19); UseDataSourceAttribute and the DataSource enum (:1, :17); AuditableBaseEntity<TIdentifierType> (constraint, :20).
  • Concept reinforced, an attribute expressed as a base class. All mapping logic (per-module container, entity-id partition key, client-side id generation via CosmosIntIdValueGenerator) lives in the engine-aware base; this type exists so the engine choice reads as part of the class declaration a consumer already writes, and so Inherited = true on the attribute (UseDataSourceAttribute.cs:12) carries it to the concrete class. The declaration ends in a semicolon (EntityTypeConfigurationCosmos.cs:21): there is genuinely no body.
  • Where it's used: no configuration in MMCA.Common, MMCA.ADC, MMCA.Store, or the Common test suite derives from it today. The Cosmos path is exercised instead through a direct [UseDataSource(DataSource.CosmosDB)] annotation on a configuration deriving from the engine-aware base (CosmosConfigurationPortabilityTests.cs:101-103), and the Aspire hosting extensions name the type when documenting which entities a Cosmos data source will serve (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:528). This matches the state ADR-018 records: the plumbing ships and is tested, with no non-SQL entity in production today.

EntityTypeConfigurationPostgreSQL<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationPostgreSQL.cs:22 · Level 7 · class (public abstract)

  • What it is: the PostgreSQL shim, [UseDataSource(DataSource.PostgreSQL)] over the engine-aware base (EntityTypeConfigurationPostgreSQL.cs:21-25). Its own doc records that the mapping it produces is the SQL Server one, not PostgreSQL house style: a table per entity inside the module schema with an identity key, so an entity moves between the two engines with a single base-class change and no configuration-body edits (:6-17).
  • Depends on: EntityTypeConfiguration<TEntity, TIdentifierType> (base, EntityTypeConfigurationPostgreSQL.cs:23); UseDataSourceAttribute and DataSource (:21); AuditableBaseEntity<TIdentifierType> (:24).
  • Concept reinforced: the attribute-as-base-class shim taught at EntityTypeConfigurationCosmos<TEntity, TIdentifierType>. The engine-aware base hands the table/key mapping to the target engine (DataSourceEngines.For(engine).ApplyKeyAndTableMapping, EntityTypeConfiguration.cs:73-77), and the PostgreSQL engine's implementation is deliberately identical to SQL Server's: a table named for the entity in the module schema (falling back to dbo), the Id key, and store generation only when the identifier type is value-generated (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/PostgreSQLDataSourceEngine.cs:92-106). PostgreSQL house style (snake_case identifiers, the public schema) is left to a consumer's own naming-convention plugin rather than built into the framework (ADR-113, EntityTypeConfiguration.cs:73-76).
  • Where it's used: exercised by the framework's own PostgreSQL test suite, PostgreSQLPersistenceTests.cs, DesignTimeDbContextHelperTests.cs, and PostgreSQLDbContextModelTests.cs (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.PostgreSQL.Tests/ and MMCA.Common.Infrastructure.Tests/). No production configuration in ADC, Store, or Helpdesk derives from it today, matching the state ADR-113 records: the engine is plumbed and tested, with no PostgreSQL entity shipped in either app yet.

EntityTypeConfigurationSqlite<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSqlite.cs:17 · Level 7 · class (public abstract)

  • What it is: the SQLite shim, identical in shape to its Cosmos sibling: a body-less class carrying [UseDataSource(DataSource.Sqlite)] (EntityTypeConfigurationSqlite.cs:16-20).
  • Depends on: EntityTypeConfiguration<TEntity, TIdentifierType> (base, EntityTypeConfigurationSqlite.cs:18); UseDataSourceAttribute and DataSource (:1, :16); AuditableBaseEntity<TIdentifierType> (:19).
  • Concept reinforced: the attribute-as-base-class shim taught at EntityTypeConfigurationCosmos<TEntity, TIdentifierType>. Its doc records the SQLite-specific mapping it delegates: table name plus an auto-increment key (EntityTypeConfigurationSqlite.cs:7-12), which the engine-aware base hands to the SQLite engine (EntityTypeConfiguration.cs:77), implemented at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SqliteDataSourceEngine.cs:91-103.
  • Where it's used: no production configuration in the three repos derives from it, but it is the framework's own in-memory-and-file test engine and is used throughout the Common test suite: the database-initialization tests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:321, :279), the convention tests (EntityTypeConfigurationTests.cs:58), the multi-source integration tests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/MultiSourceSqliteIntegrationTests.cs:240, :241), and the registry tests (EntityDataSourceRegistryTests.cs:217, :220, including a deliberate duplicate-configuration pair at :228 and :231).

EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:17 · Level 7 · class (public abstract)

  • What it is: the SQL Server shim, [UseDataSource(DataSource.SQLServer)] over the engine-aware base (EntityTypeConfigurationSQLServer.cs:16-20). It is the type nearly every entity configuration in this workspace actually derives from, which makes it the practical entry point into everything the family does.
  • Depends on: EntityTypeConfiguration<TEntity, TIdentifierType> (base, EntityTypeConfigurationSQLServer.cs:18); UseDataSourceAttribute and DataSource (:1, :16); AuditableBaseEntity<TIdentifierType> (:19).
  • Concept reinforced, and what a consumer inherits by writing one base name. Deriving from this class buys four behaviors without a line of code: the DomainEvents exclusion from EntityTypeConfigurationBase<TEntity, TIdentifierType>, a table named after the entity in a schema named after the module (EntityTypeConfiguration.cs:74), a primary key with the right generation policy for the entity's IdValueGeneratedAttribute (:67-71), and a DataSourceKey in EntityDataSourceRegistry that makes the entity routable through IUnitOfWork and DbContextFactory. Adding a class-level UseDatabaseAttribute on top overrides only the logical database, leaving the engine alone (EntityDataSourceRegistry.cs:180-182). [Rubric §15, Best Practices & Code Quality] assesses how much a consumer must know to add an entity correctly; here it is one base class name.
  • Where it's used: 28 configurations across ADC's Conference, Engagement, and Identity modules (for example MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:13 and MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/LivePolls/LivePollConfiguration.cs:16), 12 across Store's Catalog, Sales, and Identity modules, MMCA.Helpdesk's two Tickets configurations (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Infrastructure/Persistence/EntityConfiguration/TicketConfiguration.cs, MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Infrastructure/Persistence/EntityConfiguration/TicketCommentConfiguration.cs), and the framework's own notification configurations, PushNotificationConfiguration and UserNotificationConfiguration. It is also the SQL Server half of the cross-engine portability test (CosmosConfigurationPortabilityTests.cs:116).

StronglyTypedIdModelConfiguration

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conventions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/StronglyTypedIdModelConfiguration.cs:21 · Level 6 · class (public static)

  • What it is: the single call that wires strongly typed identifiers (ADR-115) into EF Core's model, once per host. Apply walks the host's StronglyTypedIdRegistry and declares one pre-convention type mapping per identifier type it describes, so every property of that type, keys, cross-module scalar references, owned-type members and the properties of a framework table alike, maps to its wrapped primitive (StronglyTypedIdModelConfiguration.cs:7-12, :29-51).
  • Depends on: EF Core's ModelConfigurationBuilder, StronglyTypedIdValueConverter<TSelf, TValue> and its comparer counterpart from MMCA.Common.Infrastructure.Persistence.Conversions, and StronglyTypedIdRegistry from MMCA.Common.Shared.Identifiers.
  • Concept introduced, pre-convention configuration versus a model-finalizing convention. [Rubric §8, Data Architecture] assesses whether a cross-cutting mapping rule is applied at the point in the pipeline where it actually works, and [Rubric §1, SOLID] assesses whether the opt-in stays orthogonal to code that never adopts it. EF Core exposes two very different places to declare a type-wide rule: ConfigureConventions (pre-convention, runs before the provider's own conventions see the model) and a model-finalizing IModelFinalizingConvention like SoftDeleteUniqueIndexConvention (runs last). The class doc comment states why this one has to be the former (StronglyTypedIdModelConfiguration.cs:14-18): a converter attached at finalization arrives after the provider's value-generation conventions have already inspected the property, so a wrapped int key would silently lose its IDENTITY strategy. Declaring the mapping here means the provider CLR type is known before any of that runs, and a wrapped key generates exactly as its primitive did.
  • Walkthrough
    • Apply(configurationBuilder, registry) (StronglyTypedIdModelConfiguration.cs:29) null-guards both arguments, then iterates registry.Describe(), which yields the (identifierType, valueType) pairs the host declared (:36).
    • For each pair it closes the two open generics reflectively: StronglyTypedIdValueConverter<,>.MakeGenericType(identifierType, valueType) and StronglyTypedIdValueComparer<>.MakeGenericType(identifierType) (StronglyTypedIdModelConfiguration.cs:38-41), then calls configurationBuilder.Properties(identifierType).HaveConversion(converterType, comparerType) (:43-45), the pre-convention API that applies the conversion to every property of that CLR type across the whole model, not just one entity's.
    • configured counts the identifiers actually wired and is returned (StronglyTypedIdModelConfiguration.cs:34,47,50), giving the caller a number to log or assert against rather than a bare void.
  • Why it's built this way: strongly typed identifiers sit on the same opt-in footing as smart enumerations (ADR-115): a wrapper is two lines, and adopting the whole feature is one DI call, services.AddStronglyTypedIds(typeof(OrderId).Assembly). The EF half of that contract has to be equally uniform, one call from ApplicationDbContext.ConfigureConventions, which every engine context inherits, so SQL Server, PostgreSQL, SQLite and Cosmos all get the same mapping without an engine branch (StronglyTypedIdModelConfiguration.cs:10-12). Driving the mapping from the registry rather than from a hard-coded type list means a host that never wraps an identifier calls Apply over an empty registry and nothing changes.
  • Where it's used: called once from ApplicationDbContext's ConfigureConventions (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs).

IRepositoryFactory

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories.Factory · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/IRepositoryFactory.cs:11 · Level 7 · interface (public)

  • What it is: a two-method contract that builds a repository over a caller-supplied DbContext (IRepositoryFactory.cs:11-34). Create returns a read-write IRepository<TEntity, TIdentifierType>, CreateReadOnly returns an IReadRepository<TEntity, TIdentifierType>, and the class doc records the one behavior an implementation is expected to fold in: conditional MiniProfiler wrapping (IRepositoryFactory.cs:7-10).
  • Depends on: EF Core's DbContext as the single parameter of both methods (NuGet, IRepositoryFactory.cs:1, :20, :31); IRepository<TEntity, TIdentifierType> and IReadRepository<TEntity, TIdentifierType> as return types (:2, :19, :30); AuditableAggregateRootEntity<TIdentifierType> and AuditableBaseEntity<TIdentifierType> as the two entity constraints (:3, :21, :32).
  • Concept introduced, a factory for the argument DI cannot supply. Plain constructor injection can hand a repository a DbContext, and the container does exactly that for the open-generic registration TryAddScoped(typeof(IRepository<,>), typeof(EFRepository<,>)) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:128). That registration can only ever bind one context, because DI resolves by type. This framework does not have one context: it has one context instance per DataSourceKey, created and cached per scope by DbContextFactory, and which instance an entity belongs to is a runtime lookup through IDataSourceService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:55-56). So the context stops being a dependency and becomes an argument, and an argument-taking creation step is a factory. This is the type-level reason the codebase forbids constructor-injecting IRepository<,> directly: that path silently binds whatever the container's single registration resolves, while everything routed through IUnitOfWork reaches this factory and gets the context its entity actually lives in. [Rubric §2, Design Patterns] assesses whether a pattern earns its place rather than decorating the code; here the factory exists because DI provably cannot express the requirement. [Rubric §8, Data Architecture] assesses how deliberately storage boundaries are drawn; per-source repository construction is what keeps database-per-service (ADR-006) true at the level a handler touches.
  • Walkthrough
    • Create<TEntity, TIdentifierType>(DbContext dbContext) (IRepositoryFactory.cs:19-22): constrained to TEntity : AuditableAggregateRootEntity<TIdentifierType> and TIdentifierType : notnull. Writes go through aggregate roots only, which is the DDD rule expressed in the signature rather than in a comment.
    • CreateReadOnly<TEntity, TIdentifierType>(DbContext dbContext) (IRepositoryFactory.cs:30-33): the same shape with a looser entity constraint, AuditableBaseEntity<TIdentifierType>. Reads may target a child entity that is not itself an aggregate root, which is exactly the asymmetry IUnitOfWork exposes on its own GetRepository / GetReadRepository pair (UnitOfWork.cs:48-50, :53-55).
    • What the interface deliberately does not say: nothing about profiling, decoration, caching, or activation. Those are implementation choices, and RepositoryFactory makes them all.
  • Why it's built this way: extracting the two lines from UnitOfWork into a contract means the unit of work never asks "is profiling on"; it asks for a repository and gets whichever composition the host configured. It also gives the test suite a substitution point that does not require a real context factory.
  • Where it's used: registered scoped as IRepositoryFactory -> RepositoryFactory (DependencyInjection.cs:129) and pinned by AddInfrastructure_RegistersIRepositoryFactory (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/DependencyInjectionInfrastructureTests.cs:239-252). Its only production consumer is UnitOfWork, which injects it (UnitOfWork.cs:13, :16) and calls Create (:42) and CreateReadOnly (:62) once per entity type, caching the result for the rest of the scope (:38-44, :58-64).

CrossDataSourceDegradeConvention

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conventions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/CrossDataSourceDegradeConvention.cs:34 · Level 11 · class (public sealed)

  • What it is: the model-finalizing convention that makes database-per-service work without forcing every module to write two versions of its entity configuration. It finds relationships whose two ends resolve to different physical databases and degrades them: the foreign key goes away, the navigation members are ignored, and entity types belonging to another database are removed from this model entirely (CrossDataSourceDegradeConvention.cs:10-34).
  • Depends on: DataSourceKey (the context's own physical source) and IEntityDataSourceRegistry (the entity-to-database map), both primary-constructor parameters (CrossDataSourceDegradeConvention.cs:34-36); DataSourceEngines and the IsRelational flag of DataSourceEngineCapabilities, read to decide whether a compensating index is allowed (CrossDataSourceDegradeConvention.cs:66, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/DataSourceEngines.cs:40); EF Core's IModelFinalizingConvention plus the mutable metadata surface (IMutableModel, IMutableEntityType, IMutableForeignKey, IMutableProperty).
  • Concept introduced, degrading a relationship instead of forbidding it. [Rubric §7, Microservices Readiness] assesses whether a module can be lifted into its own service without rewriting application code, and [Rubric §8, Data Architecture] assesses whether ownership boundaries in the data model are real. Under database-per-service (ADR-006) an order row and a user row can live in different databases, and no database engine can enforce a foreign key across that line. The naive answers are both bad: forbid the navigation in the domain model (which distorts the domain to fit deployment), or keep the FK and hope the two entities always end up co-located (which breaks the moment they do not). This convention takes a third route. The configuration body is written once, as if everything were in one database, and the model builder subtracts what the current physical source cannot support. Three things are given up and each has a compensating mechanism: the FK constraint is replaced by an index over the surviving scalar columns, in-database navigation is replaced by the INavigationPopulator batch-loading path (ADR-002), and referential consistency is maintained by integration events rather than by the engine (CrossDataSourceDegradeConvention.cs:13-22). The payoff is stated at :26-30: when every entity resolves to the same source, nothing is foreign and the convention is a structural no-op, so the monolith model is identical to the single-database model.
  • Walkthrough
    • ProcessModelFinalizing opens by casting the convention model builder's metadata to IMutableModel (CrossDataSourceDegradeConvention.cs:45-47). The class doc behind the cast is load-bearing: cross-cutting helpers (soft-delete filters, concurrency tokens) have already promoted every entity type to the Explicit configuration source, and convention-sourced builder calls cannot override Explicit, so the mutable API is the only surface that can apply these changes (:23-25).
    • IsForeign (CrossDataSourceDegradeConvention.cs:92-95) is the whole routing decision: look the CLR type's full name up in the registry, and call it foreign when the registry knows it and its key differs from this context's key. An unregistered type is never foreign.
    • The foreign set is computed over non-owned entity types, and when it is empty the method returns at once (CrossDataSourceDegradeConvention.cs:49-56). That early return is the monolith fast path.
    • Step 1, degrade the FKs (CrossDataSourceDegradeConvention.cs:63-75): for each local entity, every declared foreign key whose principal is foreign is passed to DegradeForeignKey. FKs declared on foreign dependents are not touched here because step 3 removes those entity types wholesale. Note that addCompensatingIndex is computed once, as the context engine's IsRelational capability (DataSourceEngines.For(contextKey.Engine).Capabilities.IsRelational, :66), not as a comparison against one named engine.
    • DegradeForeignKey (CrossDataSourceDegradeConvention.cs:108-139) captures the non-shadow FK properties, removes the FK (which takes both navigations with it), and returns early when no compensating index is wanted or no scalar column survives (:119-122). The subtle part is :124-131: EF's own ForeignKeyIndexConvention created an index for the FK, and its removal is processed by a deferred event that would fire after the coverage check below, silently leaving the column unindexed. So the convention drops that convention-sourced index eagerly itself, then checks coverage and adds a plain index only if nothing already covers the columns (:133-138).
    • HasCoveringIndex (CrossDataSourceDegradeConvention.cs:141-145) treats an index as covering when the FK columns are a prefix of its column list, in order, which is exactly the condition under which a composite index can serve a lookup on those columns.
    • Step 2, ignore the foreign members (CrossDataSourceDegradeConvention.cs:80-83, implementation at :152-166): skip navigations pointing at foreign entities are removed, then every CLR property whose type (or collection element type) is foreign is added to the ignore list. The comment at :77-79 explains why the second half is needed: removing a navigation leaves an unmapped CLR property of entity type behind, and EF's model validation rejects that.
    • UnwrapCollectionElementType (CrossDataSourceDegradeConvention.cs:172-175) is the one-argument-generic unwrap that makes ICollection<ForeignEntity> detectable as a collection of foreign entities.
    • Step 3, remove the foreign entity types (CrossDataSourceDegradeConvention.cs:85-89). EF's relationship discovery pulls foreign types into the model as a side effect; this is where they leave it.
  • Why it's built this way: the carve-out on the compensating index is spelled out at CrossDataSourceDegradeConvention.cs:101-106. Cosmos, the one non-relational engine (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/DataSourceEngineCapabilities.cs:14-19), auto-indexes every property and rejects explicit index definitions, so adding a compensating index would fail model validation, and skipping it is what keeps a configuration body carrying a cross-source relationship portable to Cosmos without edits (ADR-018). Reading the IsRelational capability rather than testing for DataSource.CosmosDB puts this decision on the same single flag that SoftDeleteUniqueIndexConvention and RestrictDeleteByDefaultConvention consult for their own engine guard, so the three conventions cannot disagree about which engines support indexes and constraints. Doing all of this at model finalizing rather than at configuration time is what lets the same entity configuration serve the monolith and the extracted-service topology (ADR-008) with no per-topology branching in module code.
  • Where it's used: registered by ApplicationDbContext in ConfigureConventions with the context's DataSourceKey and the resolved IEntityDataSourceRegistry (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:383-387), first of the three finalizing conventions, ahead of SoftDeleteUniqueIndexConvention (:392) and RestrictDeleteByDefaultConvention (:399). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/CrossDataSourceDegradeConventionTests.cs.

RestrictDeleteByDefaultConvention

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conventions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/RestrictDeleteByDefaultConvention.cs:42 · Level 11 · class (public sealed)

  • What it is: a model-finalizing convention that stamps every foreign key in the model with the source of its delete behavior, and restricts the ones nobody configured. It walks every declared foreign key once and defaults an unconfigured or convention-sourced cascade to DeleteBehavior.Restrict, unless the foreign key belongs to an owned-type relationship, whose cascade EF itself owns (RestrictDeleteByDefaultConvention.cs:42-108).
  • Depends on: EF Core's IModelFinalizingConvention, IConventionModelBuilder, IConventionForeignKey, ConfigurationSource and DeleteBehavior; DataSource (its one primary-constructor parameter, RestrictDeleteByDefaultConvention.cs:42); and DataSourceEngines with the IsRelational flag of DataSourceEngineCapabilities for its engine guard (:66).
  • Concept introduced, defaulting to the safe cascade instead of the convenient one. [Rubric §8, Data Architecture] assesses whether the storage design prevents the failure modes its own conventions could otherwise create, and [Rubric §15, Best Practices and Code Quality] assesses whether a default is a deliberate choice rather than whatever the underlying framework happens to ship. EF Core's own convention cascades a required foreign key by default, which means a relationship nobody explicitly configured silently deletes dependents when the principal is deleted, below the aggregate's invariants, the soft-delete filter and the domain events (RestrictDeleteByDefaultConvention.cs:14-21). This convention overrides that default: an unconfigured or convention-sourced delete behavior becomes Restrict, so a delete that would fan out across rows the domain never asked to remove fails loudly at the database instead of succeeding silently, and a genuine cascade becomes an .OnDelete(DeleteBehavior.Cascade) written in the entity configuration.
  • Walkthrough
    • DeleteBehaviorSourceAnnotation, ExplicitSource, ConventionSource and OwnershipSource (RestrictDeleteByDefaultConvention.cs:44-57) are the four public constants: the annotation name the convention stamps on every foreign key (MMCA:DeleteBehaviorSource, :48), and the three values it can carry, recording whether the behavior came from an explicit configuration call, from this convention, or from EF's ownership handling.
    • ProcessModelFinalizing (RestrictDeleteByDefaultConvention.cs:60-81) null-guards the model builder, returns immediately when the engine is not relational (!DataSourceEngines.For(engine).Capabilities.IsRelational, :66-69, the same capability check SoftDeleteUniqueIndexConvention and CrossDataSourceDegradeConvention make), then materializes every declared foreign key across the model into a list before applying anything (:71-75). The class doc gives the non-relational reason: Cosmos has no foreign-key constraints to restrict, and stamping a delete-behavior decision on a model that cannot enforce one would only make the audit lie (:35-39). The comment above the materialization names why it is a list: the cross-source convention runs first and may already have removed relationships, so nothing here should observe a collection mid-mutation.
    • Apply (RestrictDeleteByDefaultConvention.cs:88-107) is the per-foreign-key decision. An ownership foreign key is stamped OwnershipSource and left untouched (:90-94): EF owns that cascade, and overriding it would fight the owned-type feature. Otherwise the foreign key's existing delete-behavior ConfigurationSource is read; null (nobody ever set one) or ConfigurationSource.Convention (EF's own required-FK rule produced the cascading default) are both treated as "inherited, and ours to replace": the behavior is set to DeleteBehavior.Restrict and stamped ConventionSource (:96-104). Anything else, meaning a configuration explicitly set a delete behavior, is stamped ExplicitSource and left exactly as configured (:106).
  • Why it's built this way: the annotation is not cosmetic. Stamping the source of every delete behavior, rather than just flipping the ones that need it, gives a fitness test a byte-for-byte answer to "did this foreign key's cascade come from an explicit choice, from this convention, or from ownership", read straight off a finalized model without re-running the convention (RestrictDeleteByDefaultConvention.cs:29-34). That is what ADR-119 records as the decision: default every unconfigured relationship to Restrict rather than to EF's own cascading default, and make the source of that decision inspectable rather than implicit. Running at model finalizing, after every module's IEntityTypeConfiguration has declared its own relationships, is what lets the convention see the whole model without needing to know which modules exist, the same timing SoftDeleteUniqueIndexConvention uses for the same reason.
  • Where it's used: registered by ApplicationDbContext in ConfigureConventions with the context's own engine (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:400), last of the three finalizing conventions so it never stamps a relationship the cross-source convention has already removed (ApplicationDbContext.cs:395-399). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conventions/RestrictDeleteByDefaultConventionTests.cs and by MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Domain/DeleteBehaviorConventionTests.cs, which subclasses the shared DeleteBehaviorConventionTestsBase and the ArchitectureRules.DeleteBehavior rule group so ADC's own model is checked against the same contract.

SoftDeleteUniqueIndexConvention

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Conventions · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:34 · Level 11 · class (public sealed)

  • What it is: an EF Core model-finalizing convention that puts the IsDeleted = 0 predicate on every unique index of every soft-deletable entity type in the model, so a soft-deleted row stops occupying its unique slot (SoftDeleteUniqueIndexConvention.cs:11-34).
  • Depends on: EF Core's IModelFinalizingConvention, IConventionModelBuilder and IConventionEntityType; SoftDeleteFilterSql for the predicate text; the DataSource engine enum, taken as its one primary-constructor parameter (SoftDeleteUniqueIndexConvention.cs:34); DataSourceEngines with the IsRelational flag of DataSourceEngineCapabilities for its engine guard (:43); and IAuditableEntity as the marker for "this entity is soft-deletable".
  • Concept introduced, the model-finalizing convention as a cross-cutting model rule. [Rubric §6, CQRS & Event-Driven Design] assesses whether policies that apply to every entity are expressed once rather than repeated per configuration, and [Rubric §8, Data Architecture] assesses whether the data model's invariants actually hold. EF exposes convention hooks that run while the model is being built; IModelFinalizingConvention is the last of them, which means it observes the model after every module's IEntityTypeConfiguration has declared its indexes. That timing is what makes a blanket rule possible: the convention does not need to know which modules exist or what they declared, it just walks the finished model. The bug it closes is a genuine soft-delete trap, stated in the doc comment (SoftDeleteUniqueIndexConvention.cs:12-17): soft-delete a speaker, and the unique index on email still blocks creating a new speaker with that email, because the row is invisible to the application but entirely visible to the database's uniqueness check. Adding the filter aligns what the database enforces with what the application shows.
  • Walkthrough
    • ProcessModelFinalizing (SoftDeleteUniqueIndexConvention.cs:37-51) null-guards the model builder, then returns immediately when the engine is not relational (!DataSourceEngines.For(engine).Capabilities.IsRelational, :43-44). The class doc names the coverage: SQL Server, PostgreSQL and SQLite all support partial or filtered indexes, and the convention is a no-op for Cosmos (:28-31).
    • The entity selection is two predicates (SoftDeleteUniqueIndexConvention.cs:46-47): the CLR type must be assignable to IAuditableEntity, and the entity type must not be owned. Owned types share their owner's table and have no independent index story, so they are excluded.
    • ApplyFilterToUniqueIndexes (SoftDeleteUniqueIndexConvention.cs:53-82) asks SoftDeleteFilterSql to build the predicate for this engine (:57, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:35) and bails when it is null (:58-59), then walks the entity's indexes, skipping every non-unique one (:63-64). The predicate text is engine-specific: [IsDeleted] = 0 on SQL Server, "IsDeleted" = 0 on SQLite, "IsDeleted" = false on PostgreSQL, whose flag is a real boolean (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:117, SqliteDataSourceEngine.cs:115, PostgreSQLDataSourceEngine.cs:116).
    • For a unique index the convention branches three ways on the existing filter. No filter at all: set the soft-delete predicate (SoftDeleteUniqueIndexConvention.cs:67-71). A filter that already constrains the soft-delete column, whether a hand-authored literal or the output of HasSoftDeleteFilter: leave it exactly as it is, detected by SoftDeleteFilterSql.ContainsPredicate (:73-76, the helper at SoftDeleteFilterSql.cs:53). Anything else: append the clause as {existingFilter} AND {filterSql} (:80).
    • That append is the load-bearing part, and the doc comment explains why it replaced the earlier "skip an index that already has a filter" behavior (SoftDeleteUniqueIndexConvention.cs:18-27). Skipping meant the exact partial-unique indexes a model bothered to hand-author (for example one narrowed on [DedupKey] IS NOT NULL) were the only ones a soft-deleted row could keep blocking. The ContainsPredicate check is what keeps appending idempotent: without it, a second model build would produce ... AND [IsDeleted] = 0 AND [IsDeleted] = 0.
  • Why it's built this way: the comment at SoftDeleteUniqueIndexConvention.cs:55-56 names the reason the predicate comes from SoftDeleteFilterSql rather than from a local string: it is the same builder the opt-in extension reaches, so the automatic path and the hand-authored path can never disagree about column name or identifier quoting, and the AND ordering at :78-79 is chosen to match HasSoftDeleteFilter(additionalFilter:) byte for byte. Restricting the automatic behavior to unique indexes is a deliberate blast-radius choice: a unique index without the filter is a correctness bug under soft delete, while a non-unique index without it is only a performance question, and performance questions belong to whoever wrote the index. Guarding on the IsRelational capability rather than on DataSource.CosmosDB keeps this convention aligned with CrossDataSourceDegradeConvention and RestrictDeleteByDefaultConvention on which engines have indexes at all. The whole decision, including the 2026-08-26 revision from skipping to appending, is ADR-095; soft delete as a policy is ADR-005; covering SQL Server, PostgreSQL and SQLite while skipping Cosmos is the engine-portability posture of ADR-018.
  • Where it's used: registered by ApplicationDbContext in ConfigureConventions with the context's own engine (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:393), immediately after CrossDataSourceDegradeConvention and before RestrictDeleteByDefaultConvention. The comment there records the ordering intent and the precedence rule: it runs at finalization, after module configurations have declared their indexes, and hand-authored index filters are respected (ApplicationDbContext.cs:389-391). UserNotificationConfiguration relies on it outright for its unique index, declaring no filter of its own. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Conventions/SoftDeleteUniqueIndexConventionTests.cs.

PushNotificationConfiguration

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.Notifications · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/Notifications/PushNotificationConfiguration.cs:16 · Level 14 · class (internal sealed)

  • What it is: the EF Core mapping for PushNotification, the framework's own broadcast-notification aggregate (PushNotificationConfiguration.cs:16-75). It is one of only two entity configurations that ship inside the framework rather than in a consumer application, and it is where the dedup guarantee behind a retried send is actually enforced.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> as its base (PushNotificationConfiguration.cs:17), including the inherited EffectiveEngine property (:72-73, declared at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:61); PushNotification and its DedupKeyMaxLength / ScopeKeyMaxLength constants (:3, :47, :53, declared at MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:19, :22); PushNotificationInvariants for the title and body lengths (:4, :29, :33); PushNotificationStatus indirectly through the string conversion (:43); UseDatabaseAttribute (:15); the HasSoftDeleteFilter member from IndexBuilderExtensions (:71); QuoteColumn from SoftDeleteFilterSql (:73, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:70-71); EF Core's EntityTypeBuilder<TEntity> (NuGet, :1-2).
  • Concept introduced, a framework-owned entity that has to name its own home. Every other configuration in this workspace lets convention pick the schema and the logical database: a SQL Server entity is mapped by its engine to a table in a schema named by NamespaceConventions, which is the namespace segment immediately preceding Domain (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/Engines/SQLServerDataSourceEngine.cs:101). That rule is right for a consumer entity in MMCA.ADC.Conference.Domain.Sessions (schema Conference) and wrong here, because this entity lives in MMCA.Common.Domain.Notifications.PushNotifications, whose preceding segment is Common. The configuration therefore states both facts explicitly: [UseDatabase("Notification")] (PushNotificationConfiguration.cs:15) fixes the logical database that EntityDataSourceRegistry records, and ToTable(nameof(PushNotification), "Notification") (:25) overrides the auto-derived schema after the base call. The class doc states this reasoning in place (:8-14), including the consequence that matters for a small host: a host with no DataSources:Notification entry keeps these tables in its default database. [Rubric §8, Data Architecture] assesses whether the physical layout is a decision rather than an accident; this is a convention override made visible in two attributes on one class. [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether shared capabilities carry their own infrastructure; the notification feature ships its schema with the framework instead of asking each consumer to re-declare it.
  • Concept introduced, letting the database arbitrate a duplicate send. A "have I already sent this?" check in a handler is a check-then-act race: two retries of the same request can both read "no row" before either writes. The filtered unique index on DedupKey (PushNotificationConfiguration.cs:69-73) removes the race by moving arbitration into the engine, and the filter is what makes it usable: IS NOT NULL keeps the many sends that carry no key from colliding with each other (SQL Server treats NULLs as equal in a unique index), and IsDeleted = 0 keeps a soft-deleted notification from squatting on its key forever. The comment block records both halves, the defect that produced the second one, and why the filter is written for the engine of the model being built (:55-68). [Rubric §12, Performance & Scalability] assesses whether index choices are reasoned; the file argues for one index and against another in the same class. [Rubric §29, Resilience & Business Continuity] assesses behavior under retry; at-least-once delivery upstream is only safe because this index makes a repeated send idempotent at the storage layer.
  • Walkthrough
    • base.Configure(builder) (PushNotificationConfiguration.cs:22): runs the inherited chain first. EntityTypeConfigurationBase<TEntity, TIdentifierType> ignores the aggregate's in-memory DomainEvents collection (EntityTypeConfigurationBase.cs:29-32), then EntityTypeConfiguration<TEntity, TIdentifierType> reads the declared engine off the [UseDataSource(DataSource.SQLServer)] carried by the shim base (EntityTypeConfiguration.cs:43-46, EntityTypeConfigurationSQLServer.cs:16), records EffectiveEngine as the engine annotated on the model being built, falling back to the declared one (EntityTypeConfiguration.cs:48-49), and hands the declared engine to its engine's ApplyKeyAndTableMapping (:51, :68-77). For SQL Server that is table plus schema, HasKey(p => p.Id), and ValueGeneratedOnAdd() because the entity carries IdValueGeneratedAttribute (SQLServerDataSourceEngine.cs:94-104, PushNotification.cs:15).
    • The schema override (PushNotificationConfiguration.cs:25): ToTable(nameof(PushNotification), "Notification"), applied after the base call so it replaces the derived Common schema rather than being replaced by it. Ordering is load-bearing here.
    • Required scalars (:27-39): Title required with HasMaxLength(PushNotificationInvariants.TitleMaxLength) (200, MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/Invariants/PushNotificationInvariants.cs:13), Body required at BodyMaxLength (2000, PushNotificationInvariants.cs:16), SentByUserId and RecipientCount required. The column widths are the same constants the domain validates against (PushNotificationInvariants.cs:18-26), so the database cannot be narrower than the invariant.
    • Status stored as text (:41-44): HasConversion<string>() with HasMaxLength(20). The CLR type is the PushNotificationStatus enum with three members, Pending, Sent, Failed (MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotificationStatus.cs:6-16); persisting the name rather than the ordinal means reordering or inserting an enum member does not silently re-interpret existing rows.
    • DedupKey (:46-47): nullable, bounded by PushNotification.DedupKeyMaxLength (128, PushNotification.cs:19). The domain records that this is typically the Idempotency-Key header value and that the filtered unique index is what arbitrates the race (PushNotification.cs:39-45).
    • ScopeKey (:52-53): nullable, bounded by ScopeKeyMaxLength (128, PushNotification.cs:22), and deliberately not indexed. The comment gives the economics (:49-51): the scope filter runs after the primary-key join from UserNotification, over a table holding one row per send, so an index would cost writes without buying a read.
    • The filtered unique index (:69-73): HasIndex(p => p.DedupKey).IsUnique().HasSoftDeleteFilter(EffectiveEngine, additionalFilter: ...), where the additional filter is the DedupKey column quoted for EffectiveEngine by SoftDeleteFilterSql.QuoteColumn followed by IS NOT NULL (:73). The helper composes the final predicate as {additionalFilter} AND {softDeletePredicate} (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:62-65), and the soft-delete half comes from SoftDeleteFilterSql, which reads the actual column name out of the model and builds the predicate for the given engine (SoftDeleteFilterSql.cs:35). The comment at :58-61 gives the reason the engine is passed explicitly rather than left to the helper's DataSource.SQLServer default (IndexBuilderExtensions.cs:53): a host that configures only PostgreSQL or SQLite substitutes its engine and applies this SQL Server configuration to that model, and the Cosmos context strips relational indexes. On a SQL Server model the result is [DedupKey] IS NOT NULL AND [IsDeleted] = 0, asserted verbatim by DedupKeyIndex_FiltersOutSoftDeletedRows (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/PushNotificationConfigurationTests.cs:26-30) and by OnASqlServerHost_NotificationIndexFilters_KeepTheBracketedForm (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/NotificationConfigurationEngineTests.cs:68); on a PostgreSQL-only host it is "DedupKey" IS NOT NULL AND "IsDeleted" = false (NotificationConfigurationEngineTests.cs:46).
    • Why the opt-in call is belt and braces, not a requirement (:63-68): SoftDeleteUniqueIndexConvention stamps the soft-delete predicate onto every unique index of a soft-deletable entity, and it extends a hand-authored filter instead of skipping it: an index that already declares a predicate gets {existingFilter} AND {filterSql} in that exact order (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:67-80), and an index whose filter already constrains the soft-delete column is left untouched so a second model build cannot append the clause twice (:73-76). Because HasSoftDeleteFilter produces the same SQL in the same order from the same column name, the convention recognizes this index and stops, which is what the configuration's own comment says (PushNotificationConfiguration.cs:66-68).
  • Why it's built this way: the two overrides exist because a framework-owned domain type cannot be named by the convention that names consumer domain types, and stating the target explicitly is cheaper than special-casing the convention. Writing the filter for EffectiveEngine rather than as a bracketed literal is what lets one SQL Server-declared configuration serve a PostgreSQL or SQLite host: the test that pins it states the failure it prevents, a bracketed SQL Server literal being a syntax error on Npgsql (NotificationConfigurationEngineTests.cs:14-17). The soft-delete clause on the dedup index is a fix, not an original design: it closes a defect where a soft-deleted notification held its dedup key permanently, and ADC's migration for it is an explicit expand-contract drop and recreate, since a filtered index predicate cannot be altered in place (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Notification/Migrations/20260804185520_CommonV1141PushNotificationDedupIndexSoftDeleteFilter.cs:13-31). The migration comment argues the safety case explicitly: the new predicate is strictly more permissive, so a previous revision running against the new index still succeeds.
  • Where it's used: discovered by assembly scan rather than by a direct reference. AddNotificationInfrastructure() registers this class's assembly as an entity-configuration source (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:26-31, naming the type only to get its Assembly at :29), and the only production caller is ADC's Notification module (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:33), whose service points the logical Notification source at the ADC_Notification database (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/appsettings.Development.json:37-41). Store hosts no notification entities today: the only other callers of AddNotificationInfrastructure are Common's own DI tests. Behavior is pinned against a real model built from this exact configuration (PushNotificationConfigurationTests.cs:76-82): index uniqueness (:22-24), the composed filter (:26-30), the scope-key length and nullability (:35-41), and the deliberate absence of a scope-key index (:43-49). The engine substitution is pinned separately by NotificationConfigurationEngineTests, which builds the full context over a PostgreSQL-only and a SQL Server Notification source (NotificationConfigurationEngineTests.cs:27-47, :49-69).
  • Caveats / not-in-source: the PushNotificationConfigurationTests double builds the model on SQLite (PushNotificationConfigurationTests.cs:69-71) through a plain DbContext that carries no engine annotation, so EffectiveEngine falls back to the declared SQL Server engine (EntityTypeConfiguration.cs:48-49) and the asserted filter is the bracketed SQL Server form; the engine-substituted output is only asserted by NotificationConfigurationEngineTests.

RepositoryFactory

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Repositories.Factory · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:15 · Level 10 · class (public sealed)

  • What it is: the single implementation of IRepositoryFactory. It activates the concrete EF repository over the supplied DbContext through a compiled, cached constructor delegate, and wraps it in a profiling decorator when the host has MiniProfiler switched on (RepositoryFactory.cs:15-86).
  • Depends on: IServiceProvider and IOptions<ApplicationSettings>, both primary constructor parameters, unwrapped to fields at construction (RepositoryFactory.cs:15-18); ApplicationSettings for the one flag it reads (:6, :34, :58); EFRepository<TEntity, TIdentifierType>, EFReadRepository<TEntity, TIdentifierType>, EFRepositoryDecorator<TEntity, TIdentifierType>, EFReadRepositoryDecorator<TEntity, TIdentifierType> as the four concrete types it can build (:32, :37, :56, :61); Microsoft.Extensions.DependencyInjection.ActivatorUtilities and its ObjectFactory delegate type (NuGet, :3, :81-85); System.Collections.Concurrent.ConcurrentDictionary (BCL, :1, :70); EF Core's DbContext (:2).
  • Concept introduced, reflective activation traded for a cached compiled factory. ActivatorUtilities.CreateInstance is the usual way to build a type whose constructor mixes DI-resolved services with caller-supplied arguments, and it is what this class used to call. Its cost is per call: it matches the constructor by reflection every time and caches nothing, so a request touching four aggregates paid four reflective activations. ActivatorUtilities.CreateFactory does the matching once and returns an ObjectFactory, a compiled delegate that can be invoked repeatedly. The insight that makes caching safe is stated in the code (RepositoryFactory.cs:74-79): each closed repository type here always takes the same argument shape, so the delegate can be keyed by type alone with no risk of a shape mismatch. [Rubric §12, Performance & Scalability] assesses whether hot paths avoid repeated per-call work; repository creation is on every command and query, so a per-type one-time cost replaces a per-call one. [Rubric §15, Best Practices & Code Quality] assesses whether an optimization is justified in place rather than left as folklore; the doc comment names the exact scenario it fixes.
  • Concept reinforced, decoration decided by configuration. The decorator pattern itself is taught in the CQRS pipeline chapter. Here it appears in its simplest form and with a switch: ApplicationSettings.UseMiniProfiler (MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ApplicationSettings.cs:14) decides whether the plain repository is returned or is passed as the inner instance of a timing decorator. When the flag is off there is no wrapper object and no indirection at all, so profiling costs nothing in a production host that leaves it off. [Rubric §13, Observability & Operability] assesses whether the system can be inspected without being rebuilt; per-repository timing is a configuration flip.
  • Walkthrough
    • Primary constructor (RepositoryFactory.cs:15-18): captures the provider into _serviceProvider and, importantly, resolves IOptions<ApplicationSettings>.Value once into _applicationSettings rather than on every call. Nothing else is resolved at construction, so the class stays cheap to create per scope (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:129 registers it scoped).
    • Create<TEntity, TIdentifierType>(DbContext dbContext) (:26-42): builds EFRepository<TEntity, TIdentifierType> through Factory(..., DbContextArg)(_serviceProvider, [dbContext]) (:31-32). Only the context is passed positionally; the repository's two remaining constructor parameters are optional, TimeProvider? and ICurrentUserService? (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:24-28), and come from the provider, with the class doc recording the fallback when they are absent: the system clock and no user stamp (EFRepository.cs:18-23). When UseMiniProfiler is true (:34) the instance is re-wrapped by activating EFRepositoryDecorator<TEntity, TIdentifierType> with an argument shape of [typeof(IRepository<TEntity, TIdentifierType>)] (:36-38), matching the decorator's single inner parameter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepositoryDecorator.cs:14).
    • CreateReadOnly<TEntity, TIdentifierType>(DbContext dbContext) (:50-66): the identical sequence against EFReadRepository and EFReadRepositoryDecorator, with the looser AuditableBaseEntity<TIdentifierType> constraint.
    • DbContextArg (:68): a single static Type[] holding typeof(DbContext), shared by both creation paths so the common case allocates no argument-type array per call.
    • FactoryCache (:70): a static ConcurrentDictionary<Type, ObjectFactory>, so the compiled delegates are shared process-wide across scopes and requests, not per factory instance.
    • Factory(Type implementationType, Type[] argumentTypes) (:81-85): FactoryCache.GetOrAdd(implementationType, static (type, args) => ActivatorUtilities.CreateFactory(type, args), argumentTypes). Two details are deliberate: the value factory is static, so it captures nothing, and the argument types travel through GetOrAdd's state parameter instead of a closure, which is what keeps the lookup allocation-free on the hit path.
  • Why it's built this way: UnitOfWork must create a repository over a specific context instance chosen by data source (UnitOfWork.cs:55-57), which no container registration can express, so something has to do the activation by hand. Once that step exists, it is also the natural place to fold in the optional profiling decorator, and the natural place to pay the reflection cost once rather than per call. The class is public while all four types it builds are internal, which is the point: consumers get the contract and the composition, never the concrete repository types.
  • Where it's used: injected into UnitOfWork (UnitOfWork.cs:13, :16) and called from GetRepository (:42) and GetReadRepository (:62). Because the unit of work caches one repository per entity type per scope (:38-44), the factory typically runs once per entity type per request, and the compiled delegate is reused for every later request in the process. All four composition outcomes are pinned directly against a real SQLite context: plain repository with the flag off, decorated with it on, and the same pair for the read side (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Repositories/RepositoryFactoryTests.cs:45-95), plus two tests asserting the built instances are functional repositories (:95-115). Those tests construct the factory directly with Options.Create(new ApplicationSettings { UseMiniProfiler = false }) (:98, :100), which is the shape the primary constructor takes.
  • Caveats / not-in-source: the cache is keyed by implementation type only, which is correct exactly as long as every call site for a given closed type passes the same argument shape. Both current call sites do, and the doc comment states that assumption (RepositoryFactory.cs:74-79), but nothing in the code enforces it: a future overload passing a different argumentTypes array for an already-cached type would silently reuse the first delegate.

UserNotificationConfiguration

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.Notifications · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/Notifications/UserNotificationConfiguration.cs:15 · Level 14 · class (internal sealed)

  • What it is: the EF Core mapping for UserNotification, the per-user inbox row that pairs a recipient with a broadcast (UserNotificationConfiguration.cs:15-47). It is the sibling of PushNotificationConfiguration and shares its shape exactly: internal sealed, [UseDatabase("Notification")], EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> base, and a ToTable that overrides the derived Common schema.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> (UserNotificationConfiguration.cs:16), including the inherited EffectiveEngine property (:45, declared at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:61); UserNotification (:3, :24); UseDatabaseAttribute (:14); the HasSoftDeleteFilter member from IndexBuilderExtensions (:45); SoftDeleteUniqueIndexConvention, which supplies the filter on its unique index (:38-41); EF Core's EntityTypeBuilder<TEntity> (NuGet, :1-2).
  • Concept reinforced: the schema-and-database override taught at PushNotificationConfiguration, with the identical doc comment stating why (UserNotificationConfiguration.cs:7-13). What is worth studying here instead is the index pair, which shows the two routes a soft-delete filter takes onto an index side by side: the convention for the unique one, the opt-in helper for the non-unique one, neither written as an engine-specific literal. [Rubric §12, Performance & Scalability] assesses whether hot read paths are backed by an index that matches the query's predicate; the unread lookup below is a textbook covering-filter case.
  • Walkthrough
    • Base call and schema override (UserNotificationConfiguration.cs:21, :24): same order and same reason as its sibling.
    • Scalars (:26-36): UserId and PushNotificationId required, IsRead required with HasDefaultValue(false) so an inserted row is unread at the database level too, and ReadOn mapped with no facets, staying nullable because the domain declares it DateTime? (MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/UserNotifications/UserNotification.cs:24).
    • Uniqueness per recipient (:38-41): HasIndex(p => new { p.UserId, p.PushNotificationId }).IsUnique(), one inbox row per user per notification among live rows, with no filter declared here. The comment states where the predicate comes from (:38-39): SoftDeleteUniqueIndexConvention sets the soft-delete filter on a unique index of a soft-deletable entity that has none (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:67-71), built for the context's own engine (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:393), so the SQL comes out in the syntax of whichever engine the model is built for.
    • The unread lookup (:43-45): HasIndex(p => new { p.UserId, p.IsRead }).HasSoftDeleteFilter(EffectiveEngine), non-unique, serving the per-user unread query behind the notification badge. This is the case the convention deliberately skips, since it continues past any index that is not unique (SoftDeleteUniqueIndexConvention.cs:63-64), so the explicit call is required to keep soft-deleted rows out of the index. Passing EffectiveEngine rather than relying on the helper's DataSource.SQLServer default (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:53) is what makes the filter [IsDeleted] = 0 on a SQL Server model and "IsDeleted" = false on a PostgreSQL-only host (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/NotificationConfigurationEngineTests.cs:67, :45).
    • No relationship to PushNotification (:29-30): PushNotificationId is configured as a plain required scalar, with no HasOne or WithMany anywhere in the file, and the domain entity exposes no navigation property either, only the identifier (UserNotification.cs:18). The inbox row references the broadcast by value.
  • Why it's built this way: modeling the reference as a bare scalar keeps the entity honest about what the database enforces, and it is the shape the framework can move without rework. Both notification tables carry [UseDatabase("Notification")] today, so they land in one database and a declared relationship would be legal; the moment a host routed them apart, CrossDataSourceDegradeConvention would strip the EF relationship and the FK constraint anyway and leave exactly this scalar behind (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/CrossDataSourceDegradeConvention.cs:10-22). That is the same rule ADC applies to every cross-module reference under database-per-service (ADR-006). Leaving both index filters to engine-aware code rather than a bracketed literal is what lets this SQL Server-declared configuration be applied to a PostgreSQL or SQLite model, where the bracketed form is a syntax error on Npgsql (NotificationConfigurationEngineTests.cs:14-17).
  • Where it's used: registered by the same assembly scan as its sibling (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:26-31) and reached in production only through ADC's Notification module (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:33), where the UserNotification table lands in the Notification schema of ADC_Notification (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/appsettings.Development.json:37-41). Its index filters are pinned by NotificationConfigurationEngineTests, which loads this configuration's assembly into a full context over a PostgreSQL-only and a SQL Server Notification source (NotificationConfigurationEngineTests.cs:42, :64) and asserts both indexes carry the engine's soft-delete predicate (:45, :67, exactly two indexes at :73-74).
  • Caveats / not-in-source: that test pins only the two index filters; the scalar mapping (required columns, the IsRead default) has no dedicated assertion in the Common suite and is exercised indirectly through the ADC Notification migrations and integration tier.

⬅ Validation • Index • Authentication & Authorization ➡