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, CosmosDbContext,
SqliteDbContext); two EF Core save interceptors that turn a plain
SaveChangesAsync into audit stamping plus transactional domain-event capture; a small repository
family behind an interface-segregated contract (IReadRepository<TEntity, TIdentifierType>,
IWriteRepository<TEntity, TIdentifierType>,
IRepository<TEntity, TIdentifierType>) coordinated by a
UnitOfWork; a data-source routing layer that lets every entity resolve to its own
physical database ("database per service"); two model-finalizing conventions that keep that routing
honest; an engine-portable entity-configuration hierarchy; and a supporting cast of value generators,
an encryption converter, seeders, and design-time factories. The group also hosts the framework's
non-EF storage-adjacent services: blob storage, image normalization, and native push registration and
delivery. 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:34)
is an abstract primary-constructor class over EF's DbContext. It holds the cross-cutting model
configuration that 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:184-198);
it configures the RowVersion optimistic-concurrency token, mapped as a SQL Server rowversion or as
a plain application-managed token on other providers (ApplicationDbContext.cs:211-231); and it maps
the outbox and inbox tables so every relational database carries its own
(OutboxMessage at ApplicationDbContext.cs:238-251,
InboxMessage at ApplicationDbContext.cs:258-267). It also
registers four keyless ValReturn<T> shapes (ApplicationDbContext.cs:51,
165-168) so raw SQL scalar queries have somewhere to land. Its SaveChangesAsync(userId, ...)
overload (ApplicationDbContext.cs:79-93) 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.
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:23-28). The same
SQLServerDbContext class
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:13)
is instantiated once per SQL Server database, each instance carrying a different
PhysicalDataSource (connection string, 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, physical source name, designTime) and is installed by the base
in OnConfiguring (ApplicationDbContext.cs:131). This is deliberately not a per-module context split:
one sealed context per engine over the abstract base is ADR-006's ruling. SQLServerDbContext adds the
provider-specific touches: transient-fault retry (EnableRetryOnFailure with 5 attempts and a 10-second
cap, SQLServerDbContext.cs:41-45) and a suppressed PendingModelChangesWarning
(SQLServerDbContext.cs:57) 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.
SaveChanges as an interceptor pipeline
The base context resolves two EF Core SaveChangesInterceptors from DI in OnConfiguring
(ApplicationDbContext.cs:124-126), and together they turn a bare save into the framework's audit-plus-
outbox flow. AuditSaveChangesInterceptor
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:13)
runs on SavingChanges: it walks every tracked
IAuditableEntity, stamps CreatedOn/By plus
LastModifiedOn/By on Added, and on Modified marks the two Created* properties unmodified before
re-stamping LastModified*, reading the timestamp from an injected TimeProvider and the user id from
CurrentSaveUserId (falling back to default as the system-operation sentinel,
AuditSaveChangesInterceptor.cs:38-65). 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. That is the [Rubric §10, Cross-Cutting Concerns] payoff, one enforcement
point instead of copy-paste in every handler.
DomainEventSaveChangesInterceptor
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:38)
is the producer end of the outbox, and it is the most subtle type in the group. On SavingChanges it
collects pending IDomainEvents from every tracked
IAggregateRoot and writes an
OutboxMessage row for each into the same context, so the
events land in the database in the same transaction as the aggregate changes
(DomainEventSaveChangesInterceptor.cs:143-195). 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
(DomainEventSaveChangesInterceptor.cs:160-191). The captured state is parked in a
CapturedState record (DomainEventSaveChangesInterceptor.cs:268) held in a
ConditionalWeakTable keyed by context (:48), so it is cleaned up automatically when the context is
disposed.
After the save, SavedChangesAsync does one of two things (DomainEventSaveChangesInterceptor.cs:201-218).
With no ambient transaction it flushes immediately: dispatch local events through
IDomainEventDispatcher, clear the aggregates' event
lists, mark the local outbox rows processed, and signal the outbox for integration events
(:224-252). With an active transaction it clears the events (so a second save inside the same
transaction cannot re-capture them) and parks a DeferredDispatch
(:275) in a second weak table; DbContextFactory then calls the static
FlushDeferredAsync only after a successful commit (:120-129) and DropDeferred on rollback (:137).
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. If in-process dispatch
throws, the interceptor logs a warning and signals the outbox to retry from the persisted rows rather
than losing the event (:238-246). The synchronous SavedChanges path cannot await a dispatcher at
all, so it clears events and leaves delivery entirely to the outbox (:100-113). Cosmos DB has no
relational outbox table, so the base exposes a SupportsOutbox flag (ApplicationDbContext.cs:62) that
CosmosDbContext overrides to false (CosmosDbContext.cs:69) and the interceptor
honors by dispatching everything in-process instead. 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.
Repositories 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:13) for a repository.
The repository contract is deliberately interface-segregated
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs): a handler
that only needs a lookup can depend on the narrow IEntityReader<TEntity, TIdentifierType>
(IRepository.cs:14) or IEntityQuerier<TEntity, TIdentifierType>
(IRepository.cs:64); IReadRepository<TEntity, TIdentifierType>
(IRepository.cs:110) combines both plus four IQueryable surfaces (tracking, no-tracking, single-query,
split-query), IWriteRepository<TEntity, TIdentifierType>
(IRepository.cs:133) adds mutation, and IRepository<TEntity, TIdentifierType>
(IRepository.cs:214) 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. The concrete
EFReadRepository<TEntity, TIdentifierType> and
EFRepository<TEntity, TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:15)
wrap an EF DbSet. The write side patches already-tracked entities in place through an O(1)
Local.FindEntry lookup instead of re-attaching (EFRepository.cs:44-62), seeds RowVersion original
values for optimistic concurrency on both the aggregate and any child implementing IRowVersioned
(EFRepository.cs:72-93, ADR-035), and offers a set-based ExecuteDeleteAsync escape hatch that the
interface itself documents as bypassing domain events, audit stamps, and soft-delete
(IRepository.cs:187-197).
Two factories keep the wiring honest. RepositoryFactory
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:13)
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 (RepositoryFactory.cs:30-37, 54-61), adding timing without the base
repository knowing. DbContextFactory
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:18)
is the scoped coordinator: it caches one ApplicationDbContext per
DataSourceKey so every repository in a scope shares one change tracker, and it enlists
a late-created context into an already-open transaction (DbContextFactory.cs:51-66). Because there can
be more than one physical source in play, ExecuteInTransactionAsync 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 (DbContextFactory.cs:296-357); cross-source consistency is the
outbox's job. A returned failed Result rolls back exactly
like an exception (DbContextFactory.cs:314-321), which is what makes ADR-013's Result-over-exceptions
rule safe for partial persistence, and rollback also drops the deferred event dispatch
(DbContextFactory.cs:265-276). DbContextFactory further carries the SET IDENTITY_INSERT machinery
(IdentityInsertGroup at DbContextFactory.cs:240, the save split at
131-191) for importing entities with explicit database-generated ids one table at a time, and the
MigrateAsync / HasPendingMigrationsAsync sweeps over every SQL Server source in use
(DbContextFactory.cs:361-377).
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:33-66). The physical creation
itself runs through PhysicalDbContextFactory
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:16),
a singleton that switches on the key's engine to construct the right context class
(PhysicalDbContextFactory.cs:34-45) and whose doc comment warns it must never be pooled, because
each instance carries per-source constructor state that pooling would smear across databases
(PhysicalDbContextFactory.cs:10-14). Three thin adapters
(DefaultSqlServerDbContextFactory,
DefaultSqliteDbContextFactory,
DefaultCosmosDbContextFactory, all in
.../Factory/DefaultEngineDbContextFactories.cs:13-37) preserve EF's IDbContextFactory<TContext> DI
surface for the Default source, and ApplicationDbContextEFFactory
(.../Factory/ApplicationDbContextEFFactory.cs:14) picks among them from the DefaultDataSource or
DataSource configuration key. The interfaces (IUnitOfWork,
IDbContextFactory, IPhysicalDbContextFactory,
IRepositoryFactory) keep the application layer talking to abstractions.
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/DataSourceKey.cs:15), a
(Engine, Name) record struct where the DataSource engine
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IDataSourceService.cs:6) is
one of Cosmos DB, SQLite, or SQL Server, 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:13),
a singleton, builds the logical-to-physical map once per engine from configuration: 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:94-135), and sources sharing a connection identity collapse onto one canonical
key named after their alphabetically-first member (DataSourceResolver.cs:172-210). It fails fast when
two logical names collapsing to one database declare conflicting migrations assemblies
(DataSourceResolver.cs:229-249) and logs a warning when a separate SQL Server source falls back to the
Default migrations assembly (DataSourceResolver.cs:185-192).
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 [UseDataSource] attribute on the configuration class and the logical name from
[UseDatabase], the entity's module namespace via NamespaceConventions.GetModuleName,
or Default (EntityDataSourceRegistry.cs:164-177). It caches an immutable Snapshot
of frozen collections (EntityDataSourceRegistry.cs:25), rescans once on a lookup miss when the assembly
set changed so late-loaded module assemblies are picked up (:91-106), and rejects an entity claimed by
two different sources (:134-145). 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
(:155-170). DataSourceService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/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 not Cosmos (DataSourceService.cs:31-38).
Two model-finalizing conventions
The base context adds both of its conventions in ConfigureConventions (ApplicationDbContext.cs:137-152),
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:33,
added at ApplicationDbContext.cs:146) 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:38-89, 107-138). 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), and it skips the
compensating index on Cosmos, which auto-indexes everything and rejects explicit index definitions
(:65, :96-105). 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:24, added at ApplicationDbContext.cs:151) 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 appends an IsDeleted = 0 filter to every unique index on a soft-deletable entity,
in provider-correct syntax (bracketed for SQL Server, quoted for SQLite), leaves hand-authored filters
untouched, and no-ops for Cosmos (SoftDeleteUniqueIndexConvention.cs:33-56). 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.
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:28)
or, more commonly, one of the fixed-engine shims like
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>
(.../EntityTypeConfigurationSQLServer.cs:17), which is that base annotated
[UseDataSource(DataSource.SQLServer)]. The base reads the attribute off its own runtime type and throws
a clear error when it is missing (EntityTypeConfiguration.cs:43-47), then applies the engine's
conventions in ApplyEngineConventions (:57-99): SQL Server gets a table in a module schema, SQLite a
plain table, Cosmos a per-module container with the entity id as partition key, and each maps key
generation according to the entity's
EntityTypeExtensions.IsIdValueGenerated
marker (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 three provider marker interfaces
(IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,
IEntityTypeConfigurationSqlite<TEntity, TIdentifierType>,
IEntityTypeConfigurationCosmos<TEntity, TIdentifierType>,
all over the common IEntityTypeConfigurationBase<TEntity, TIdentifierType>),
moving an entity between engines is a one-line attribute change with no configuration-body edits. The
shared EntityTypeConfigurationBase<TEntity, TIdentifierType>
(.../EntityTypeConfigurationBase.cs:19) handles the one universal concern: excluding the in-memory
DomainEvents collection from mapping (:29-32). Discovery runs through
ModelBuilderExtensions.ApplyAllConfigurations
(.../DbContexts/ModelBuilderExtensions.cs:10), which the base calls with an entity filter so each
database's model receives only its own entities (ApplicationDbContext.cs:276-303), over the assemblies
supplied by IEntityConfigurationAssemblyProvider and its
DefaultEntityConfigurationAssemblyProvider implementation
(configured through EntityConfigurationOptions). Two configurations ship
inside the framework itself, PushNotificationConfiguration and
UserNotificationConfiguration
(.../Configuration/EntityTypeConfiguration/Notifications/*.cs:15), both tagged [UseDatabase("Notification")]
and re-declaring the Notification schema because namespace derivation would otherwise resolve them to
Common. This engine-portability design is ADR-018 (polyglot persistence); note the current-reality
caveat: the SQLite and Cosmos plumbing is shipped and tested, but SQL Server is the only engine backing
production entities today.
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:28)
is a value converter that transparently encrypts a string column with authenticated AES-256-GCM (a random
12-byte nonce, a 16-byte tag, stored Base64 as nonce plus ciphertext plus tag), rejecting any key that is
not exactly 32 bytes (:30-52, :60-80). 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:45). That is what
makes the specification evaluation in Group 03 unit-testable
without a database, a small but real [Rubric §14, Testability] win.
ProfilingHelper (.../Persistence/ProfilingHelper.cs:9) is the MiniProfiler
step wrapper the repository decorators share.
Seeding and design time close the loop. IDbSeeder 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 (DbSeeder.cs:22-40). For migrations,
DesignTimeDbContextHelper
(.../DbContexts/Design/DesignTimeDbContextHelper.cs:34) builds a
SQLServerDbContext for dotnet ef without the app's DI container: a downstream
migrations project writes a few-line IDesignTimeDbContextFactory, and
dotnet ef migrations add X -- --datasource Conference selects which physical source to build against
(:86-104), so each database gets its own migrations project. It composes minimal stand-ins
(ExplicitAssemblyProvider at :106,
NullDomainEventDispatcher at :111) and a
DesignTimeDbContextOptions carrying the connection settings, then wires
the same DataSourceResolver and EntityDataSourceRegistry
the runtime uses so the design-time model matches the runtime one (:43-81).
Blobs, images, and native push
The group also carries the storage-adjacent infrastructure services that are not EF at all, each behind
an Application-layer interface with a null default so a host that has not configured the backend still
starts and degrades cleanly. IFileStorageService
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IFileStorageService.cs:11)
stores and deletes blobs behind a Result-returning API and
exposes an IsConfigured flag handlers can gate features on;
AzureBlobFileStorageService
(.../Services/AzureBlobFileStorageService.cs:15) is the Azure implementation over a single
pre-provisioned container, and NullFileStorageService
(.../Services/NullFileStorageService.cs:11) fails uploads with a named error while letting deletes
succeed. IImageProcessor and ImageSharpImageProcessor
(.../Services/ImageSharpImageProcessor.cs:14) normalize untrusted uploads by decoding, baking in the
EXIF orientation, center-cropping to a square, stripping all metadata, and re-encoding as JPEG, so only
pixels survive; the dependency-free ImageContentSniffer
(.../Interfaces/Infrastructure/ImageContentSniffer.cs:10) is its upload-side companion, deciding the
accepted formats from magic bytes rather than the client-declared content type. 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). On the push side, INativePushSender and
IPushDeviceRegistrar describe OS-level delivery and the device-installation
registry that backs it (ADR-044), implemented by
AzureNotificationHubNativePushSender
(.../Services/AzureNotificationHubNativePushSender.cs:14) and
AzureNotificationHubDeviceRegistrar
(.../Services/AzureNotificationHubDeviceRegistrar.cs:15) over Azure Notification Hubs, with
NullNativePushSender and NullPushDeviceRegistrar
as the unconfigured defaults. NativePushPayloads
(.../Services/NativePushPayloads.cs:10) is the pure helper that builds the FCM v1 and APNs payload
shapes and chunks user tags at the hub's 20-tag expression cap (NativePushPayloads.cs:13), which is
what makes those rules unit-testable without a hub. This channel sits beside the persisted notification
record and the SignalR path in Group 10.
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 run
through this group's repositories and IQueryable surfaces; the navigation populators in
Group 11 fill the cross-source gaps the degrade convention opens;
and the entity-source registry answers the .Include() questions those populators ask. The design axes
here are two orthogonal ones, ADR-006's Name axis (which database) and ADR-018's Engine axis (which
storage technology), collapsed behind a single DataSourceKey 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.
DataSource
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IDataSourceService.cs:6· Level 0 · enum
- What it is: a three-value enum (
CosmosDB,Sqlite,SQLServer) naming which database engine persists a given entity type. It shares a file withIDataSourceService(the enum at line 6, the interface at line 24). - Depends on: nothing first-party (BCL only).
- Concept introduced, database-per-service routing at the entity level.
[Rubric §8, Data Architecture](assesses deliberate database-per-service design, key/routing strategy, and the absence of implicit cross-database JOINs) and[Rubric §7, Microservices Readiness](each module owns its own store so it can be lifted out). ADR-006 establishes that every module owns its own database;DataSourceis the engine axis the query pipeline reads to decide whether two entity navigations can be resolved with a single relational.Include()/JOIN or must fall back to cross-source batch loading. The values encode a capability, not just a name: theCosmosDBdoc comment (IDataSourceService.cs:8) states "no cross-container JOINs," whileSqlite(IDataSourceService.cs:11) andSQLServer(IDataSourceService.cs:14) support JOINs. - Walkthrough: three members, each documented with its JOIN capability,
CosmosDB(IDataSourceService.cs:9, document store, no cross-container JOINs),Sqlite(IDataSourceService.cs:12, JOINs within a single file),SQLServer(IDataSourceService.cs:15, full relational JOIN support). - Why it's built this way: encoding the JOIN-capability difference in the enum lets
IDataSourceService.HaveIncludeSupportanswer the include-vs-batch-load question from a table lookup rather than scatteredifchains, and keeps that decision in the framework-pure Application layer (no EF reference). - Where it's used: paired with a database name in
DataSourceKey; resolved and compared byIDataSourceService; consumed byNavigationMetadataProviderto classify each navigation as an EF include or a manual populate.
IEntityConfigurationAssemblyProvider
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IEntityConfigurationAssemblyProvider.cs:10· Level 0 · interface
- What it is: a single-method contract returning the
Assemblylist that holds EF Core entity type configurations, so the DbContext can discover and apply configurations without hardcoding module assembly-name patterns. - Depends on:
System.Reflection(BCL) only. - Concept introduced, module-agnostic model assembly, keeping EF out of Application.
[Rubric §3, Clean Architecture](the Application layer declares what assemblies carry configurations; the Infrastructure DbContext performs the EF scan) and[Rubric §7, Microservices Readiness](each module's Infrastructure assembly holds its own configurations, so extraction is removal from a list, not a rewrite). In a modular monolith each module ships its ownIEntityTypeConfiguration<T>classes; the DbContext'sOnModelCreatingcallsApplyConfigurationsFromAssemblyfor each assembly this provider returns rather than from a fixed list. The doc comment (IEntityConfigurationAssemblyProvider.cs:5-9) states exactly this. - Walkthrough: one method,
IReadOnlyList<Assembly> GetConfigurationAssemblies()(IEntityConfigurationAssemblyProvider.cs:15). Implementations aggregate the Infrastructure assemblies of every enabled module. - Why it's built this way: routing configuration discovery through an injected provider means the
active module set (which
ModulesSettingscan toggle) determines the model without the DbContext knowing any module by name, satisfying the extraction invariant of ADR-006/ADR-007. - Where it's used: consumed by
ApplicationDbContext(Infrastructure, this group) during model creation; registered in each app's composition root.
ImageContentSniffer
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ImageContentSniffer.cs:10· Level 0 · class (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>). It is the upload-side companion toIImageProcessor. - Concept introduced, magic-byte content sniffing as an upload trust boundary.
[Rubric §11, Security](assesses validating untrusted input by its actual content, not a spoofable client-supplied MIME type or extension) and[Rubric §26, Front-End Security]where an avatar upload originates. The doc comment (ImageContentSniffer.cs:3-8) frames the division of labor (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; app-specific size limits and error codes stay in the calling handler. - Walkthrough: four span-based predicates.
IsAllowedImage(ReadOnlySpan<byte>)(ImageContentSniffer.cs:15): the public entry point,IsJpeg || IsPng || IsWebP.IsJpeg(ImageContentSniffer.cs:21): length >= 3 and the SOI prefixFF D8 FF.IsPng(ImageContentSniffer.cs:27): length >= 8 and the exact 8-byte PNG signature.IsWebP(ImageContentSniffer.cs:33): length >= 12, aRIFFcontainer (bytes 0-3,"RIFF"u8) declaring theWEBPform type (bytes 8-11,"WEBP"u8).
- Why it's built this way:
ReadOnlySpan<byte>and UTF-8 literals ("RIFF"u8) mean the checks allocate nothing and run on the raw payload prefix; being a pure static class it can be called from any layer without DI. Checking bytes (not the declared type) is the security point, a client can renameevil.exetoavatar.pngbut cannot forge the leading signature and survive re-encoding. - Where it's used: called by the avatar-upload handler before handing content to
IImageProcessor(ADR-045).
INativePushSender
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/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
UserIdentifierTypealias (anint); BCL otherwise. - Concept introduced, native push as the third delivery channel.
[Rubric §10, Cross-Cutting Concerns](a swappable delivery abstraction, defined in Application, implemented at the edge). The doc comment (INativePushSender.cs:3-9) places this beside the two other channels (ADR-044): the persisted inbox record and the SignalR real-time push handled byIPushNotificationSender. Where SignalR reaches a connected browser, native push reaches a device that is not running the app. Infrastructure targets Azure Notification Hubs (FCM v1 + APNs); the default is a no-op until a hub is configured, so a host that never sets one up degrades cleanly. - Walkthrough: two methods.
SendToUsersAsync(IEnumerable<UserIdentifierType> userIds, string title, string body, Dictionary<string,string>? metadata = null, CancellationToken)(INativePushSender.cs:19): targets specific users, resolved to installations via user tags;metadatacarries an optional deep-link route in the platform payload.BroadcastAsync(string title, string body, Dictionary<string,string>? metadata = null, CancellationToken)(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/token bookkeeping (
IPushDeviceRegistrarowns the registry); the no-op default means native push is an opt-in capability, not a hard dependency. - Where it's used: called by notification command handlers alongside
IPushNotificationSender; its device registry is maintained byIPushDeviceRegistrar.
IQueryableExecutor
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IQueryableExecutor.cs:7· Level 0 · interface
- What it is: an abstraction over the EF Core
IQueryableoperations (Include,AsSplitQuery,ToListAsync,CountAsync) that the Application layer needs but that would otherwise require a directMicrosoft.EntityFrameworkCorereference. - Depends on:
System.Linq(BCL) only. - Concept introduced, inverting EF's terminal operators out of Application.
[Rubric §3, Clean Architecture](Application depends on an abstraction; the EF-specific implementation lives in Infrastructure). EF's async materializers (ToListAsync,CountAsync) andIncludeare extension methods in the EF assembly; 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 the intent. - Walkthrough: four methods.
Include<T>(IQueryable<T>, string navigationPropertyPath)(IQueryableExecutor.cs:14): a string-based include path (e.g."Order.OrderLines"), deliberately not a lambda, because the generic query pipeline builds include paths dynamically from navigation-property name strings.AsSplitQuery<T>(IQueryable<T>)(IQueryableExecutor.cs:26): switches EF to split-query mode. The doc comment (IQueryableExecutor.cs:17-25) explains why it matters: paginating (Skip/Take) a query with collection includes in single-query mode truncates or mis-correlates child rows, so list reads come back with empty collections. A no-op for in-memory queryables.ToListAsync<T>(IQueryable<T>, CancellationToken)(IQueryableExecutor.cs:34) andCountAsync<T>(IQueryable<T>, CancellationToken)(IQueryableExecutor.cs:41): the async materializers.
- Why it's built this way: the string include path (over
Expression<Func<T,TProperty>>) is required because the query pipeline composes includes from runtime navigation metadata, not compile-time lambdas; theAsSplitQueryescape hatch keeps a well-known EF pagination bug from reaching list endpoints. - Where it's used: called by the generic entity query service and the navigation populators of this group through the injected interface.
DataSourceKey
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/DataSourceKey.cs:15· Level 1 · record struct (readonly)
- What it is: the identity of a physical data source, a
([DataSource](#datasource) Engine, string Name)pair, whereNamedistinguishes multiple databases on the same engine ("database per microservice"). - Depends on:
DataSource(Level 0). - Concept, physical-key comparison for include support.
[Rubric §8, Data Architecture](database-per-service routing). Areadonly record structgives correct structural equality with zero boilerplate, which is the whole point: Application code that needs to know whether two entities can be joined compares theirDataSourceKeyvalues. The doc comment (DataSourceKey.cs:6-11) stresses thatNameis the physical source name produced by the Infrastructure resolver after collapsing logical names that share a connection string, so two logical names mapping to the same connection string end up with the same physical key (and are joinable), while distinct databases do not. - Walkthrough
- The positional record
DataSourceKey(DataSource Engine, string Name)(DataSourceKey.cs:15). DefaultName(DataSourceKey.cs:18): theconst string = "Default"reserved for the top-levelConnectionStringssection.Default(DataSource engine)(DataSourceKey.cs:23): a factory building the default key for an engine.ToString()(DataSourceKey.cs:26): renders"{Engine}/{Name}"for diagnostics.
- The positional record
- Why it's built this way: making the key a value type with structural equality means the routing
decision (same physical database and relational engine) is a simple
==comparison, and a host with noDataSourcesconfiguration collapses everything ontoDefaultand behaves like a single-database monolith (ADR-006). - Where it's used: the entity data-source registry maps each entity type to a
DataSourceKey;IDataSourceServiceresolves and compares them; the EF context factory (this group) caches one context per key.
IDataSourceService
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IDataSourceService.cs:24· Level 2 · interface
- What it is: resolves which physical data source (
DataSourceKey: engine + 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) andDataSourceKey(Level 1), both in the same namespace. - Concept introduced, multi-database service routing at the Application layer.
[Rubric §8, Data Architecture](assesses database-per-service design, deliberate routing, and no 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 (never Cosmos). This interface answers that question without referencing EF, keeping Application pure (ADR-006). The doc comment (IDataSourceService.cs:18-23) names the consumer:NavigationMetadataProvideruses it to classify navigation properties as supported or unsupported includes. - Walkthrough: eight members across four concerns.
GetDataSourceKey(Type)(IDataSourceService.cs:29) andGetDataSourceKey(string entityFullName)(IDataSourceService.cs:34): resolve the physical key by CLR type or full type name.GetDataSource(string)(IDataSourceService.cs:39) andGetDataSource(Type)(IDataSourceService.cs:44): resolve just the engine.HaveIncludeSupport(DataSourceKey first, DataSourceKey second)(IDataSourceService.cs:54): the crux, returnstrueonly when both keys identify the same physical database and the engine is relational (the doc comment at lines 46-53 notes Cosmos DB has no cross-document JOINs).HaveIncludeSupport(string firstEntityFullName, string secondEntityFullName)(IDataSourceService.cs:63): 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; to build a query the Application layer must know the routing topology so it can classify each navigation as "include-able" versus "manual load required."
- Where it's used: consumed by
NavigationMetadataProviderto drive the supported/unsupported include split, and indirectly by the query pipeline's eager-loading decisions. The Infrastructure implementation is a facade over the eager entity registry.
IFileStorageService
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IFileStorageService.cs:11· Level 3 · interface
- What it is: the contract for storing and deleting binary blobs (e.g. user avatar images). Implementations own the container/bucket; callers pass only a blob name scoped within it.
- Depends on:
Resultand its generic form (viaMMCA.Common.Shared.Abstractions,IFileStorageService.cs:1); BCLStream/Uri. - Concept introduced, the managed blob-storage boundary.
[Rubric §8, Data Architecture](binary content lives in object storage, not the relational row) and[Rubric §10, Cross-Cutting Concerns](a swappable storage transport behind a Result-returning interface). Per the doc comment (IFileStorageService.cs:5-10, ADR-045) the default implementation is unconfigured, uploads fail with a clear error, until a host callsAddAzureBlobFileStorage(configuration)with a completeFileStoragesection. ReturningResultrather than throwing keeps a failed upload on the same error-flow rails as the rest of the stack (see the Result pattern). - Walkthrough: one property and two 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)(IFileStorageService.cs:22): uploads or overwrites a blob and returns its public absolute URL asResult<Uri>.DeleteAsync(string blobName, CancellationToken)(IFileStorageService.cs:28): deletes a blob; unknown names succeed (idempotent), matching at-least-once cleanup semantics.
- Why it's built this way: an unconfigured default plus an
IsConfiguredgate means the framework ships avatar support without forcing every consumer to provision blob storage; idempotent delete makes cleanup safe to retry. - Where it's used: the avatar-upload handler, after
ImageContentSnifferandIImageProcessorhave validated and normalized the bytes (ADR-045).
IImageProcessor
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IImageProcessor.cs:11· Level 3 · interface
- What it is: the contract for normalizing an untrusted uploaded image, decoding it, correcting EXIF orientation, center-cropping to a square, stripping all metadata, and re-encoding as JPEG.
- Depends on:
Result(viaMMCA.Common.Shared.Abstractions,IImageProcessor.cs:1); BCLStream. - Concept introduced, re-encoding as an image trust boundary.
[Rubric §11, Security](assesses neutralizing untrusted binary input) and[Rubric §30, Compliance, Privacy & Data Governance](stripping EXIF GPS coordinates, which are PII). The doc comment (IImageProcessor.cs:5-10, ADR-045) makes both points: metadata removal deletes location PII, and re-encoding is the defense against polyglot or malformed payloads because only pixels survive the decode/re-encode round trip. This is the processor half of the pair thatImageContentSnifferopens. - Walkthrough: one method,
NormalizeToSquareJpegAsync(Stream content, int size, CancellationToken)(IImageProcessor.cs:18), returningResult<byte[]>of the normalized JPEG, or a validation failure for undecodable content.sizeis the output square edge length in pixels. - Why it's built this way: returning bytes (not a stream to storage) keeps the processor a pure
transform, so the handler can sniff, then normalize, then hand the result to
IFileStorageService; a Result failure on undecodable input keeps a bad upload from ever reaching storage. - Where it's used: the avatar-upload handler, between
ImageContentSnifferandIFileStorageService(ADR-045).
IPushDeviceRegistrar
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/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(viaMMCA.Common.Shared.Abstractions,IPushDeviceRegistrar.cs:1),DeviceInstallationRequest(fromMMCA.Common.Shared.Notifications.PushNotifications,IPushDeviceRegistrar.cs:2), and theUserIdentifierTypealias. - Concept, the token-registry side of native push.
[Rubric §10, Cross-Cutting Concerns]and[Rubric §11, Security](installations are bound to an authenticated owner, so a send targets a person, not an anonymous token). The doc comment (IPushDeviceRegistrar.cs:6-10, ADR-044) explains the split: this type owns the installation registry, tagged by user, soINativePushSendercan send to users; the default implementation is a no-op until a notification hub is configured. - Walkthrough: two methods.
UpsertAsync(UserIdentifierType userId, DeviceInstallationRequest request, CancellationToken)(IPushDeviceRegistrar.cs:18): creates or refreshes an installation, tagging it with the authenticated owner; returnsResult.DeleteAsync(string installationId, CancellationToken)(IPushDeviceRegistrar.cs:24): removes an installation; unknown ids succeed (idempotent).
- 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; idempotent delete makes stale-token cleanup safe to retry. - Where it's used: paired with
INativePushSender; the concrete implementation targets Azure Notification Hubs (ADR-044).
IEntityQuerier<TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:64· Level 4 · interface
- What it is: the collection/projection half of the repository split,
GetAllAsync,GetProjectedAsync<TResult>,GetAllForLookupAsync, and twoCountAsyncoverloads. - Depends on:
AuditableBaseEntity<TIdentifierType>(theTEntityconstraint,IRepository.cs:65) andBaseLookup<TIdentifierType>(the lookup projection,IRepository.cs:87). - Concept introduced, the ISP-split repository family.
[Rubric §1, SOLID](Interface Segregation).IRepository.csdefines a deliberate ladder of ever-wider interfaces so a handler depends on exactly the surface it uses:IEntityQuerier(collections/projection, this type),IEntityReader(by-id lookups),IWriteRepository(mutations),IReadRepository(reader + querier + rawIQueryable), andIRepository(read + write). A query handler that only lists entities can declareIEntityQuerier<T,Id>instead of the whole repository, a narrower, more testable dependency.[Rubric §12, Performance & Scalability]:GetProjectedAsync<TResult>(IRepository.cs:80) accepts anExpression<Func<TEntity,TResult>>translated to SQL, so read-heavy handlers fetch only the columns they need. - Walkthrough
GetAllAsync(IEnumerable<string> includes, where?, orderBy?, select?, asTracking, ignoreQueryFilters, CancellationToken)(IRepository.cs:69): the general collection read with optional includes, filter, ordering, and projection;ignoreQueryFilterscan bypass the soft-delete filter.GetProjectedAsync<TResult>(select, where?, asTracking, CancellationToken)(IRepository.cs:80): SQL-side projection to an arbitrary result type.GetAllForLookupAsync(string nameProperty, where?, asTracking, CancellationToken)(IRepository.cs:87): returns lightweightBaseLookup<TIdentifierType>id/name pairs for dropdowns without materializing full entities.CountAsync(CancellationToken)(IRepository.cs:94) andCountAsync(where, CancellationToken)(IRepository.cs:97): total and predicated counts.
- Why it's built this way: splitting reads into a focused querier lets the framework signal intent through the constructor dependency and keeps projection/counting off the by-id interface.
- Where it's used: query handlers needing collections or counts; folded into
IReadRepository<TEntity, TIdentifierType>for handlers that want the full read surface.
IEntityReader<TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:14· Level 4 · interface
- What it is: the by-id half of the repository split,
GetByIdAsync(two overloads),GetByIdsAsync, andExistsAsync(two overloads), for handlers whose data access is minimal. - Depends on:
AuditableBaseEntity<TIdentifierType>(theTEntityconstraint,IRepository.cs:15). - Concept, minimal data access as a declared dependency.
[Rubric §1, SOLID](Interface Segregation, introduced onIEntityQuerier) and[Rubric §8, Data Architecture](deliberate, minimal access patterns). The doc comment (IRepository.cs:7-11) is explicit: "Prefer this overIReadRepository<>when a handler only needsGetByIdAsyncorExistsAsync, this signals minimal data access." TheignoreQueryFiltersparameter onExistsAsync(IRepository.cs:47) lets a handler check whether a soft-deleted entity exists, e.g. for conflict detection on re-creation. - Walkthrough
GetByIdAsync(id, CancellationToken)(IRepository.cs:19): plain fetch, returnsnullif missing.GetByIdAsync(id, IEnumerable<string> includes, bool asTracking, CancellationToken)(IRepository.cs:24): the eager-load overload; include paths are navigation-property names.GetByIdsAsync(ids, includes?, asTracking, ignoreQueryFilters, CancellationToken)(IRepository.cs:37): a single-query bulk fetch; may return fewer than requested when some ids are missing or filtered.ExistsAsync(id, ignoreQueryFilters, CancellationToken)(IRepository.cs:45) andExistsAsync(Expression<Func<TEntity,bool>> where, ignoreQueryFilters, CancellationToken)(IRepository.cs:51): existence checks by key or predicate.
- Why it's built this way: a handler that only needs a point lookup takes the narrowest interface, which reads clearly and mocks trivially in tests.
- Where it's used: command handlers that load an aggregate before mutating it; folded into
IReadRepository<TEntity, TIdentifierType>. AGetByIdOrFailAsyncextension (this group) wraps the null-returningGetByIdAsyncin aResult<TEntity>.
IWriteRepository<TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:133· Level 4 · interface
- What it is: the write half of the repository abstraction,
AddAsync,AddRangeAsync,UpdateAsync,UpdateRange,SetOriginalRowVersion,ExecuteDeleteAsync,Save, andSaveChangesAsync. - Depends on:
AuditableBaseEntity<TIdentifierType>(theTEntityconstraint,IRepository.cs:134). - Concept introduced, optimistic-concurrency wiring and change-tracking-bypass delete.
[Rubric §8, Data Architecture](deliberate concurrency control, not accidental last-write-wins). Two members carry the weight:SetOriginalRowVersion(TEntity entity, byte[]? rowVersion)(IRepository.cs:173): plants the client's last-observedRowVersionas the tracked entity's original concurrency token, so the next save emitsWHERE RowVersion = @originaland raisesDbUpdateConcurrencyException(mapped to409 Conflict) if the row changed since the client read it. The doc comment (IRepository.cs:165-172) notes it is a no-op whenrowVersionis null or empty (legacy clients / first write).ExecuteDeleteAsync(where, CancellationToken)(IRepository.cs:183): a set-based delete run directly in the database. The doc comment (IRepository.cs:175-182) warns in capitals that it does not trigger domain events, audit stamps, or soft-delete; it is for maintenance scenarios only.
- Walkthrough
AddAsync/AddRangeAsync(IRepository.cs:141,:149): single and batch inserts.UpdateAsync/UpdateRange(IRepository.cs:157,:163): mark tracked entities modified.Save()(IRepository.cs:189) andSaveChangesAsync(CancellationToken)(IRepository.cs:194): the synchronous and async persist, each returning the number of state entries written; the doc comment prefers the async form.
- Why it's built this way: keeping writes in a focused interface means a query handler cannot accidentally acquire mutation methods, and the concurrency/bulk-delete escape hatches are declared where they are visible rather than buried in a concrete class.
- Where it's used: command handlers that mutate entities; folded into
IRepository<TEntity, TIdentifierType>. Handed out byIUnitOfWork.GetRepository.
IReadRepository<TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:110· Level 5 · interface
- What it is: the full read surface, combining
IEntityReader(by-id) andIEntityQuerier(collections), plus fourIQueryable<TEntity>properties for handlers that need raw LINQ. - Depends on:
IEntityReader<TEntity, TIdentifierType>andIEntityQuerier<TEntity, TIdentifierType>(both same file,IRepository.cs:111);AuditableBaseEntity<TIdentifierType>(constraint). - Concept, composing the focused reads and exposing controlled
IQueryable.[Rubric §1, SOLID](the composition point of the ISP ladder) and[Rubric §12, Performance & Scalability](the query properties expose EF's tracking and split-query modes explicitly). The doc comment (IRepository.cs:102-109) says existing code may keep using this interface while new handlers pick the focused sub-interfaces for tighter ISP. - Walkthrough: four
IQueryable<TEntity>properties for handlers that must drop to raw LINQ.Table(IRepository.cs:116): change-tracking enabled.TableNoTracking(IRepository.cs:119): no-tracking, the read-only default.TableNoTrackingSingleQuery(IRepository.cs:122): no-tracking forced to a single SQL query.TableNoTrackingSplitQuery(IRepository.cs:125): no-tracking in split-query mode, avoiding the cartesian explosion that collection includes cause (the same concernIQueryableExecutor.AsSplitQueryaddresses).
- Why it's built this way: naming the tracking and query-shape choices as distinct properties makes an expensive default (tracking, single-query with collection includes) an explicit opt-in rather than an accident.
- Where it's used: query handlers wanting the whole read surface; the concrete EF read repository
(this group) implements it; combined with
IWriteRepositoryintoIRepository.
IRepository<TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:202· Level 6 · interface
- What it is: the combined read-write repository, extending both
IReadRepositoryandIWriteRepository, so a command handler that reads and mutates takes a single dependency. - Depends on:
IReadRepository<TEntity, TIdentifierType>,IWriteRepository<TEntity, TIdentifierType>, andAuditableBaseEntity<TIdentifierType>(constraint). - Concept, the top of the ISP ladder.
[Rubric §1, SOLID]. The interface is purely compositional, it adds no members of its own, only the two constraintswhere TEntity : AuditableBaseEntity<TIdentifierType>andwhere TIdentifierType : notnull(IRepository.cs:203-204). Command handlers that both query and mutate takeIRepository; query handlers takeIReadRepository; handlers needing only point lookups takeIEntityReader. Each dependency is explicit and minimal. - Walkthrough: no members; the body is just the two interface bases and the two generic
constraints (
IRepository.cs:202-204). - Why it's built this way: keeping the combined interface empty means the read and write surfaces each stay independently usable, while a handler that genuinely needs both still gets one constructor parameter.
- Where it's used: resolved by
IUnitOfWork.GetRepository<TEntity, TId>(); the concrete EF repository (this group) implements it.
IUnitOfWork
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IUnitOfWork.cs:10· Level 7 · interface
- What it is: the single coordination point for a command that reads entities, mutates them, and saves atomically. It hands out typed repositories, persists changes with domain-event dispatch, and gives controlled access to transactions and identity-insert mode.
- Depends on:
IRepository<TEntity, TIdentifierType>andIReadRepository<TEntity, TIdentifierType>(returned by the factory methods);AuditableAggregateRootEntity<TIdentifierType>andAuditableBaseEntity<TIdentifierType>(constraint bounds); BCLIDisposable/IAsyncDisposable(IUnitOfWork.cs:10). - Concept introduced, the Unit of Work with post-commit domain-event dispatch.
[Rubric §8, Data Architecture](deliberate persistence: transactions, audit, consistency) and[Rubric §6, CQRS & Event-Driven](domain events dispatched from one place after a successful save). The Unit of Work is the boundary within which a command sees a consistent view of the database and within which all its changes commit together or not at all. Handlers never touchDbContext, they ask the UoW for a repository, mutate through it, then callSaveChangesAsync, which also serializes and dispatches the domain events raised by tracked aggregates (the save flow lives inApplicationDbContextin this group).[Rubric §14, Testability]: because everything routes through this interface, a test swaps the implementation for a mock and asserts handler behavior without a database. - Walkthrough
GetRepository<TEntity, TIdentifierType>()(IUnitOfWork.cs:19): constrained toAuditableAggregateRootEntity<TIdentifierType>, enforcing the DDD rule that only aggregate roots are directly persisted; returns a read-writeIRepository. Passing a child entity fails at compile time.GetReadRepository<TEntity, TIdentifierType>()(IUnitOfWork.cs:29): constrained toAuditableBaseEntity<TIdentifierType>, so it can read any entity (including children); returns a read-onlyIReadRepository.SaveChangesAsync(CancellationToken)(IUnitOfWork.cs:36) andSave()(IUnitOfWork.cs:40): the async persist (plus event dispatch) and its synchronous fallback; the doc comment prefers the async form.RequestIdentityInsert()(IUnitOfWork.cs:49): a one-shot flag that lets the next save include explicit values for database-generated identity columns (e.g. imported data), wrapping such inserts inSET IDENTITY_INSERT ON/OFF; the flag auto-clears after the save.BeginTransaction()/CommitTransaction()/RollbackTransaction()(IUnitOfWork.cs:52-58): manual transaction control for operations spanning multiple save points.ExecuteInTransactionAsync<TResult>(operation, CancellationToken)(IUnitOfWork.cs:70): wraps the operation in the active execution strategy so a retrying strategy (e.g.SqlServerRetryingExecutionStrategy) can retry the whole transaction as one unit; commits on success, rolls back on exception. The doc comment (IUnitOfWork.cs:60-66) states this is the safe way to transact, bareBeginTransactioncalls outside the strategy would break retry semantics.
- Why it's built this way: ADR-006 (database-per-service) means one host may touch several
physical databases; the lower-level context factory coordinates the multiple sources, and
IUnitOfWorksits above it as the per-command abstraction. Keeping it inMMCA.Common.Application(not Infrastructure) lets the Application layer depend on it without a reference to EF, satisfying[Rubric §3, Clean Architecture]. - Where it's used: injected into every write command handler; used by the transactional command decorator (the CQRS pipeline, taught in the primer).
EntityConfigurationOptions
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EntityConfigurationOptions.cs:10· Level 0 · class
- 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
Assemblyinto 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 byDefaultEntityConfigurationAssemblyProviderthroughIOptions<EntityConfigurationOptions>. - 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.Infrastructurenaming rule (for example a Common feature like Notification that lives insideCommon.Infrastructureitself, which the auto-scan deliberately excludes) still gets its configurations applied by adding its assembly here. - Walkthrough: one member,
List<Assembly> AdditionalAssemblies { get; } = [](EntityConfigurationOptions.cs:16). It is initialized to an empty list and appended to via the standardservices.Configure<EntityConfigurationOptions>(o => o.AdditionalAssemblies.Add(...))pattern during registration. - 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: consumed by
DefaultEntityConfigurationAssemblyProvider; populated by Infrastructure DI registration for Common-internal feature modules.
ModelBuilderExtensions
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ModelBuilderExtensions.cs:10· Level 0 · class
- What it is: an internal static helper with a single extension method,
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, andMicrosoft.Extensions.DependencyInjection.ActivatorUtilities(all BCL/EF/NuGet). It is called byApplicationDbContext; it does not itself referenceNamespaceConventionsorEntityConfigurationOptions. - 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'sApplyConfiguration<TEntity>): because the entity CLR type is only known at runtime, the method resolves EF's open genericModelBuilder.ApplyConfiguration<TEntity>(IEntityTypeConfiguration<TEntity>)once, then closes it per entity viaMakeGenericMethod. Configurations are created withActivatorUtilities.CreateInstance(ModelBuilderExtensions.cs:62), so a configuration class may constructor-inject services rather than needing a parameterless ctor. - Walkthrough:
- Guards all four required arguments with
ArgumentNullException.ThrowIfNull(ModelBuilderExtensions.cs:31-34). - Resolves the single-parameter
ApplyConfigurationoverload by reflection (ModelBuilderExtensions.cs:38-40). - Selects concrete, non-generic types in the assembly whose interface set contains a closed form of
interfaceType(the open generic likeIEntityTypeConfigurationSQLServer<,>) (ModelBuilderExtensions.cs:42-51). - For each, takes the first generic argument as the entity type, skips it when
entityFilterreturns false (ModelBuilderExtensions.cs:56-60), then instantiates viaActivatorUtilitiesand invokes the closedApplyConfiguration(ModelBuilderExtensions.cs:62-64).
- Guards all four required arguments with
- Why it's built this way:
internalkeeps this a framework detail; modules never call it. TheentityFilterparameter is the boundary that keeps each physical database's model to only its own entities (seeApplicationDbContext), and DI-based activation lets configurations depend on services without a parameterless-ctor constraint. - Where it's used: called from
ApplicationDbContext.ApplyConfigurationsForEntitiesInContext, which passes the engine's configuration interface and a filter that matches each entity's resolvedDataSourceKey.
NamespaceConventions
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/NamespaceConventions.cs:7· Level 0 · class
- What it is: one internal static method that derives a module name from an entity type's namespace
by returning the segment immediately preceding
Domain. It is the single shared rule so SQL schema names and logical database names can never drift apart. - Depends on: nothing first-party (BCL string/array only).
- Concept introduced, convention-over-configuration naming.
[Rubric §8, Data Architecture](assesses schema/database organization) and[Rubric §7, Microservices Readiness](assesses whether the model splits cleanly per module):MMCA.Store.Sales.Domain.Ordersyields"Sales", which becomes both the[Sales]SQL schema and theSaleslogical database name. 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. - Walkthrough:
GetModuleName(Type entityType)(NamespaceConventions.cs:16) splits the namespace on., finds the case-insensitive index of theDomainsegment (NamespaceConventions.cs:19-20), and returns the preceding segment when that index is>= 1, otherwisenull(NamespaceConventions.cs:21). The>= 1guard is what makes aDomain-first orDomain-less namespace fall through tonull. - 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. It is
internalbecause callers should consume the resolved name, not re-derive it. - Where it's used:
EntityDataSourceRegistryfalls back to it when no[UseDatabase]is present (EntityDataSourceRegistry.cs:173), and theEntityTypeConfigurationbase class uses it for the SQL table schema and the Cosmos container name (EntityTypeConfiguration.cs:67,88).
ProfilingHelper
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/ProfilingHelper.cs:9· Level 0 · class
- What it is: an internal static helper that wraps repository operations in a MiniProfiler timing step when profiling is active and is a zero-cost 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/instrumentation of persistence): every helper routes throughMiniProfiler.Current?.Step(...)(ProfilingHelper.cs:12). When MiniProfiler is not registered,MiniProfiler.Currentisnull, the?.short-circuits, and the returnedTiming?isnull, sousing 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): returns aTiming?namedMMCA.Common.Infrastructure.{className}: {methodName}.Profile(className, methodName, Func<int>)(ProfilingHelper.cs:14): opens a step and runs a synchronous delegate returningint.ProfileAsync(...)non-generic andProfileAsync<T>(...)(ProfilingHelper.cs:20,26): the async equivalents, each awaiting the delegate under the step withConfigureAwait(false).
- Why it's built this way:
internalhides 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.cs:23-53andEFReadRepositoryDecorator.cs:31-80);ApplicationDbContext.SaveChangesAsyncopens its own MiniProfiler step directly (ApplicationDbContext.cs:81).
ValReturn<T>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:51· Level 0 · class
- What it is: a keyless container class, nested in
ApplicationDbContext, used to materialize a scalar SQL result (abool,int,DateTime, orstring) from a raw query without a backing table. - Depends on:
Microsoft.EntityFrameworkCoreand its hostApplicationDbContext, which registers it as a keyless entity type. - Concept introduced, keyless entity types for raw scalar queries.
[Rubric §8, Data Architecture]: EF Core'sFromSql-style scalar materialization needs a CLR class to project into. Rather than one ad-hoc class per scalar shape,ValReturn<T>is a single generic holder with oneValueproperty that any raw query can select into asSELECT ... AS Value. - Walkthrough: one mutable property,
T Value { get; set; } = default!(ApplicationDbContext.cs:54).ApplicationDbContext.OnModelCreatingregisters four closed forms as keyless views withHasNoKey().ToView(null)(ApplicationDbContext.cs:130-133), so they map to no table and exist only to shape raw-query output. - Why it's built this way:
internal sealedkeeps it a persistence-layer detail; the generic parameter avoids a proliferation of single-property result classes.ToView(null)marks the type as query-only with no schema object behind it. - Where it's used: registered by
ApplicationDbContext; consumed by Infrastructure code that runs raw scalar SQL through EF. - Caveats / not-in-source: only the four closed forms registered in
OnModelCreatingare usable; a fifth scalar type would need its ownHasNoKey().ToView(null)registration.
DefaultEntityConfigurationAssemblyProvider
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DefaultEntityConfigurationAssemblyProvider.cs:12· Level 1 · class
- 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(excludingCommon.Infrastructureitself), plus any assemblies explicitly registered throughEntityConfigurationOptions. - Depends on:
IEntityConfigurationAssemblyProvider(the contract it implements),EntityConfigurationOptionsviaIOptions<>, andSystem.AppDomain/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): scanningAppDomain.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 whoseFullNamecontains.Infrastructureand does not containCommon.Infrastructure(both matchedOrdinalIgnoreCase,DefaultEntityConfigurationAssemblyProvider.cs:18-20), and the distinctoptions.Value.AdditionalAssemblies(DefaultEntityConfigurationAssemblyProvider.cs:21). TheCommon.Infrastructureexclusion is why Common-internal feature modules must opt in throughEntityConfigurationOptions. - 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: injected into
ApplicationDbContext(asIEntityConfigurationAssemblyProvider), which iterates its assemblies insideApplyConfigurationsForEntitiesInContext.
EFQueryableExecutor
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EFQueryableExecutor.cs:11· Level 1 · class
- What it is: the EF Core bridge for the Application layer's
IQueryableExecutor. It exposesInclude,AsSplitQuery,ToListAsync, andCountAsyncover anIQueryable<T>, guarding each so the same code path works against a real EF queryable and against a plain in-memoryIQueryable. - Depends on:
IQueryableExecutor(the Application-layer contract it implements) andMicrosoft.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 callsIQueryableExecutorrather 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): calls EF's string-basedIncludeon an EF queryable, otherwise returns the query unchanged (in-memory queries are already fully loaded).AsSplitQuery<T>(EFQueryableExecutor.cs:21): applies EF's split-query behavior only on EF queryables, otherwise a pass-through.ToListAsync<T>(EFQueryableExecutor.cs:28): uses EF's async materialization when available, otherwise the synchronous collection expression[.. query].CountAsync<T>(EFQueryableExecutor.cs:34): EF async count when available, otherwiseTask.FromResult(query.Count()).IsEfQuery<T>(EFQueryableExecutor.cs:43): the discriminator: an EF provider's queryable implementsIAsyncEnumerable<T>, a plain LINQ-to-Objects queryable does not, so a singleis IAsyncEnumerable<T>test routes each call.
- Why it's built this way:
internal sealed; centralizing the EF/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: resolved as
IQueryableExecutorby the query-side repositories and specification evaluation in Infrastructure.
ApplicationDbContext
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:34· Level 6 · class
- What it is: the single abstract
DbContextbase that every engine-specific context (SQLServerDbContext,CosmosDbContext,SqliteDbContext) inherits. One instance exists per physical database: the same class is instantiated multiple times, each carrying a differentPhysicalDataSourceand building a model that contains only that database's entities. - Depends on:
AuditSaveChangesInterceptor,DomainEventSaveChangesInterceptor,DataSourceModelCacheKeyFactory,CrossDataSourceDegradeConvention,IEntityDataSourceRegistry,IEntityConfigurationAssemblyProvider,PhysicalDataSource,IAuditableEntity,AuditableBaseEntity<TIdentifierType>,OutboxMessage,InboxMessage, 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'sDbContextis the unit of work, tracking everyAdded/Modified/Deletedentity 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 two interceptors registered inOnConfiguringrun before and afterbase.SaveChangesAsyncto stamp audit fields and serialize domain events into the outbox, so those cross-cutting concerns live in the interceptor pipeline rather than inline. - Walkthrough:
- Primary constructor (
ApplicationDbContext.cs:34-39): takesDbContextOptions, anIServiceProvider, anIEntityConfigurationAssemblyProvider, and thePhysicalDataSourcethis instance targets, delegating toDbContext(options). DataSourceKey(ApplicationDbContext.cs:42): exposes the(engine, database name)pair this context serves;PhysicalSource(ApplicationDbContext.cs:45) exposes the resolved connection info to subclasses.ValReturn<T>(ApplicationDbContext.cs:51): the nested keyless scalar holder (documented in its own section above).SupportsOutbox(ApplicationDbContext.cs:62):internal virtual,trueby default; the Cosmos subclass overrides tofalse. Read by the domain-event interceptor.CurrentSaveUserId(ApplicationDbContext.cs:69):internalaudit user id, set by the public save overload and read by the audit interceptor;nullmarks a system operation.SaveChangesAsync(userId, ct)(ApplicationDbContext.cs:79): the mutation entry point. Opens a MiniProfiler step, setsCurrentSaveUserId, then callsbase.SaveChangesAsync, which fires the interceptor pipeline.OnConfiguring(ApplicationDbContext.cs:87): resolves both interceptors from DI and adds them (ApplicationDbContext.cs:94-96), then replaces EF'sIModelCacheKeyFactorywithDataSourceModelCacheKeyFactory(ApplicationDbContext.cs:101) so each database gets its own model.ConfigureConventions(ApplicationDbContext.cs:107): addsCrossDataSourceDegradeConventionat model finalization (ApplicationDbContext.cs:115-116), which strips FK constraints and navigations between entities in different physical databases (a no-op in the collapsed-monolith case).OnModelCreating(ApplicationDbContext.cs:124): applies soft-delete filters and concurrency tokens, registers the four keylessValReturn<T>views, and configures the outbox and inbox tables.ApplySoftDeleteFilters(ApplicationDbContext.cs:149):protected static; iterates every non-ownedIAuditableEntitytype and builds an expression-treeHasQueryFilter("SoftDelete", e => !e.IsDeleted)(ApplicationDbContext.cs:151-162). 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](global filters eliminate per-queryWhere(!IsDeleted)boilerplate).ConfigureConcurrencyTokens(ApplicationDbContext.cs:176): appliesIsRowVersion()on SQL Server (database-generatedrowversion) orIsConcurrencyToken()elsewhere (application-managed) to theRowVersionproperty of every non-owned auditable entity (ApplicationDbContext.cs:179-195). EF then includes the token inUPDATE/DELETEWHEREclauses and throwsDbUpdateConcurrencyExceptionon conflicts.[Rubric §8, Data Architecture].ConfigureOutbox/ConfigureInbox(ApplicationDbContext.cs:203,223): mapOutboxMessagesandInboxMessagesindbo, with a filteredIX_OutboxMessages_Pendingindex on[ProcessedOn] IS NULL(ApplicationDbContext.cs:213-215) and a uniqueIX_InboxMessages_MessageIdindex (ApplicationDbContext.cs:229-231).ApplyConfigurationsForEntitiesInContext(ApplicationDbContext.cs:241): the discovery method subclasses call from theirOnModelCreating. It maps the engine to its configuration interface (ApplicationDbContext.cs:243-249), then for each assembly from the provider callsModelBuilderExtensions.ApplyAllConfigurationswith a filter that keeps only entities whose registry-resolved key equals thisDataSourceKey, or, for unregistered entities, only when this context is the engine'sDefaultsource (ApplicationDbContext.cs:258-267).
- Primary constructor (
- 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
ApplicationDbContextis deliberately never split into per-module context classes (ADR-006). The interceptor pipeline keeps audit and outbox concerns out of every handler. - Where it's used: inherited by the three concrete contexts below; consumed by the interceptors, the outbox processor, and the context factories.
DataSourceModelCacheKeyFactory
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/DataSourceModelCacheKeyFactory.cs:16· Level 6 · class
- 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 readDataSourceKey.Name) and EF Core'sIModelCacheKeyFactory. - Concept introduced, model caching under one-context-class-per-engine.
[Rubric §8, Data Architecture]: EF builds an in-memory model perDbContexttype 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. InsertingDataSourceKey.Nameinto the cache key makes EF treat "SQL Server / Conference" and "SQL Server / Identity" as distinct models. This is the critical enabler for ADR-006. - Walkthrough:
Create(DbContext context, bool designTime)(DataSourceModelCacheKeyFactory.cs:19) returns(context.GetType(), applicationDbContext.DataSourceKey.Name, designTime)when the context is anApplicationDbContext, 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.
- Where it's used: registered in
ApplicationDbContext.OnConfiguringviaoptionsBuilder.ReplaceService<IModelCacheKeyFactory, DataSourceModelCacheKeyFactory>().
CosmosDbContext
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/CosmosDbContext.cs:15· Level 7 · class
- What it is: the
sealedApplicationDbContextsubclass 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 differentOnModelCreatingpath. - 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 detection (
CosmosDbContext.cs:27-62): checks the connection string for the well-known emulator key prefix"C2y6yDjf5"(CosmosDbContext.cs:30). The emulator path usesConnectionMode.GatewayandDangerousAcceptAnyServerCertificateValidator(the self-signed cert), guarded by a#pragma warning disable S4830with a comment that this is safe only in local dev (CosmosDbContext.cs:42-53). The production path usesConnectionMode.DirectwithMaxRequestsPerTcpConnection(20)andMaxTcpConnectionsPerEndpoint(32)(CosmosDbContext.cs:58-60). SupportsOutbox => false(CosmosDbContext.cs:70): overrides the base; Cosmos has no relational outbox table, so domain events are dispatched in-process only.OnModelCreating(CosmosDbContext.cs:73-95): applies the Cosmos configurations, thenIgnore<OutboxMessage>()(CosmosDbContext.cs:78), then removes every index from every entity type (CosmosDbContext.cs:84-88) because the provider does not support relationalHasIndex/HasFilter, then callsApplySoftDeleteFiltersdirectly. It deliberately does not callbase.OnModelCreating(CosmosDbContext.cs:90-94) because the base registers the keylessValReturn<T>views, a relational-only construct the Cosmos provider rejects.
- 4-arg constructor (
- 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.
- Where it's used: instantiated per Cosmos source by the physical context factory when a data source
resolves to the
CosmosDBengine. - Caveats / not-in-source: the emulator certificate bypass is intentionally scoped to the emulator key prefix; whether any production connection string could match that prefix is Not determinable from source.
SqliteDbContext
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SqliteDbContext.cs:13· Level 7 · class
- What it is: the
sealedApplicationDbContextsubclass 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. - Depends on:
ApplicationDbContext,PhysicalDataSource,DataSource,IEntityConfigurationAssemblyProvider, and the SQLite EF provider. - Walkthrough: the 4-arg constructor forwards to the base (
SqliteDbContext.cs:13-19).OnConfiguringis justoptionsBuilder.UseSqlite(PhysicalSource.ConnectionString)(SqliteDbContext.cs:24-25), with no retry policy (the store is file-local) and no migrations-assembly override.OnModelCreatingcallsApplyConfigurationsForEntitiesInContext(DataSource.Sqlite, modelBuilder)thenbase.OnModelCreating(SqliteDbContext.cs:31-33), so unlike Cosmos it keeps the full base pipeline (soft-delete filters, concurrency tokens as application-managed tokens, outbox and inbox tables, and theValReturn<T>views). SeeSQLServerDbContextfor the shared subclass shape. - Why it's built this way: SQLite needs none of the SQL Server hardening (transient-failure retry, per-service migrations assembly), so the override is intentionally sparse.
- Where it's used: instantiated per SQLite source by the physical context factory when a data source
resolves to the
Sqliteengine.
SQLServerDbContext
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:14· Level 7 · class
- What it is: the
sealedApplicationDbContextsubclass 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 resolvedPhysicalDataSource. - Depends on:
ApplicationDbContext,PhysicalDataSource,DataSource,IEntityConfigurationAssemblyProvider, 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 is baked into the SQL Server path). - Walkthrough:
- 4-arg constructor (
SQLServerDbContext.cs:14-19): forwards to the base. OnConfiguring(SQLServerDbContext.cs:22-61): callsUseSqlServer(PhysicalSource.ConnectionString, sql => ...). The options action conditionally setssql.MigrationsAssembly(PhysicalSource.SqlServerMigrationsAssembly)(SQLServerDbContext.cs:31-34) so each extracted service can point at its own per-module migrations project, thensql.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null)(SQLServerDbContext.cs:42-45). An inline comment (SQLServerDbContext.cs:38-41) records the retry caveat: with retry enabled, any manualBeginTransactionAsyncmust be wrapped inDatabase.CreateExecutionStrategy().ExecuteAsync, which the transactional command decorator already does. FinallyConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))(SQLServerDbContext.cs:58) suppresses EF Core's pending-model error.OnModelCreating(SQLServerDbContext.cs:64-68): callsApplyConfigurationsForEntitiesInContext(DataSource.SQLServer, modelBuilder)thenbase.OnModelCreating, so the full base pipeline (soft-delete,rowversionconcurrency tokens, outbox/inbox tables,ValReturn<T>views) runs.
- 4-arg constructor (
- Why it's built this way: the
PendingModelChangesWarningsuppression 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 duringMigrateAsync. The documented trade-off (SQLServerDbContext.cs:55-57): the monolith loses the "you forgot a migration" safety net, so CI should rundotnet ef migrations has-pending-model-changesagainst the full model 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 the physical context factory; the primary production context in both MMCA.ADC and MMCA.Store.
UnitOfWork
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13· Level 8 · class
- 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 byDbContextFactory),IDataSourceService,IRepositoryFactory,AuditableAggregateRootEntity<TIdentifierType>, andAuditableBaseEntity<TIdentifierType>. - Concept introduced, the Unit of Work over a database-per-service topology.
[Rubric §2, Design Patterns](Unit of Work + Repository) and[Rubric §8, Data Architecture](transactions and change-tracker scoping): a handler never knows which database an entity lives in.GetRepositoryresolves 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:
- Constructor (
UnitOfWork.cs:13-16): stores the context factory, data-source service, and repository factory, null-guarding the context factory and the repository factory. _repositories(UnitOfWork.cs:23): aDictionary<Type, object>keyed by the closed generic repository interface (for exampleIRepository<Order, int>), so a repository is created at most once per entity type per scope.GetRepository<TEntity, TIdentifierType>()(UnitOfWork.cs:33-46): on a cache miss, resolves the entity'sDataSourceKeyviadataSourceService.GetDataSourceKey(typeof(TEntity))(UnitOfWork.cs:40), asks the context factory for the matching context, and builds a read-write repository through the repository factory; constrained toAuditableAggregateRootEntity<TIdentifierType>so only aggregate roots get a mutable repository.GetReadRepository<TEntity, TIdentifierType>()(UnitOfWork.cs:53-66): the same resolution but callsCreateReadOnlyand accepts anyAuditableBaseEntity<TIdentifierType>, for query handlers.- Save and transaction methods (
UnitOfWork.cs:69-91):SaveChangesAsync,Save,RequestIdentityInsert,BeginTransaction,CommitTransaction,RollbackTransaction, andExecuteInTransactionAsyncall 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. - Disposal (
UnitOfWork.cs:93-119): implements bothDisposeandDisposeAsync, disposing the context factory once, guarded by the_disposedflag.
- Constructor (
- Why it's built this way: the UoW plus the factory hide the physical topology from handlers, and
per-scope repository caching guarantees a single change tracker per database. It is
internal sealedbecause consumers only ever see theIUnitOfWorkabstraction (DIP). - Where it's used: injected into virtually every command and query handler in Common and in both apps.
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 process-level incrementing
intID generator for entities stored in Cosmos DB, which has no server-side identity columns the way SQL Server does. - Depends on:
Microsoft.EntityFrameworkCore.ValueGeneration.ValueGenerator<int>(the EF Core base) andMicrosoft.EntityFrameworkCore.ChangeTracking.EntityEntry; both are external (EF Core), so not cross-linked. - Concept introduced:
[Rubric §8, Data Architecture]assesses how well the persistence model fits each engine's capabilities. SQL Server hands out identity values server-side; Cosmos does not, so anint-keyed entity routed to Cosmos needs a client-side generator. This class supplies one that stays lock-free and thread-safe. - Walkthrough:
_seed(CosmosIntIdValueGenerator.cs:18) is a static field initialised to(int)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() % int.MaxValue), seeding from the current Unix time so restarts begin at a different point and reduce collision probability.GeneratesTemporaryValues(CosmosIntIdValueGenerator.cs:21) returnsfalse: the value produced is the real stored value, not a client-side placeholder EF will replace.Next(EntityEntry entry)(CosmosIntIdValueGenerator.cs:24-25) returnsInterlocked.Increment(ref _seed), an atomic increment with nolock. - Why it's built this way:
Interlocked.Incrementavoidslockoverhead on a hot path (every entity insert). The modulo on the seed prevents overflow at initialisation. The XML remarks (CosmosIntIdValueGenerator.cs:11-14) are explicit that collisions across separate process instances are possible; the accepted tradeoff for current usage, with GUID keys named as the alternative when strict uniqueness is required. - Where it's used: wired into Cosmos entity type configurations via EF's value-generator hook; it is engine-specific and only relevant when the Cosmos engine (CosmosDbContext) is in play.
EncryptedStringConverter
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Encryption·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Encryption/EncryptedStringConverter.cs:28· Level 0 · class (sealed)
- What it is: an EF Core
ValueConverter<string, string>that encrypts a string property with AES-256-GCM before it is written to the database and decrypts it on read. It is applied per-property inside an entity type configuration. - Depends on:
System.Security.Cryptography.AesGcmandRandomNumberGenerator,System.Text.Encoding, andMicrosoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(all external, not cross-linked). - Concept introduced:
[Rubric §11, Security](protection of data at rest) and[Rubric §30, Compliance/Privacy](handling of personal data). AES-256-GCM is authenticated encryption: it provides confidentiality (nobody without the key reads the plaintext) and integrity (tampering makes decryption throw rather than silently return garbage). A fresh 12-byte random nonce per write means two identical plaintexts encrypt to different ciphertexts, defeating frequency analysis. This is the mechanism the IAnonymizable doc comment names for personal fields that must stay retrievable after other erasure steps. - Walkthrough
NonceSize = 12andTagSize = 16(EncryptedStringConverter.cs:31,34): the GCM nonce (96 bits, NIST-recommended) and authentication tag (128 bits) sizes.- Constructor (
EncryptedStringConverter.cs:40-52) takes abyte[] encryptionKey, passesEncrypt/Decryptto theValueConverterbase, then validates the key is non-null and exactly 32 bytes (256 bits), throwingArgumentExceptionotherwise. GenerateKey()(EncryptedStringConverter.cs:58) returns a cryptographically random 32-byte key viaRandomNumberGenerator.GetBytes(32), a convenience for initial setup.Encrypt(EncryptedStringConverter.cs:60-80) short-circuits empty input, generates a random nonce, encrypts withAesGcm, then lays out[nonce(12)][ciphertext(N)][tag(16)]and returnsConvert.ToBase64String.Decrypt(EncryptedStringConverter.cs:82-103) base64-decodes, rejects anything too short to hold a nonce and tag with aCryptographicException, slices the three regions by fixed offsets usingSpan<byte>, and decrypts.
- Why it's built this way: GCM was chosen over CBC because the built-in authentication tag catches both accidental corruption and deliberate tampering without a separate HMAC pass. The 32-byte guard fails fast at construction rather than at first use. The key is passed in (never derived here) so key management lives with the host (Azure Key Vault, user-secrets, environment variables), not the converter.
- Where it's used:
[Rubric §11, Security]: currently a provided-but-unadopted extension point. Verified by source search across all four repos: no entity configuration callsHasConversion(new EncryptedStringConverter(...)), and no DI registration supplies an encryption key. The only references are its own unit tests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/EncryptedStringConverterTests.cs) and the prose pointer in IAnonymizable. It is ready to apply in anyIEntityTypeConfiguration<T>.Configurewhen a column genuinely needs at-rest encryption, but nothing does so today.
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 a module implements to populate its tables with initial reference data at startup.
- Depends on: only
System.Threading.CancellationToken. Contrast it withIModuleSeeder(in the Application layer), which receives anIServiceProviderand is the orchestration layer that instantiates and runs anIDbSeeder. - Concept introduced:
[Rubric §3, Clean Architecture]assesses whether responsibilities sit in the correct layer. Seeding is split across two contracts.IDbSeederis the Infrastructure-side contract: its implementations resolve persistence services and write data.IModuleSeederis the Application-side contract that receivesIServiceProvider, builds the concreteIDbSeeder, and drives it. Keeping the low-level write concern in Infrastructure keeps the Application layer from depending on EF directly. - Walkthrough: a single member,
Task SeedAsync(CancellationToken cancellationToken)(IDbSeeder.cs:13). No return payload; the implementation persists its own data. - Why it's built this way: the interface is deliberately minimal so any module can supply a seeder without inheriting behavior; the shared behavior (identifier conversion) lives in the DbSeeder base rather than here.
- Where it's used: implemented by DbSeeder and, through it, by every module seeder (for example
CatalogModuleDbSeeder,IdentityModuleDbSeeder,ConferenceModuleDbSeeder), each of which anIModuleSeederin the module's API project instantiates and calls.
IdentityInsertGroup
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Factory·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:226· Level 0 · record (private sealed, nested)
- What it is: a small private grouping record used inside DbContextFactory to collect the change-tracker entries destined for one table during a
SET IDENTITY_INSERTsave round. - Depends on:
Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry(external). - Concept introduced: this is a supporting value type for the identity-insert mechanism taught under DbContextFactory; it introduces no new pattern of its own.
[Rubric §8, Data Architecture]applies only insofar as it models "which added rows target which table" so the save can toggleIDENTITY_INSERTone table at a time. - Walkthrough:
private sealed record IdentityInsertGroup(string Schema, string Table, List<EntityEntry> Entries)(DbContextFactory.cs:226).SchemaandTablename the SQL Server table;Entriesholds the Added entries with explicit (non-temporary) identity values bound for it. Instances are produced byGetIdentityInsertGroups(DbContextFactory.cs:183-224) and consumed bySaveWithIdentityInsertAsync(DbContextFactory.cs:132-177). - Why it's built this way: a positional record is the least-ceremony way to bundle the three fields the identity-insert loop needs to iterate; being
privatekeeps it an implementation detail of the factory, invisible to callers. - Where it's used: solely within DbContextFactory's SQL Server identity-insert path.
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 for module database seeders. It implements IDbSeeder (leaving
SeedAsyncabstract) and adds one shared helper that converts an integer seed ID into whichever identifier type the module keys on. - Depends on: IDbSeeder; BCL only otherwise (
Guid,BitConverter,Span<byte>). - Concept introduced:
[Rubric §8, Data Architecture]: seed data is authored with plainintIDs, but modules key entities on eitherintorGuid.GetId<TIdentifier>bridges the two so the same seed literals work under either key strategy, and theGuidmapping is deterministic so reruns and test fixtures produce identical IDs. - Walkthrough:
SeedAsync(DbSeeder.cs:10) stays abstract; each module fills it in.GetId<TIdentifier>(int id)(DbSeeder.cs:20-39), constrainedwhere TIdentifier : notnull, has three branches: forGuidit writes the int into a zeroed 16-bytestackallocspan viaBitConverter.TryWriteBytesand returns a reproducibleGuid(DbSeeder.cs:23-31); forintit passes the value straight through (DbSeeder.cs:33-36); any other type throwsNotSupportedException(DbSeeder.cs:38). - Why it's built this way: the deterministic
Guidconversion (as opposed toGuid.NewGuid()) is what makes seed data stable across runs, so relationships wired by seed IDs and assertions in tests remain valid. Keeping this helper on the base means every seeder gets it without duplicating the conversion. - Where it's used: subclassed by each module's Infrastructure seeder:
CatalogModuleDbSeeder,IdentityModuleDbSeeder, andSalesModuleDbSeederin Store,ConferenceModuleDbSeederandIdentityModuleDbSeederin ADC. Each is constructed (typically with anIUnitOfWork) and invoked by the matchingIModuleSeederin the module's API project.
IDbContextFactory
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Factory·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IDbContextFactory.cs:11· Level 7 · interface
- What it is: the scoped coordinator contract for ApplicationDbContext instances across multiple physical databases within one request. It caches one context per DataSourceKey in a scope, orchestrates multi-source saves and transactions, and abstracts the schema-lifecycle operations.
- Depends on: ApplicationDbContext, DataSourceKey, DataSource (the engine enum); BCL
IDisposableandIAsyncDisposable, which it extends (IDbContextFactory.cs:11). - Concept introduced:
[Rubric §8, Data Architecture](a multi-database strategy with an explicit consistency contract) and[Rubric §29, Resilience & Business Continuity](no two-phase commit; the outbox is the compensating mechanism). The doc comment onExecuteInTransactionAsync(IDbContextFactory.cs:65-77) states the load-bearing invariant: when one operation touches multiple physical data sources, each source gets its own transaction and the commits are sequential and best-effort. A failure after the first commit but before the second leaves earlier sources committed. The OutboxMessage pattern (ADR-003) is the cross-source consistency mechanism, and callers design multi-source writes accordingly. - Walkthrough
GetDbContext(DataSourceKey)andGetDbContext(DataSource)(IDbContextFactory.cs:17,24): return the scope-cached context for a source; theDataSourceoverload targets that engine'sDefaultsource, preserving single-database call sites.SaveChangesAsyncandSaveChanges(IDbContextFactory.cs:35,40): save across every active context in the scope with audit stamping.RequestIdentityInsert()(IDbContextFactory.cs:48): signals that the next save may include entities carrying explicit values for identity columns; the flag auto-clears after the save.BeginTransaction/CommitTransaction/RollbackTransaction(IDbContextFactory.cs:53,58,63): fan out to all transaction-capable contexts.ExecuteInTransactionAsync<TResult>(IDbContextFactory.cs:78-80): runsoperationinside the active execution strategy so a retrying strategy can retry the whole unit.EnsureCreatedAsync,MigrateAsync,HasPendingMigrationsAsync(IDbContextFactory.cs:30,86,92): schema lifecycle;MigrateAsynctargets only SQL Server sources (Cosmos and SQLite are skipped).
- Why it's built this way: ADR-006 (database-per-service) means a single scope may hold several active contexts; this interface is the one place to obtain them without spawning competing instances, and the unit-of-work sits above it and delegates here.
- Where it's used: implemented by DbContextFactory; injected into the unit-of-work, into seeders, and into migration/startup code.
IPhysicalDbContextFactory
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Factory·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IPhysicalDbContextFactory.cs:14· Level 7 · interface
- What it is: the primitive one-method factory that creates a raw, uncached ApplicationDbContext for a given physical source.
- Depends on: ApplicationDbContext and DataSourceKey.
- Concept introduced:
[Rubric §2, Design Patterns]: the Factory Method at its simplest, one input (DataSourceKey), one output (a fresh context). The doc comment (IPhysicalDbContextFactory.cs:6-13) makes the layering explicit: contexts created here are not scoped or cached; IDbContextFactory layers per-scope caching, save coordination, and transactions on top. The engine in the key selects the context class; the source name selects the database (connection string, migrations assembly, model). - Walkthrough: a single member,
ApplicationDbContext Create(DataSourceKey key)(IPhysicalDbContextFactory.cs:21). - Why it's built this way: splitting "make one raw context for a key" from "cache contexts per scope and coordinate saves" lets IDbContextFactory be implemented without knowing which engine produces which concrete context.
- Where it's used: implemented by PhysicalDbContextFactory; consumed by DbContextFactory and by the
Default*engine adapters.
ApplicationDbContextEFFactory
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Factory·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/ApplicationDbContextEFFactory.cs:15· Level 8 · class (sealed)
- What it is: an adapter that implements EF Core's own
IDbContextFactory<ApplicationDbContext>, resolving the default engine from configuration and delegating to the matching engine-specific factory. - Depends on:
Microsoft.EntityFrameworkCore.IDbContextFactory<TContext>(external), ApplicationDbContext, DataSource, and the threeIDbContextFactory<T>engine adapters (DefaultSqlServerDbContextFactory, DefaultSqliteDbContextFactory, DefaultCosmosDbContextFactory) resolved fromIServiceProvider. - Concept introduced:
[Rubric §8, Data Architecture]: this is the compatibility bridge for consumers (health checks, design-time tooling) that expect EF'sIDbContextFactory<ApplicationDbContext>rather than the framework's IDbContextFactory. It reads which engine is the host default and hands back a context for that engine'sDefaultsource. - Walkthrough: the constructor (
ApplicationDbContextEFFactory.cs:25-32) readsDefaultDataSource, falls back toDataSource, then defaults toDataSource.SQLServer, parsing case-insensitively and defaulting to SQL Server on a parse miss.CreateDbContext()(ApplicationDbContextEFFactory.cs:35-41) switches on the resolvedDataSource:CosmosDB,Sqlite, orSQLServereach resolve the correspondingIDbContextFactory<T>and call itsCreateDbContext(); any other value throwsInvalidOperationException. - Why it's built this way: it keeps EF's expected DI surface working after the move to per-physical-source instantiation, so tooling that constructs a context through
IDbContextFactory<ApplicationDbContext>still resolves the correct engine for theDefaultsource. - Where it's used: resolved by anything expecting EF's
IDbContextFactory<ApplicationDbContext>; it in turn resolves theDefault*engine adapters below.
DbContextFactory
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Factory·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:19· Level 8 · class (sealed)
- What it is: the scoped implementation of IDbContextFactory: it caches one ApplicationDbContext per physical DataSourceKey and orchestrates save, transaction, and disposal across all of them.
- Depends on: IPhysicalDbContextFactory (which PhysicalDbContextFactory implements), IEntityDataSourceRegistry, IDataSourceResolver, and ICurrentUserService (all injected via primary constructor,
DbContextFactory.cs:19-29). - Concept introduced:
[Rubric §8, Data Architecture](per-source context caching, best-effort sequential transactions) and[Rubric §29, Resilience](no distributed commit). Three mechanisms carry the section:- Per-source caching and late enlistment.
_dbContexts(DbContextFactory.cs:34) holds one context per source so every repository in the scope shares one change tracker.GetDbContext(DataSourceKey)(DbContextFactory.cs:52-67) lazily creates and caches, and if a transaction is already active it immediately callsBeginTransaction()on the late-created context (DbContextFactory.cs:63-64) so all writes in a transactional command share one boundary. - Best-effort sequential transactions.
BeginTransaction/CommitTransaction/RollbackTransaction(DbContextFactory.cs:237-256) each iterate the transaction-capable contexts; Cosmos is excluded throughSupportsTransactions(DbContextFactory.cs:329-330).ExecuteInTransactionAsync(DbContextFactory.cs:263-304) obtains an EF execution strategy from the first transactional context (creating the SQL Server default if none exists yet), then begins, runs, and commits insidestrategy.ExecuteAsync, rolling back on cancellation or exception. The doc comment (DbContextFactory.cs:258-262) states plainly there is no two-phase commit; the outbox is the cross-source consistency mechanism. SET IDENTITY_INSERThandling. WhenRequestIdentityInsert()(DbContextFactory.cs:124) has set the one-shot flag,SaveChangesAsync(DbContextFactory.cs:103-121) routes SQL Server contexts throughSaveWithIdentityInsertAsync(DbContextFactory.cs:132-177).GetIdentityInsertGroups(DbContextFactory.cs:183-224) scans the change tracker for Added entities whose single-column PK is a SQL Server identity column carrying an explicit (non-temporary) value and buckets them by(schema, table)into IdentityInsertGroup records. The save loop then handles one table at a time: it flips other tables' Added entries toUnchangedto respect SQL Server's one-table constraint, runsSET IDENTITY_INSERT ... ON, saves, runs... OFF, and restores the hidden entries (DbContextFactory.cs:144-168). ACA2100suppression is justified because the table and schema names come from EF metadata, not user input (DbContextFactory.cs:101-102).
- Per-source caching and late enlistment.
- Why it's built this way: ADR-006: each service owns its database, so a scope may touch several contexts and this factory is what makes "one logical save" span them. Skipping two-phase commit is deliberate; the outbox provides at-least-once cross-source consistency instead. The identity-insert path exists for import flows that carry externally assigned IDs (for example Sessionize IDs).
- Where it's used: the unit-of-work delegates its save and transaction calls here; the outbox processor and cleanup services resolve contexts through it. Disposal fans out to every cached context (
DbContextFactory.cs:335-365).
DefaultCosmosDbContextFactory
DefaultSqliteDbContextFactory
DefaultSqlServerDbContextFactory
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Factory·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DefaultEngineDbContextFactories.cs· Level 8 · classes (internal sealed)
These three near-identical adapters share one section (structurally identical: same wrapping factory, differing only in engine constant and cast).
Three near-identical adapters that preserve EF Core's IDbContextFactory<TContext> DI surface for each concrete engine context after the move to per-physical-source instantiation. Each wraps IPhysicalDbContextFactory and returns a context for its engine's Default physical source, matching the pre-multi-database behavior consumers such as ApplicationDbContextEFFactory and health checks expect.
| Type | File:Line | Returns |
|---|---|---|
DefaultSqlServerDbContextFactory |
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DefaultEngineDbContextFactories.cs:13 |
(SQLServerDbContext)physicalFactory.Create(DataSourceKey.Default(DataSource.SQLServer)) (:17-18) |
DefaultSqliteDbContextFactory |
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DefaultEngineDbContextFactories.cs:22 |
(SqliteDbContext)physicalFactory.Create(DataSourceKey.Default(DataSource.Sqlite)) (:26-27) |
DefaultCosmosDbContextFactory |
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DefaultEngineDbContextFactories.cs:31 |
(CosmosDbContext)physicalFactory.Create(DataSourceKey.Default(DataSource.CosmosDB)) (:35-36) |
- What they are: thin
IDbContextFactory<T>adapters, one per engine (SQLServerDbContext, SqliteDbContext, CosmosDbContext). They differ only in the engine constant and the cast. - Depends on: IPhysicalDbContextFactory (primary-constructor parameter on all three), DataSourceKey, and DataSource; the EF
IDbContextFactory<TContext>interface (external). - Concept reinforced:
[Rubric §2, Design Patterns](Adapter): they exist purely to keep EF'sIDbContextFactory<TContext>contract satisfiable for callers that predate the physical-factory split, delegating the real construction to PhysicalDbContextFactory viaDataSourceKey.Default(engine). - Why it's built this way: grouping them into one small file avoids three near-empty files, and each stays
internal sealedbecause only DI and ApplicationDbContextEFFactory resolve them. - Where they're used: resolved by ApplicationDbContextEFFactory (the engine switch) and by health checks that want a
Default-source context for one engine.
PhysicalDbContextFactory
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Factory·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:16· Level 8 · class (sealed)
- What it is: the singleton IPhysicalDbContextFactory implementation that constructs concrete engine context instances directly, resolving connection information through IDataSourceResolver.
- Depends on:
IServiceProvider, IDataSourceResolver, and IEntityConfigurationAssemblyProvider (primary constructor,PhysicalDbContextFactory.cs:16-19); constructs SQLServerDbContext, SqliteDbContext, or CosmosDbContext, each taking a per-source PhysicalDataSource. - Concept introduced:
[Rubric §2, Design Patterns](an Abstract-Factory-style engine switch) and[Rubric §8, Data Architecture](the never-pool correctness rule). The class comment (PhysicalDbContextFactory.cs:7-15) is a load-bearing warning: these contexts must never be pooled (AddPooledDbContextFactory), because each instance carries per-source constructor state (PhysicalDataSource) that pooling would reuse across sources, silently pointing repositories at the wrong database. - Walkthrough: three static empty
DbContextOptions<T>fields (PhysicalDbContextFactory.cs:24-31) are shared across calls; the comment (PhysicalDbContextFactory.cs:21-23) explains all real configuration (provider, connection, interceptors, model cache key) happens in each context'sOnConfiguring, so the options are intentionally empty and match the oldAddDbContextFactory<T>()registrations.Create(DataSourceKey key)(PhysicalDbContextFactory.cs:34-45) resolves the PhysicalDataSource viaresolver.GetPhysical(key), then switches onkey.Enginetonewthe matching context (passing the shared options,serviceProvider,assemblyProvider, and the resolved physical source); an unknown engine throwsInvalidOperationException. - Why it's built this way: separating "make one raw context for a key" (this singleton) from "cache contexts per scope and coordinate saves" (DbContextFactory) is the split that makes multi-database routing work; the no-pool rule is the subtle correctness invariant that split protects.
- Where it's used: DbContextFactory calls
Createfor every source it caches; theDefault*engine adapters and health checks call it forDefault-source contexts.
NativePushPayloads
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/NativePushPayloads.cs:10· Level 0 · class (internal static)
- What it is: a pure helper that builds the platform-native JSON bodies (FCM v1 for Android, APNs for Apple) and the
user:{id}OR-tag expressions that an Azure Notification Hubs send needs. It holds no state and touches no hub, so the payload shapes and the tag-chunking rule are unit-testable in isolation (NativePushPayloads.cs:5-10). - Depends on: the BCL only:
System.Text.Json.JsonSerializerfor the payload strings,Enumerable.Chunkfor the OR-expression batching, and theUserIdentifierTypealias (see primer §2) for the user-tag input. - Concept introduced, native push payload construction and the 20-tag chunk rule.
[Rubric §7, Microservices Readiness]assesses whether cross-cutting delivery mechanics live behind a reusable, transport-specific boundary rather than smeared through handlers; here the exact wire shapes of two third-party push protocols are pinned in one place. Azure Notification Hubs caps a single tag expression at 20 tags (MaxTagsPerExpression,NativePushPayloads.cs:13), so a user-targeted broadcast to a large audience is split intoChunk(20)groups, each rendered as auser:a || user:b || ...OR-expression (NativePushPayloads.cs:59-63). That cap is a real hub limit, not an arbitrary batch size, which is why it is a named constant the sender reuses rather than a literal. - Walkthrough:
BuildFcmV1Payload(NativePushPayloads.cs:16-28) nests anotificationblock oftitle/bodyunder amessageenvelope, adding adatamap only when metadata is non-empty ({ Count: > 0 }pattern, line 22).BuildApnsPayload(NativePushPayloads.cs:31-53) builds the APNsaps.alertblock, then copies each metadata pair up to the top level as a custom key while explicitly refusing to overwrite the reservedapskey (NativePushPayloads.cs:44-49).BuildUserTagExpressions(NativePushPayloads.cs:59-63) maps each id throughUserTag, chunks, and joins.UserTag(NativePushPayloads.cs:66-67) formatsuser:{userId}underInvariantCultureviastring.Create, so a numeric id never picks up a locale-specific separator. - Why it's built this way: keeping the payload shapes and the hub's tag cap in a stateless helper (ADR-044) means the
AzureNotificationHubNativePushSenderstays a thin adapter and the fiddly JSON/tag rules can be proven correct without a live hub or credentials. - Where it's used: consumed by
AzureNotificationHubNativePushSender(payloads and tag expressions) andAzureNotificationHubDeviceRegistrar(theUserTagstamped on each installation). - Caveats / not-in-source:
internal, so it is reachable only insideMMCA.Common.Infrastructureand itsInternalsVisibleTotest project.
AzureNotificationHubNativePushSender
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/AzureNotificationHubNativePushSender.cs:14· Level 1 · class (sealed partial)
- What it is: the Azure Notification Hubs implementation of
INativePushSender: the real, mobile-facing native notification channel that pushes FCM v1 and APNs payloads through a hub client (AzureNotificationHubNativePushSender.cs:7-16). - Depends on:
INativePushSender(the contract it fulfills),NativePushPayloads(payload + tag construction), and two externals:Microsoft.Azure.NotificationHubs.INotificationHubClient(the hub SDK) andILogger<T>. - Concept introduced, the native (mobile) push channel and its best-effort contract.
[Rubric §13, Observability & Operability]covers whether side-effecting integrations log their outcomes and fail without taking the request down; this sender emits a structured log per send (LogNativePushSent,AzureNotificationHubNativePushSender.cs:42-43) and its class comment records that callers treat the channel as best-effort, wrapping it in a non-fatal catch (AzureNotificationHubNativePushSender.cs:11-12). This is the device-facing counterpart to the in-app SignalR channel:NullPushNotificationSenderand its SignalR sibling deliver to connected web clients, whereas this reaches devices via APNs/FCM. - Walkthrough: the primary constructor takes the hub client and logger (
AzureNotificationHubNativePushSender.cs:14-16).SendToUsersAsync(AzureNotificationHubNativePushSender.cs:19-31) builds both payloads once, then for each 20-tag OR-expression sends anFcmV1Notificationand anAppleNotificationtargeted at that expression (AzureNotificationHubNativePushSender.cs:24-28), so one call fans out to both platforms per audience chunk.BroadcastAsync(AzureNotificationHubNativePushSender.cs:34-40) sends the same two payloads with no tag filter, reaching every registered installation. BothConfigureAwait(false)on every await (infrastructure code, no sync context needed) and log the title on completion. - Why it's built this way: the
partialclass exists so the[LoggerMessage]source generator can emitLogNativePushSent(AzureNotificationHubNativePushSender.cs:42-43), the high-performance logging pattern used across the framework. Splitting payload construction intoNativePushPayloads(ADR-044) keeps this type a pure transport adapter. - Where it's used: registered in place of
NullNativePushSenderwhen a host callsAddNativePushNotifications()with an enabled hub configuration; resolved whereverINativePushSenderis injected (the native-push send handler).
ExplicitAssemblyProvider
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Design·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:106· Level 1 · class (sealed, private nested)
- What it is: a tiny private nested provider inside
DesignTimeDbContextHelperthat returns a fixed, caller-supplied list of entity-configuration assemblies (DesignTimeDbContextHelper.cs:106-109). - Depends on:
IEntityConfigurationAssemblyProvider(the contract) andSystem.Reflection.Assembly. - Concept reinforced, explicit assembly enumeration in place of runtime scanning.
[Rubric §8, Data Architecture]looks at whether the model's entity set is deterministic per database; at runtime the framework discovers configuration assemblies by scanning the AppDomain, butdotnet efdesign-time commands see none of that.GetConfigurationAssemblies(DesignTimeDbContextHelper.cs:108) simply hands back the assemblies the migrations project listed viaDesignTimeDbContextOptions.AddConfigurationAssembly, so the design-time model contains exactly the intended entities and nothing else. - Why it's built this way: it is the design-time substitute for the AppDomain-scanning provider; keeping it private and trivial means the migrations authoring surface stays
DesignTimeDbContextOptions, not this class. - Where it's used: instantiated once inside
DesignTimeDbContextHelper.CreateSqlServer(DesignTimeDbContextHelper.cs:55) and registered as theIEntityConfigurationAssemblyProviderfor the design-time context (DesignTimeDbContextHelper.cs:70). - Caveats / not-in-source: private nested type; it surfaces in the inventory only because the tool includes private nested classes. Not reachable from outside the helper.
NullNativePushSender
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/NullNativePushSender.cs:10· Level 1 · class (sealed)
- What it is: the no-op default implementation of
INativePushSender: both methods returnTask.CompletedTaskso the native-push channel always resolves and silently does nothing until a host opts in (NullNativePushSender.cs:5-19). - Depends on:
INativePushSenderonly. - Concept reinforced, the Null Object pattern as the safe default channel.
[Rubric §2, Design Patterns]values a harmless default that satisfies a contract without a live dependency; registering this type by default means DI resolution and the Devices/send endpoints work everywhere, even in a host with no notification hub. The realAzureNotificationHubNativePushSenderis swapped in only whenAddNativePushNotifications()runs with enabled hub configuration (NullNativePushSender.cs:6-9). - Walkthrough:
SendToUsersAsyncandBroadcastAsync(NullNativePushSender.cs:13-18) each match the interface signature and return a completed task; there is no logging and no failure, by design. - Why it's built this way: ADR-044 gives the framework three notification channels; a no-op default keeps the native channel optional so a host that never configures a hub still composes and runs.
- Where it's used: registered as the default
INativePushSender; paired withNullPushDeviceRegistrar, the no-op registrar for the same disabled-hub scenario.
SoftDeleteUniqueIndexConvention
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Conventions·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:24· Level 1 · class (sealed)
- What it is: an EF Core model-finalizing convention that appends an
IsDeleted = 0filter to every unique index on a soft-deletable entity type, so a soft-deleted row stops occupying its unique slot (SoftDeleteUniqueIndexConvention.cs:10-24). - Depends on:
IAuditableEntity(the soft-delete marker it tests for) and theDataSourceengine enum; externally, EF Core's convention metadata API (IModelFinalizingConvention,IConventionModelBuilder,IConventionEntityType,IConventionIndex). - Concept introduced, filtered (partial) unique indexes as the database half of soft delete.
[Rubric §8, Data Architecture]assesses whether the storage model actually enforces the semantics the application presents, and[Rubric §16, Maintainability]assesses whether a cross-cutting rule is applied once centrally instead of remembered per entity. Soft delete (see primer §2) hides a row behind a global query filter, but the row is still physically present, so a plain unique index keeps rejecting a new record that reuses the deleted one's value: delete a speaker and that email address stays permanently unusable (SoftDeleteUniqueIndexConvention.cs:11-16). A filtered index solves this by indexing only live rows. Making it a convention means no entity configuration author has to remember the rule. - Walkthrough
- The primary constructor takes the engine of the context being built (
SoftDeleteUniqueIndexConvention.cs:24), because filter syntax is provider-specific. ProcessModelFinalizing(SoftDeleteUniqueIndexConvention.cs:27-41) null-guards the builder, returns immediately for Cosmos (SoftDeleteUniqueIndexConvention.cs:33-34), then selects every entity type assignable toIAuditableEntitythat is not owned (SoftDeleteUniqueIndexConvention.cs:36-37) and processes each.ApplyFilterToUniqueIndexes(SoftDeleteUniqueIndexConvention.cs:43-57) resolves the mapped column name forIsDeleted, falling back to the property name when the property is absent (SoftDeleteUniqueIndexConvention.cs:45-46), then builds the SQL literal: bracket-quoted[IsDeleted] = 0for SQL Server, double-quoted"IsDeleted" = 0otherwise (SoftDeleteUniqueIndexConvention.cs:48-50).- The loop applies the filter only to indexes that are unique and have no filter already (
SoftDeleteUniqueIndexConvention.cs:52-56), so a hand-authored filter in an entity configuration always wins.
- The primary constructor takes the engine of the context being built (
- Why it's built this way: running at model finalization guarantees the convention sees every index a module configuration declared, rather than racing declaration order. Cosmos is skipped because it has no partial-index concept; SQL Server and SQLite both support filtered/partial indexes (
SoftDeleteUniqueIndexConvention.cs:17-21). Respecting an existing filter keeps the convention additive, never destructive. - Where it's used: registered per context in
ApplicationDbContext.ConfigureConventions(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:151), immediately afterCrossDataSourceDegradeConvention, and it therefore applies to every entity in every module of every host.
DesignTimeDbContextOptions
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Design·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextOptions.cs:11· Level 2 · class (sealed)
- What it is: the configuration carrier a migrations project fills in to tell
DesignTimeDbContextHelperhow to build a context fordotnet ef ... -- --datasource <Name>. It holds the connection settings, the named data-source entries, and the explicit list of entity-configuration assemblies (DesignTimeDbContextOptions.cs:11-33). - Depends on:
ConnectionStringSettings,DataSourceEntrySettings, andSystem.Reflection.Assembly. - Concept introduced, design-time context construction for database-per-service.
[Rubric §8, Data Architecture]assesses whether each database's migrations are built in isolation; in the database-per-service model (ADR-006) each module's migrations project must scaffold a context for only its own database. At design time there is no DI container and no AppDomain scan, so this options object captures everythingdotnet efcannot discover on its own: the top-level connection strings includingSQLServerMigrationsAssembly(DesignTimeDbContextOptions.cs:20-24), the namedDataSourcesentries (DesignTimeDbContextOptions.cs:26-27), and the explicit configuration assemblies (DesignTimeDbContextOptions.cs:29-33, whose comment notes the runtime scan sees nothing here). - Walkthrough:
DataSourceName(DesignTimeDbContextOptions.cs:18) is optional; when null the helper parses--datasourceand falls back toDefault.AddConfigurationAssembly(DesignTimeDbContextOptions.cs:38-47) is a chainable builder method that guards against duplicate registrations before adding. - Why it's built this way: a single options object plus a builder method keeps each per-module migrations factory to a handful of lines while still pinning the model to one database (ADR-006).
- Where it's used: passed to
DesignTimeDbContextHelper.CreateSqlServer(args, options => ...)from each per-database migrations factory (for example theMMCA.ADC.Migrations.SqlServer.*projects).
NullDomainEventDispatcher
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Design·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:111· Level 2 · class (sealed, private nested)
- What it is: a no-op
IDomainEventDispatcherused only inside the design-time context helper (never in production).DispatchAsyncreturnsTask.CompletedTask(DesignTimeDbContextHelper.cs:111-115). - Depends on:
IDomainEventandIDomainEventDispatcher. - Concept reinforced, the Null Object pattern for a design-time DI gap.
[Rubric §2, Design Patterns]values satisfying an interface with a harmless no-op when the real implementation would need the full application container. Duringdotnet ef migrations addthe design-time factory builds a context but never saves through it, so a real dispatcher (which would try to hand events to handlers that are not registered here) would be both unnecessary and wrong. Registering this null dispatcher (DesignTimeDbContextHelper.cs:66) closes that dependency without pulling in application services. - Why it's built this way: the design-time service graph is deliberately minimal (null loggers, null dispatcher, a hand-built
ServiceCollection) so scaffolding a migration never spins up the app; this type is one leaf of that minimal graph. - Where it's used: registered as the
IDomainEventDispatcherinsideDesignTimeDbContextHelper.CreateSqlServer(DesignTimeDbContextHelper.cs:66). - Caveats / not-in-source: private nested type inside
DesignTimeDbContextHelper; not accessible from outside.
CrossDataSourceDegradeConvention
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Conventions·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/CrossDataSourceDegradeConvention.cs:33· Level 3 · class (sealed)
- What it is: the EF Core model-finalizing convention that detects relationships whose two ends resolve to different physical databases and degrades them: the foreign key and its navigations are removed from the model, the declared scalar FK columns survive with a compensating index, and entity types belonging to another source are dropped from this model entirely (
CrossDataSourceDegradeConvention.cs:9-33). - Depends on:
DataSourceKey,DataSource,IEntityDataSourceRegistry; externally EF Core's metadata API (IModelFinalizingConvention,IMutableModel,IMutableEntityType,IMutableForeignKey,IMutableProperty,IConventionIndex). - Concept introduced, automatic cross-database relationship degradation.
[Rubric §8, Data Architecture]assesses the database-per-service consistency strategy, and[Rubric §7, Microservices Readiness]assesses whether the model adapts to the deployment topology without per-entity code. Under database-per-service (ADR-006) a database cannot enforce a foreign key that points into another database, so this convention strips those relationships automatically at model finalization. The scalar column survives (a query can still filter on, say, aUserId), cross-source loading is left toINavigationPopulatorbatch loading (ADR-002, seeINavigationPopulator<in TEntity>), and cross-source consistency is the outbox's job (CrossDataSourceDegradeConvention.cs:12-21). The closing remark is the load-bearing invariant: when every entity resolves to the same physical source (the monolith-collapse case) nothing is foreign and the convention is a structural no-op, so the collapsed model is identical to the single-database model (CrossDataSourceDegradeConvention.cs:25-29). - Walkthrough
- The primary constructor takes the
contextKey(the physical source whose model is being built) and theIEntityDataSourceRegistry(CrossDataSourceDegradeConvention.cs:33-35);IsForeign(CrossDataSourceDegradeConvention.cs:91-94) asks the registry for a CLR type's key and returns true when it differs fromcontextKey. ProcessModelFinalizing(CrossDataSourceDegradeConvention.cs:38-89) casts the model to the mutable surface (CrossDataSourceDegradeConvention.cs:46) deliberately: cross-cutting helpers (soft-delete filters, concurrency tokens) promote every entity type to the Explicit configuration source, which convention-sourced builder calls cannot override (CrossDataSourceDegradeConvention.cs:22-24,44-45). It collects the non-owned foreign entity types (CrossDataSourceDegradeConvention.cs:48-50) and returns early when there are none (CrossDataSourceDegradeConvention.cs:52-55).- Step 1 (
CrossDataSourceDegradeConvention.cs:62-74): for every local dependent it degrades each declared FK pointing at a foreign principal.addCompensatingIndexis false for Cosmos (CrossDataSourceDegradeConvention.cs:65), because Cosmos auto-indexes every property and rejects explicit index definitions; that skip is what makes one configuration body portable to Cosmos without edits (CrossDataSourceDegradeConvention.cs:100-105). DegradeForeignKey(CrossDataSourceDegradeConvention.cs:107-138) keeps the non-shadow scalar FK properties, removes the FK (CrossDataSourceDegradeConvention.cs:116), then eagerly drops the convention-created FK index before the coverage check (CrossDataSourceDegradeConvention.cs:123-130), because EF's deferred event processing would otherwise remove it after the check and leave the column unindexed. It adds a plain index only whenHasCoveringIndex(CrossDataSourceDegradeConvention.cs:140-144) finds no existing index covering those columns as a prefix.- Step 2 (
CrossDataSourceDegradeConvention.cs:79-82):IgnoreForeignMembers(CrossDataSourceDegradeConvention.cs:151-165) removes skip navigations to foreign types and ignores any CLR property whose (collection-unwrapped) type is a foreign entity, so model validation does not later reject an unmapped entity-typed property;UnwrapCollectionElementType(CrossDataSourceDegradeConvention.cs:171-174) handles theList<T>/ICollection<T>case. - Step 3 (
CrossDataSourceDegradeConvention.cs:84-88): removes the foreign entity types from the model.
- The primary constructor takes the
- Why it's built this way: degrading in a convention rather than per-configuration means no module author has to remember to break a cross-service relationship by hand; the same configuration class works whether its module ships inside the monolith or as its own service (ADR-006, ADR-007).
- Where it's used: registered per context in
ApplicationDbContext.ConfigureConventions(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:146), just beforeSoftDeleteUniqueIndexConvention;DataSourceModelCacheKeyFactory(ApplicationDbContext.cs:131) ensures each database caches its own degraded model rather than reusing one built for a different source.
DataSourceService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/DataSourceService.cs:12· Level 3 · class (sealed)
- What it is: the application-facing facade over
IEntityDataSourceRegistry: given an entity type (or its full name) it answers which physical data source that entity lives in, and whether two entities can be EF-Included together (DataSourceService.cs:6-12). - Depends on:
IDataSourceService(the contract),IEntityDataSourceRegistry(the eager routing table it delegates to), and theDataSourceKey/DataSourcevalue types. - Concept reinforced, entity-to-database routing as a query surface.
[Rubric §8, Data Architecture]assesses whether database-per-service routing is a first-class, queryable concept; the registry aggregates every[UseDataSource]/[UseDatabase]declaration at startup, and this facade is the thin runtime interface over it. Because the registry is built eagerly from configuration assemblies (DataSourceService.cs:8-11), resolution no longer waits for an EF model to be built, which matters for the navigation classification that runs before any query. - Walkthrough: the four
GetDataSource*overloads (DataSourceService.cs:15-24) forward straight to the registry, returning either the fullDataSourceKeyor just itsEngine(DataSource).HaveIncludeSupport(DataSourceKey, DataSourceKey)(DataSourceService.cs:31-32) encodes the eager-loading rule: an EFIncludeis valid only when both entities resolve to the same key and that engine is not Cosmos (first == second && first.Engine != DataSource.CosmosDB), because Cosmos has no cross-document joins. The string overload (DataSourceService.cs:35-38) resolves both names throughTryGetDataSourceKeyand defers to the key overload, returning false if either name is unknown. - Why it's built this way: keeping the include-support rule in one predicate lets the navigation populators and cross-source degrade logic ask a single authority whether a relationship can be loaded in-database versus batch-loaded across sources (ADR-006). Facading the registry keeps callers off its lower-level API.
- Where it's used: injected wherever code must classify a navigation or pick a context for an entity: the cross-data-source degrade convention and the navigation-populator batching path both consult it.
AzureBlobFileStorageService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/AzureBlobFileStorageService.cs:15· Level 4 · class (sealed)
- What it is: the Azure Blob Storage implementation of
IFileStorageService: uploads and deletes blobs in the single configured container, returningResultinstead of throwing (AzureBlobFileStorageService.cs:10-17). - Depends on:
IFileStorageService, theResult/Errortypes, and Azure externalsBlobContainerClient/BlobUploadOptions/RequestFailedExceptionplusILogger<T>. - Concept introduced, the file-storage boundary and Result-wrapped I/O.
[Rubric §10, Cross-Cutting Concerns]covers pushing infrastructure integrations behind an application-owned contract; here blob I/O is hidden behindIFileStorageServiceand every SDK failure is caught and mapped to a domainErrorrather than bubbling as an exception.IsConfigured => true(AzureBlobFileStorageService.cs:20) is the flag that distinguishes this live implementation from theNullFileStorageServicefallback. - Walkthrough: the constructor takes an already-scoped
BlobContainerClientand a logger (AzureBlobFileStorageService.cs:15-17); the class comment notes the container and its access level are provisioned by infrastructure, not created here (AzureBlobFileStorageService.cs:12-13).UploadAsync(AzureBlobFileStorageService.cs:23-43) gets a blob client, uploads with an explicitContentTypeheader, and returnsResult.Success(blobClient.Uri); aRequestFailedExceptionis logged and mapped toError.Failure("FileStorage.UploadFailed", ...)(AzureBlobFileStorageService.cs:35-42).DeleteAsync(AzureBlobFileStorageService.cs:46-62) callsDeleteBlobIfExistsAsync(idempotent) and maps failures toFileStorage.DeleteFailed. - Why it's built this way: ADR-045 introduces the file-storage/image pipeline; returning
Resultkeeps storage failures on the same error-handling rail as the rest of the stack, and catching onlyRequestFailedExceptionmeans genuinely unexpected errors still surface. - Where it's used: registered by
AddAzureBlobFileStorage(configuration)in place ofNullFileStorageService; consumed by feature handlers that persist uploaded files (typically afterImageSharpImageProcessorhas normalized the bytes).
AzureNotificationHubDeviceRegistrar
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/AzureNotificationHubDeviceRegistrar.cs:15· Level 4 · class (sealed)
- What it is: the Azure Notification Hubs implementation of
IPushDeviceRegistrar: registers (upserts) and unregisters a device's push installation using the hub's installation model, stamping each installation with its owner'suser:{id}tag (AzureNotificationHubDeviceRegistrar.cs:10-17). - Depends on:
IPushDeviceRegistrar,DeviceInstallationRequest(the inbound DTO),NativePushPayloads.UserTag(the owner tag),Result/Error, and Azure externalsINotificationHubClient/Installation/MessagingException. - Concept introduced, device registration via the installation model.
[Rubric §11, Security]includes owner-scoping of side channels; by stamping every installation withNativePushPayloads.UserTag(userId)(AzureNotificationHubDeviceRegistrar.cs:41) the registrar guarantees a later user-targeted send reaches only that user's devices. The installation model uses client-owned stable ids with full upsert semantics (AzureNotificationHubDeviceRegistrar.cs:11-13), so re-registering the same device is idempotent rather than duplicating. - Walkthrough:
UpsertAsync(AzureNotificationHubDeviceRegistrar.cs:20-57) first maps the request's platform string to aNotificationPlatformvia aswitchoverFCMV1/APNS(AzureNotificationHubDeviceRegistrar.cs:22-27); an unrecognized value returnsError.Validation("PushDevice.UnsupportedPlatform", ...)before any hub call (AzureNotificationHubDeviceRegistrar.cs:28-34). It then builds anInstallationwith the client id, platform, push channel, and the single user tag (AzureNotificationHubDeviceRegistrar.cs:36-42) and callsCreateOrUpdateInstallationAsync, mapping aMessagingExceptiontoPushDevice.UpsertFailed.DeleteAsync(AzureNotificationHubDeviceRegistrar.cs:60-80) deletes the installation but treatsMessagingEntityNotFoundExceptionas success (AzureNotificationHubDeviceRegistrar.cs:67-71): an unknown installation is already in the desired state, so delete is idempotent. - Why it's built this way: ADR-044's native channel needs a way to associate devices with users; the tag-per-installation approach lets sends target
user:{id}OR-expressions without the app keeping its own device table. Idempotent delete keeps client retries safe. - Where it's used: registered by
AddNativePushNotifications()in place ofNullPushDeviceRegistrar; called by the Devices endpoints and paired withAzureNotificationHubNativePushSenderfor the send side.
ImageSharpImageProcessor
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/ImageSharpImageProcessor.cs:14· Level 4 · class (sealed)
- What it is: the ImageSharp implementation of
IImageProcessor: it decodes an uploaded image, re-orients and crops it to a square, strips all metadata, and re-encodes it as JPEG, returning the bytes as aResult(ImageSharpImageProcessor.cs:9-17). - Depends on:
IImageProcessor,Result/Error, and the SixLabors.ImageSharp externals (Image,Mutate,ResizeOptions,JpegEncoder). - Concept introduced, full re-encode as a security control.
[Rubric §11, Security]and[Rubric §30, Compliance/Privacy/Data Governance]both apply: decoding to pixels and re-encoding is deliberate so that EXIF metadata (including GPS coordinates, which are PII) and any polyglot payload smuggled into the original file are discarded, since only pixels survive the round trip (ImageSharpImageProcessor.cs:9-13). This is a defense against both privacy leaks and image-parser exploits, not merely a resize. - Walkthrough:
NormalizeToSquareJpegAsync(ImageSharpImageProcessor.cs:17-51) loads the stream, thenMutates withAutoOrient()before stripping metadata so a portrait phone photo is not left rotated (ImageSharpImageProcessor.cs:23-31), and resizes tosize x sizewithResizeMode.Crop. It then nulls out the EXIF, XMP, and IPTC profiles (ImageSharpImageProcessor.cs:33-35) and saves to aMemoryStreamwithJpegEncoder { Quality = 85 }(ImageSharpImageProcessor.cs:40), returningResult.Success(output.ToArray()). AnUnknownImageFormatExceptionorInvalidImageContentExceptionis caught and mapped toError.Validation("Image.Undecodable", ...)(ImageSharpImageProcessor.cs:44-50), so a garbage upload becomes a clean validation failure rather than a 500. - Why it's built this way: ADR-045 pairs storage with sanitization; ordering
AutoOrientbefore metadata removal is the subtle correctness detail, and quality 85 is the standard size/quality trade-off. Catching only the two ImageSharp decode exceptions keeps unexpected faults visible. - Where it's used: invoked by feature handlers before an avatar or image upload is handed to
AzureBlobFileStorageService; it has no configured/unconfigured split (there is no Null variant) because processing needs no external resource.
NullFileStorageService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/NullFileStorageService.cs:11· Level 4 · class (sealed)
- What it is: the unconfigured-host fallback for
IFileStorageService: uploads fail with a clear error while deletes succeed, so file features degrade cleanly instead of crashing (NullFileStorageService.cs:6-11). - Depends on:
IFileStorageServiceandResult/Error. - Concept reinforced, asymmetric Null Object (fail-closed write, no-op delete).
[Rubric §2, Design Patterns]and[Rubric §10, Cross-Cutting Concerns]: unlike a pure no-op, this fallback distinguishes its two operations by intent.IsConfigured => false(NullFileStorageService.cs:14) lets callers detect the disabled channel;UploadAsyncreturnsError.Failure("FileStorage.NotConfigured", ...)(NullFileStorageService.cs:17-21) so a write fails loudly and predictably, whileDeleteAsyncreturnsResult.Success()(NullFileStorageService.cs:24-25) because there is nothing to delete and a delete of a non-existent file is already the desired state. - Why it's built this way: ADR-045 makes storage optional; failing uploads with a typed error (rather than a null-reference crash) keeps a host with no storage configured running and honest about what it cannot do.
- Where it's used: the default
IFileStorageService, swapped forAzureBlobFileStorageServicebyAddAzureBlobFileStorage(configuration).
NullPushDeviceRegistrar
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/NullPushDeviceRegistrar.cs:12· Level 4 · class (sealed)
- What it is: the no-op default for
IPushDeviceRegistrar: it accepts and discards device registrations so clients can call the Devices endpoints unconditionally, storing nothing until a hub is configured (NullPushDeviceRegistrar.cs:7-12). - Depends on:
IPushDeviceRegistrar,DeviceInstallationRequest, andResult. - Concept reinforced, the Null Object pattern for the disabled native channel.
[Rubric §2, Design Patterns]: bothUpsertAsyncandDeleteAsyncreturnResult.Success()(NullPushDeviceRegistrar.cs:15-20), so the Devices API is always callable and simply does nothing when no notification hub is wired up. It is the device-registration twin ofNullNativePushSender, which no-ops the send side of the same disabled channel (ADR-044). - Why it's built this way: keeping registration a success (rather than an error) means a client that always registers on launch is not blocked by a host that has not enabled native push; the channel becomes real only when
AzureNotificationHubDeviceRegistraris registered. - Where it's used: the default
IPushDeviceRegistrar, replaced byAzureNotificationHubDeviceRegistrarwhenAddNativePushNotifications()runs with an enabled hub.
DesignTimeDbContextHelper
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DbContexts.Design·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:34· Level 8 · class (static)
- What it is: a static helper that builds a
SQLServerDbContextfordotnet efdesign-time commands without the application's DI container, so each per-database migrations project reduces to a few lines (DesignTimeDbContextHelper.cs:16-34). - Depends on: EF Core (
DbContextOptionsBuilder,IDesignTimeDbContextFactory), the data-source resolution stack (DataSourceResolver,EntityDataSourceRegistry,DataSourcesSettings), the save interceptors (AuditSaveChangesInterceptor,DomainEventSaveChangesInterceptor),IOutboxSignal/OutboxSignal, and its own two private nested leavesExplicitAssemblyProviderandNullDomainEventDispatcher. - Concept introduced, design-time context construction for migrations-per-database.
[Rubric §17, DevOps]and[Rubric §33, Developer Experience]: database-per-service (ADR-006) needs one migrations project per database, and scaffolding a migration must not require standing up the whole app.CreateSqlServer(args, configure)(DesignTimeDbContextHelper.cs:43-81) lets a migrations project implement EF'sIDesignTimeDbContextFactory<SQLServerDbContext>in a callback that supplies connection settings and configuration assemblies (the pattern is shown in the class doc,DesignTimeDbContextHelper.cs:20-30). - Walkthrough:
CreateSqlServer(DesignTimeDbContextHelper.cs:43-81) validates its arguments, runs the caller'sconfigureover a freshDesignTimeDbContextOptions, then resolves the logical source name in priority order: the explicitDataSourceName, else--datasourcefrom args, elseDataSourceKey.DefaultName(DesignTimeDbContextHelper.cs:51-53). It builds anExplicitAssemblyProviderfrom the listed assemblies, aDataSourceResolverwith null logging, and anEntityDataSourceRegistry(DesignTimeDbContextHelper.cs:55-60), then hand-builds a minimalServiceCollectionwiringTimeProvider.System, null loggers, theNullDomainEventDispatcher, anOutboxSignal, both interceptors, and the resolver/registry (DesignTimeDbContextHelper.cs:62-72). Finally it resolves the physical source and constructs theSQLServerDbContextwith an empty options builder plus that provider (DesignTimeDbContextHelper.cs:74-81), so the built model contains only the selected source's entities.ParseDataSourceName(DesignTimeDbContextHelper.cs:86-104) reads--datasource <Name>or--datasource=Name, throwing if the flag is present with no value. - Why it's built this way: ADR-006 requires per-database migrations; a shared design-time helper keeps each migrations project trivial and avoids booting the full application DI graph just to scaffold a migration. Null loggers and a null dispatcher are the minimal stand-ins for services that are irrelevant when no save ever runs.
- Where it's used: called from each per-database migrations factory, for example the
MMCA.ADC.Migrations.SqlServer.{Identity,Conference,Engagement}projects, and invoked asdotnet ef migrations add X --project ... -- --datasource <Name>(DesignTimeDbContextHelper.cs:31).
CapturedState
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Interceptors·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:268· Level 2 · record (sealed, private nested)
- What it is: a private nested record inside
DomainEventSaveChangesInterceptorthat carries everything captured before a save so it can be consumed after the save completes: the tracked aggregate roots, the events that are eligible for in-process dispatch, the outbox rows backing those events, and a flag saying whether any integration events were also written. - Depends on:
IAggregateRoot(viaEntityEntry<IAggregateRoot>[]),IDomainEvent,OutboxMessage;Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<T>(EF Core). - Concept introduced, pre-save / post-save state handoff.
[Rubric §6, CQRS & Event-Driven]assesses whether state changes are announced as events with reliable delivery rather than leaked as ad-hoc side effects; here the interceptor runs in two phases (capture before the write, route after it), andCapturedStateis the immutable value that bridges them instead of a mutable field a concurrent save could clobber. Its four positional members (DomainEventSaveChangesInterceptor.cs:268-272) are exactly what the post-save phase needs:AggregateRootEntities(the entries whose events must be cleared),LocalEvents(what to dispatch in process),LocalOutboxEntries(the rows to mark processed once that dispatch succeeds), andHasIntegrationEvents(whether to wake the outbox processor for rows that deliberately stay unprocessed). - Walkthrough: a
sealed recordwith four constructor-set members (DomainEventSaveChangesInterceptor.cs:268-272). Instances live per-DbContextin the interceptor's staticConditionalWeakTable<DbContext, CapturedState>(DomainEventSaveChangesInterceptor.cs:48) and are removed again in the post-save phase (DomainEventSaveChangesInterceptor.cs:105,DomainEventSaveChangesInterceptor.cs:206), so nothing keeps a context alive past its own lifetime. When a transaction is active the same instance is re-wrapped in aDeferredDispatchand parked until commit (DomainEventSaveChangesInterceptor.cs:213). - Where it's used: created in
CaptureEventsAndPersistToOutbox(DomainEventSaveChangesInterceptor.cs:193-194), read back inDispatchAndFinalizeAsync(DomainEventSaveChangesInterceptor.cs:203-206), consumed inFlushStateAsync(DomainEventSaveChangesInterceptor.cs:224), and drained on the synchronous save path inSavedChanges(DomainEventSaveChangesInterceptor.cs:103-109). - Caveats / not-in-source: private nested type; it surfaces in the inventory only because the tool includes private nested types. It is not part of the public API.
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), andGetPhysicalSourcesInUse()(IEntityDataSourceRegistry.cs:35). - Depends on:
DataSourceKey. - 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 this interface exists: 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 strictGetDataSourceKeyoverloads throwInvalidOperationExceptionfor an unregistered entity (documented atIEntityDataSourceRegistry.cs:16andIEntityDataSourceRegistry.cs:22), whileTryGetDataSourceKeyis the non-throwing probe used where a miss is legitimate. - Why it's built this way: database-per-service (ADR-006) needs every entity to resolve to exactly one physical source; building the map eagerly turns a misconfiguration into a loud startup failure instead of a silent wrong-database query.
- Where it's used: implemented by
EntityDataSourceRegistry; consumed byCrossDataSourceDegradeConvention(to classify foreign entity types),DataSourceService(the runtime facade), and the outbox/migrations enumeration paths.
PhysicalDataSource
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DataSources·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:17· Level 2 · record (sealed)
- What it is: the fully-resolved connection information for one physical database:
Key(its engine plus name identity),ConnectionString,SqlServerMigrationsAssembly?, andCosmosDatabaseName(PhysicalDataSource.cs:17-21). - Depends on:
DataSourceKey. - Concept, a logical name resolved to a real connection.
[Rubric §8, Data Architecture]covers the step from a configured name likeDataSources:Conferenceto an actual database. The record's doc comment (PhysicalDataSource.cs:5-9) explains that it is produced byIDataSourceResolverfrom the top-levelConnectionStringssection (theDefaultsource) plus the namedDataSourcesentries. Two members are engine-scoped:SqlServerMigrationsAssemblyis null for non-SQL-Server engines and lets each SQL database own its own EF migration history (PhysicalDataSource.cs:12-15);CosmosDatabaseNameis ignored for relational engines (PhysicalDataSource.cs:16). Making this arecordgives value equality, so two resolutions of the same source compare equal. - Where it's used: produced by
DataSourceResolver(DataSourceResolver.cs:156-160for the Default source andDataSourceResolver.cs:200-204for named ones) and handed back byGetPhysical(DataSourceResolver.cs:63); consumed downstream byPhysicalDbContextFactoryto open a context against the right database with the right migrations assembly.
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:FrozenDictionary<string, (DataSourceKey Key, Type ConfigurationType)> EntitiesandFrozenSet<Assembly> ScannedAssemblies(EntityDataSourceRegistry.cs:25-27). - Depends on:
DataSourceKey;System.Collections.Frozen,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 holdsprivate volatile Snapshot? _snapshot(EntityDataSourceRegistry.cs:30) and reads it without a lock, relying onvolatilefor the store/load barrier so every thread sees a consistent reference. Writes (the initial build and any rescan) takeLock _rebuildLock(EntityDataSourceRegistry.cs:29) for mutual exclusion, then atomically swap in a brand-newSnapshot. BecauseFrozenDictionary/FrozenSetare immutable once built, any number of readers share one snapshot with zero synchronization. The second member is the reason the record carries more than a lookup table:ScannedAssembliesrecords which assemblies that snapshot covered, so the registry can tell whether a miss is genuine or just a stale scan. - Walkthrough: the value type carried in
Entitiesis a tuple of the resolvedDataSourceKeyand the configuration type that produced it (EntityDataSourceRegistry.cs:26). The configuration type is not used for routing; it exists so the duplicate-registration failure can name both conflicting configuration classes in its message (EntityDataSourceRegistry.cs:138-141). - Where it's used: exclusively inside
EntityDataSourceRegistry: built byBuildSnapshot(EntityDataSourceRegistry.cs:150-152), swapped inGetOrBuildSnapshot(EntityDataSourceRegistry.cs:87) andRescanIfAssembliesChanged(EntityDataSourceRegistry.cs:102-104). - Caveats / not-in-source: private nested type; it appears in the inventory because private nested types are included.
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 likeOutbox:DatabaseName) to a physicalDataSourceKey, and hands back the resolvedPhysicalDataSourcefor such a key. Two members:ResolveLogical(DataSource engine, string logicalName)(IDataSourceResolver.cs:27) andGetPhysical(DataSourceKey key)(IDataSourceResolver.cs:35). - 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 noDataSourcesconfiguration every logical name resolves toDefault, yielding one DbContext per engine with an identical change tracker, FK constraints, transactions, and EF model as a plain single-database monolith.ResolveLogical's contract (IDataSourceResolver.cs:17-27) spells out the collapse cases: a name with noDataSourcesentry, with no connection string for the engine, or whose connection equals the top-level one falls toDataSourceKey.Default(engine); entries sharing a connection with each other collapse to one physical source named after the alphabetically-first logical name.GetPhysical(IDataSourceResolver.cs:29-35) is the reverse lookup and throws if handed a key that did not come fromResolveLogical. - 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). A single interface owns the rule, so no caller has to reimplement the defaulting logic.
- Where it's used: implemented by
DataSourceResolver; injected intoEntityDataSourceRegistry(EntityDataSourceRegistry.cs:23) to resolve each entity's derived logical name, and into the context factories to open connections.
DataSourceResolver
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.DataSources·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceResolver.cs:13· Level 4 · 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 namedDataSourcesentries, validates migrations-assembly conflicts, and then serves both lookups from in-memory dictionaries. - Depends on:
DataSource,DataSourceKey,PhysicalDataSource,IDataSourceResolver,IConnectionStringSettings,DataSourcesSettings,DataSourceEntrySettings;ILogger<T>. - 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 onIDataSourceResolver. Two guardrails are worth calling out. First, conflictingSQLServerMigrationsAssemblydeclarations on logical names that collapse to the same physical database throw at startup (DataSourceResolver.cs:243-245), a loud fail-fast rather than a silent pick. Second,[Rubric §13, Observability & Operability]shows in the source-generated[LoggerMessage]LogMigrationsAssemblyFallback(DataSourceResolver.cs:278-279), which warns when a named SQL Server source has no dedicated migrations assembly and falls back to another database's, because that snapshot describes a different schema. - Walkthrough
- Constructor (
DataSourceResolver.cs:33-45): validates its two settings arguments, then loops all three engines (AllEngines= CosmosDB, Sqlite, SQLServer,DataSourceResolver.cs:15) callingBuildEngineMap. State lives in two dictionaries:_logicalToPhysicalkeyed by(engine, logical name)(DataSourceResolver.cs:18) and_physicalSourceskeyed byDataSourceKey(DataSourceResolver.cs:21). BuildEngineMap(DataSourceResolver.cs:75):ClassifyEntriessplits the engine's named entries into "collapsed onto Default" and "grouped by connection identity" (DataSourceResolver.cs:81);RegisterDefaultSourcethenRegisterNamedSourcepopulate the two dictionaries.ClassifyEntries(DataSourceResolver.cs:94): computes a per-connection identity string viaGetIdentity(DataSourceResolver.cs:257-260), where Cosmos identities append the database name because one account hosts many databases, relational engines use the connection string alone, and 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:107-112) becauseResolveLogicalalready defaults on a map miss.RegisterDefaultSource(DataSourceResolver.cs:141): registers theDefaultkey for the engine, letting entries that collapsed onto it contribute an explicit migrations assembly (DataSourceResolver.cs:148-154), and maps each collapsed logical name onto that key (DataSourceResolver.cs:162-165).RegisterNamedSource(DataSourceResolver.cs:172): names the physical key after the alphabetically-first member (Order(...).First(),DataSourceResolver.cs:178) so routing is deterministic regardless of config key order, then warns and falls back when a SQL Server source declares no migrations assembly of its own (DataSourceResolver.cs:185-192).ResolveLogical(DataSourceResolver.cs:48): aDefault-name short-circuit (DataSourceResolver.cs:52-55) then a dictionary lookup; a miss returnsDataSourceKey.Default(engine)(DataSourceResolver.cs:59), the monolith default.GetPhysical(DataSourceResolver.cs:63): a_physicalSourceslookup that throws with an actionable message if the key was not produced byResolveLogical(DataSourceResolver.cs:65-68).ResolveMigrationsAssembly(DataSourceResolver.cs:229): returns null for non-SQL-Server engines or when no explicit value exists (DataSourceResolver.cs:234-237), and throws when logical names sharing a database declare conflicting assemblies (DataSourceResolver.cs:239-246).
- Constructor (
- 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 config orderings (ADR-006).
- Where it's used: registered as the singleton
IDataSourceResolver; consumed byEntityDataSourceRegistry, the context factories, and the design-time helper.
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,Snapshot(nested);System.Reflection,System.Collections.Frozen,Lock(BCL). - Concept introduced, eager entity-to-data-source mapping.
[Rubric §8, Data Architecture](ADR-006: an entity lives in exactly one database). The doc comment (EntityDataSourceRegistry.cs:8-20) explains the design: the map is derived from configuration classes, not from EF model metadata, 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
_rebuildLock(EntityDataSourceRegistry.cs:29) and thevolatile _snapshot(EntityDataSourceRegistry.cs:30) implement the pattern taught underSnapshot: reads are lock-free, rebuilds are serialized and swap in a new immutable snapshot.GetDataSourceKey(Type)(EntityDataSourceRegistry.cs:33) null-guards and forwards to the string overload viaFullName;GetDataSourceKey(string)(EntityDataSourceRegistry.cs:40) isTryGetDataSourceKeyplus a throw whose message names the exact remedy: add anEntityTypeConfigurationSQLServer/Cosmos/Sqlitefor the entity in a discovered configuration assembly (EntityDataSourceRegistry.cs:43-46).TryGetDataSourceKey(EntityDataSourceRegistry.cs:49): probes the current snapshot; on a miss it callsRescanIfAssembliesChangedonce (EntityDataSourceRegistry.cs:62) and retries. This two-step check keeps the common hit path off the lock.GetPhysicalSourcesInUse(EntityDataSourceRegistry.cs:74): projects the snapshot's values to distinct keys, which is how migrations,EnsureCreated, and outbox draining enumerate this host's databases.GetOrBuildSnapshot(EntityDataSourceRegistry.cs:77) double-checks_snapshotand builds under the lock;RescanIfAssembliesChanged(EntityDataSourceRegistry.cs:91) rebuilds only when the provider reports assemblies not already inScannedAssemblies(EntityDataSourceRegistry.cs:96-100).BuildSnapshot(EntityDataSourceRegistry.cs:108): for every loadable type in every configuration assembly, it skips abstract and open-generic types (EntityDataSourceRegistry.cs:115-118), finds the closedIEntityTypeConfigurationBase<,>interface (EntityDataSourceRegistry.cs:120-125), takes the entity type from the first generic argument (EntityDataSourceRegistry.cs:127), and callsDeriveDataSourceKey. A second configuration registering the same entity against a different key throws with a message naming both configuration classes and both keys (EntityDataSourceRegistry.cs:134-145); an agreeing duplicate is simply ignored.DeriveDataSourceKey(EntityDataSourceRegistry.cs:164): reads the engine fromUseDataSourceAttributeand returns null when it is absent (EntityDataSourceRegistry.cs:166-170), 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[UseDatabase]thenNamespaceConventions.GetModuleNamethenDataSourceKey.DefaultName(EntityDataSourceRegistry.cs:172-174), and delegates the collapse toIDataSourceResolver.ResolveLogical(EntityDataSourceRegistry.cs:176).GetLoadableTypes(EntityDataSourceRegistry.cs:182): wrapsassembly.GetTypes()and toleratesReflectionTypeLoadExceptionby keeping the types that did load (EntityDataSourceRegistry.cs:187-191), mirroring module discovery, so a partially-loaded assembly does not abort the 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 in a multi-database system where the alternative is a silent wrong-database read.
- Where it's used: registered as the singleton
IEntityDataSourceRegistry; consumed byCrossDataSourceDegradeConvention,DataSourceService, and the outbox/migrations enumeration.
AuditSaveChangesInterceptor
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Interceptors·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:13· Level 6 · class (sealed)
- What it is: an EF Core
SaveChangesInterceptorthat automatically stampsCreatedOn/ByandLastModifiedOn/Byon everyIAuditableEntityentry before the database write (AuditSaveChangesInterceptor.cs:8-13). - Depends on:
ApplicationDbContext,IAuditableEntity;SaveChangesInterceptor(EF Core),TimeProvider(BCL). - Concept introduced, the EF Core
SaveChangesInterceptoras a cross-cutting hook.[Rubric §10, Cross-Cutting Concerns]assesses whether audit and similar concerns are wired centrally rather than repeated per handler. An interceptor is a class EF calls at defined points in the save pipeline (SavingChangesbefore the write,SavedChangesafter). Using an interceptor rather than overridingSaveChangesAsyncmeans the logic runs for both the sync and async save paths, multiple interceptors compose through EF's own pipeline, and the concern lives in one class.[Rubric §8, Data Architecture](audit stamped centrally, not per operation) and[Rubric §30, Compliance/Privacy/Data Governance](a consistent audit trail supports accountability) also apply. - Walkthrough
SavingChangesAsync(AuditSaveChangesInterceptor.cs:16) andSavingChanges(AuditSaveChangesInterceptor.cs:28): both callStampAuditFieldswhen the context is anApplicationDbContext, then delegate to base. Stamping in the saving phase is what puts the values into the same SQL statement as the entity change.StampAuditFields(AuditSaveChangesInterceptor.cs:38): readstimeProvider.GetUtcNow().UtcDateTimeonce (AuditSaveChangesInterceptor.cs:40) so every entity in one save shares a timestamp, and reads the context'sCurrentSaveUserId ?? default(AuditSaveChangesInterceptor.cs:41), the per-save user handed in byApplicationDbContext(ApplicationDbContext.cs:69). It then walksChangeTracker.Entries<IAuditableEntity>().- Added (
AuditSaveChangesInterceptor.cs:47-52): stamps all four fields from the resolved user id and timestamp; a null current user resolves todefault, the sentinel for system-generated rows. - Modified (
AuditSaveChangesInterceptor.cs:53-58): stamps onlyLastModifiedBy/LastModifiedOn, and marksCreatedBy/CreatedOnasIsModified = false(AuditSaveChangesInterceptor.cs:54-55) so an update can never overwrite the creation fields. That is the load-bearing invariant of this class. - Detached / Unchanged / Deleted (
AuditSaveChangesInterceptor.cs:59-63): no-op. Soft delete is a domain concern (the entity setsIsDeleted = true, which lands it in theModifiedbranch), not something this interceptor special-cases.
- Added (
- Why it's built this way: centralizing audit in an interceptor guarantees no handler can forget the stamps; injecting
TimeProviderrather than readingDateTime.UtcNowmakes the stamps deterministic under test ([Rubric §14, Testability]). - Where it's used: registered as a singleton in
AddInfrastructure(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:52) and added to every context's options alongsideDomainEventSaveChangesInterceptorinApplicationDbContext.OnConfiguring(ApplicationDbContext.cs:124-126); the design-time helper registers its own copy (Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:68).
DeferredDispatch
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Interceptors·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:275· Level 6 · record (sealed, private nested)
- What it is: a two-member private record pairing a
CapturedStatewith theDomainEventSaveChangesInterceptorinstance that captured it, representing one unit of post-save work parked until the surrounding transaction commits (DomainEventSaveChangesInterceptor.cs:274-275). - Depends on:
DomainEventSaveChangesInterceptor,CapturedState. - Concept introduced, carrying the owner so a static entry point can flush instance work.
[Rubric §1, SOLID](dependency direction) and[Rubric §6, CQRS & Event-Driven](delivery ordered after commit) both apply. The deferred list is stored in a staticConditionalWeakTable<DbContext, List<DeferredDispatch>>(DomainEventSaveChangesInterceptor.cs:55) and drained by the staticFlushDeferredAsync, whichDbContextFactorycalls after a successful commit. Flushing needs the interceptor's injected dispatcher and outbox signal, which a static method does not have. Rather than give the factory a DI edge back to the interceptor, each entry carries its ownOwnerand the flush loop callsdispatch.Owner.FlushStateAsync(...)(DomainEventSaveChangesInterceptor.cs:127-128). The comment atDomainEventSaveChangesInterceptor.cs:50-54states exactly that intent. - Walkthrough: created in
DispatchAndFinalizeAsyncwhencontext.Database.CurrentTransaction is not null(DomainEventSaveChangesInterceptor.cs:208-214), appended to the per-context list viaDeferredTable.GetOrCreateValue(context).Add(...)(DomainEventSaveChangesInterceptor.cs:213); consumed inFlushDeferredAsync(DomainEventSaveChangesInterceptor.cs:120-129) or discarded wholesale byDropDeferred(DomainEventSaveChangesInterceptor.cs:137). It is aListrather than a single entry because several saves can occur inside one transaction. - Where it's used: only inside
DomainEventSaveChangesInterceptor; the flush and drop entry points are called byDbContextFactory(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:329on commit,DbContextFactory.cs:275andDbContextFactory.cs:347on rollback and abort). - Caveats / not-in-source: private nested type; it surfaces in the inventory only because private nested types are included.
DomainEventSaveChangesInterceptor
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Interceptors·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:38· Level 6 · class (sealed, partial)
- What it is: the EF Core interceptor that implements the producer end of the transactional outbox. Before the write it captures domain events from aggregate roots and serializes each to an
OutboxMessagerow in the same transaction; after the write it routes them, dispatching local events in process and leaving integration-event rows for theOutboxProcessorto publish; and when a transaction is open it defers all of that until after commit. - Depends on:
ApplicationDbContext,CapturedStateandDeferredDispatch(nested),IAggregateRoot,IDomainEvent,IIntegrationEvent,IDomainEventDispatcher,IOutboxSignal,OutboxMessage,OutboxFinalizer;SaveChangesInterceptor,ConditionalWeakTable,ILogger<T>(EF Core / BCL). - Concept introduced, the routed transactional outbox (ADR-003).
[Rubric §6, CQRS & Event-Driven](state changes announced as events) and[Rubric §29, Resilience & Business Continuity](at-least-once delivery). There are three rules layered on top of each other, all stated in the class doc comment (DomainEventSaveChangesInterceptor.cs:12-34):- Transactional persistence. Every captured event becomes an
OutboxMessageadded to the context beforebase.SaveChangesAsync, so the rows commit in the same database transaction as the aggregate change. A crash between the write and the dispatch cannot lose an event. - Routing by event kind. Local domain events get an outbox row and an in-process dispatch, after which their rows are marked processed. Events implementing
IIntegrationEventget a row and no in-process dispatch: their rows stay unprocessed so theOutboxProcessorpublishes them throughIMessageBus, letting the registered transport (in process for the monolith, broker for an extracted service) decide delivery. The comment records why this matters (DomainEventSaveChangesInterceptor.cs:22-24): before this routing existed,AddDomainEvent(integrationEvent)dispatched locally and marked the row processed, so the event silently never reached the wire. - Deferral under a transaction. When the save runs inside the Transactional decorator's transaction, all post-save work is parked as a
DeferredDispatchand flushed byDbContextFactoryonly after a successful commit (DomainEventSaveChangesInterceptor.cs:26-33). That keeps handler side effects (email, cache writes, pushes) from acting on state that may still roll back, and keeps EF execution-strategy retries from dispatching the same events once per attempt.
- Transactional persistence. Every captured event becomes an
- Walkthrough
- Two
ConditionalWeakTables.StateTable(DomainEventSaveChangesInterceptor.cs:48) associates the per-saveCapturedStatewith the context instance;DeferredTable(DomainEventSaveChangesInterceptor.cs:55) holds the parkedDeferredDispatchlist. Weak tables mean neither structure keeps a context alive, so state cleans up automatically on disposal (DomainEventSaveChangesInterceptor.cs:43-47). SavingChangesAsync/SavingChanges(DomainEventSaveChangesInterceptor.cs:58,DomainEventSaveChangesInterceptor.cs:70): both callCaptureEventsAndPersistToOutboxwhen the context is anApplicationDbContext; capture must happen before the SQL write, in the same unit of work.CaptureEventsAndPersistToOutbox(DomainEventSaveChangesInterceptor.cs:143): collects trackedIAggregateRootentries carrying pending events (DomainEventSaveChangesInterceptor.cs:145-150) and flattens their events. Whencontext.SupportsOutbox(DomainEventSaveChangesInterceptor.cs:160) it builds one row per event viaOutboxMessage.FromDomainEvent, adds it to the set, and sorts the event into the integration bucket (flag only) or the local bucket (event plus its row) (DomainEventSaveChangesInterceptor.cs:165-184). TheAddis intentionally the synchronousDbSetcall; theVSTHRD103pragma (DomainEventSaveChangesInterceptor.cs:169-171) records thatAddAsyncexists only for special value generators. When the context has no outbox table (Cosmos overridesSupportsOutboxto false,Persistence/DbContexts/CosmosDbContext.cs:69) nothing can carry an integration event to the bus, so the legacy behavior of dispatching everything in process is kept (DomainEventSaveChangesInterceptor.cs:186-191). The result is stored as aCapturedState(DomainEventSaveChangesInterceptor.cs:193-194).SavedChangesAsync(DomainEventSaveChangesInterceptor.cs:81): the post-commit entry point, callsDispatchAndFinalizeAsync.SavedChanges(the synchronous path,DomainEventSaveChangesInterceptor.cs:100): cannot await the dispatcher, so it relies entirely on the outbox. For outbox-capable contexts it removes the state, clears the aggregates' events (preventing the duplicate re-capture a later async save used to produce) and signals the processor if there is anything pending (DomainEventSaveChangesInterceptor.cs:102-110). Contexts without outbox support keep the legacy no-op so a later async save can still deliver their events (DomainEventSaveChangesInterceptor.cs:93-99).DispatchAndFinalizeAsync(DomainEventSaveChangesInterceptor.cs:201): pulls and removes the state, then branches oncontext.Database.CurrentTransaction. With a transaction open it clears the aggregates' events now (so a second save inside the same transaction cannot re-capture them,DomainEventSaveChangesInterceptor.cs:210-212), parks aDeferredDispatch, and returns. Otherwise it flushes immediately.FlushStateAsync(DomainEventSaveChangesInterceptor.cs:224): dispatches local events throughIDomainEventDispatcher(DomainEventSaveChangesInterceptor.cs:228-229), clears the aggregates' events, marks the local rows processed viaOutboxFinalizer(DomainEventSaveChangesInterceptor.cs:233), and signals the processor when integration events are waiting (DomainEventSaveChangesInterceptor.cs:235-236). Acatchlogs and signals so the unprocessed rows get picked up (DomainEventSaveChangesInterceptor.cs:238-246), and afinallyclears events idempotently (DomainEventSaveChangesInterceptor.cs:247-251) so a dispatch failure never leaves stale events on an aggregate.FlushDeferredAsync/DropDeferred(DomainEventSaveChangesInterceptor.cs:120,DomainEventSaveChangesInterceptor.cs:137): theinternal staticpairDbContextFactorycalls after commit and on rollback. The flush comment notes a missed flush is safe (DomainEventSaveChangesInterceptor.cs:117-118): the rows are still there unprocessed and the outbox delivers them.LogDispatchError(DomainEventSaveChangesInterceptor.cs:260-261): a source-generated[LoggerMessage]warning, which is why the class ispartial([Rubric §13, Observability & Operability]).
- Two
- Why it's built this way: ADR-003 requires at-least-once delivery, so the durable row is written in the same transaction and the in-process dispatch is only a fast path that avoids a round trip. Splitting integration events out of that fast path is what makes the same
AddDomainEventcall correct in both the monolith and an extracted service (ADR-007). Deferring until after commit resolves the one remaining ordering hazard, a handler observing state that later rolls back. - Where it's used: registered as a singleton in
AddInfrastructure(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:53, stateless because per-save state lives in the weak tables, per the comment atDependencyInjection.cs:50-51) and added to every context's options alongsideAuditSaveChangesInterceptorinApplicationDbContext.OnConfiguring(ApplicationDbContext.cs:124-126); its deferred work is driven byDbContextFactoryand its rows are drained by theOutboxProcessor.
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
- What it is: the base marker interface for every EF Core entity type configuration in the framework. It extends EF Core's own
IEntityTypeConfiguration<TEntity>and redeclaresConfigurewithnewso the provider-specific sub-interfaces can restate the same signature. - Depends on:
AuditableBaseEntity<TIdentifierType>(the entity constraint), and EF Core'sIEntityTypeConfiguration<TEntity>/EntityTypeBuilder<TEntity>(NuGet). - Concept introduced: the provider-split configuration hierarchy.
[Rubric §8: Data Architecture]assesses whether each entity is deliberately bound to a physical store rather than mapped ad hoc; this interface is the root of a family that lets one configuration class be discovered per engine and routed to the right database.[Rubric §1: SOLID](ISP): the interface adds nothing but a redeclared method, so provider sub-interfaces (IEntityTypeConfigurationSQLServer,IEntityTypeConfigurationSqlite,IEntityTypeConfigurationCosmos) can specialize discovery without carrying members they do not need. - Walkthrough: the two type constraints (lines 15-16) pin
TEntitytoAuditableBaseEntity<TIdentifierType>andTIdentifierTypetonotnull; the singlenew void Configure(EntityTypeBuilder<TEntity> builder)(line 18) is the redeclaration that gives EF an unambiguous method to invoke through the provider interfaces. - Why it's built this way:
ApplicationDbContext.ApplyConfigurationsForEntitiesInContextdiscovers configurations by the specific provider interface, so a SQL Server configuration is never applied to a Cosmos model pass. Thenewredeclaration is the mechanism that keeps that discovery type-safe. - Where it's used: implemented transitively by every concrete
EntityTypeConfiguration*class in both apps, and scanned byEntityDataSourceRegistryto enumerate configurable entities.
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 (abstract)
- What it is: the abstract base class every entity configuration inherits. Its
Configuredoes exactly one cross-cutting thing: for aggregate roots it tells EF to ignore the in-memoryDomainEventscollection so the change tracker never tries to persist it. - Depends on:
IEntityTypeConfigurationBase<TEntity, TIdentifierType>(implements it),AuditableBaseEntity<TIdentifierType>,AuditableAggregateRootEntity<TIdentifierType>,IAggregateRoot, and EF Core'sEntityTypeBuilder<TEntity>. - Concept: the single, deliberate crossing of the domain boundary by infrastructure.
[Rubric §3: Clean Architecture]assesses whether infrastructure references to the domain are minimal and centralized; here the only domain fact Infrastructure needs is that aggregate roots carry a non-persisted event list, and it acts on that in one place. - Walkthrough:
Configure(lines 25-33): theif (typeof(IAggregateRoot).IsAssignableFrom(typeof(TEntity)))guard (line 29) runsbuilder.Ignore(nameof(AuditableAggregateRootEntity<>.DomainEvents))(line 31) only for roots; child entities have no such collection and skip it. The class doc comment (lines 11-15) records that entity-to-data-source routing was previously a model-building side effect here and has since moved toEntityDataSourceRegistry, which derives it eagerly from attributes. - Why it's built this way:
DomainEventsis dispatch-only state; excluding it once at the base means every concrete configuration inherits the exclusion for free rather than repeating it. - Where it's used: the direct base of the engine-aware
EntityTypeConfiguration<TEntity, TIdentifierType>, and thus an ancestor of every per-entity configuration in ADC and Store.
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 internal marker interface that identifies a configuration as targeting Azure Cosmos DB. Structurally identical to its SQL Server and SQLite siblings; only the marker type differs.
- Depends on:
IEntityTypeConfigurationBase<TEntity, TIdentifierType>(extends it),AuditableBaseEntity<TIdentifierType>. - Concept: the provider-split pattern introduced under
IEntityTypeConfigurationBase. Theinternalmodifier (line 13) is deliberate: consumers never implement this marker directly. They derive from the publicEntityTypeConfigurationCosmosbase, and the framework attaches the marker for them. - Walkthrough: the redeclared
new void Configure(...)(line 17) mirrors the base; the interface adds no members of its own.ApplicationDbContext.ApplyConfigurationsForEntitiesInContextfilters on this type when building the Cosmos model pass, so only Cosmos configurations reach a Cosmos context. - Where it's used: discovered by
EntityDataSourceRegistryand the Cosmos model pass; implemented (via the shim base) by any entity routed toDataSource.CosmosDB.
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 SQLite counterpart of the provider marker interface. Same shape as the Cosmos and SQL Server markers; different marker type.
- Depends on:
IEntityTypeConfigurationBase<TEntity, TIdentifierType>,AuditableBaseEntity<TIdentifierType>. - Concept: see the provider-split pattern under
IEntityTypeConfigurationBase.internal(line 13) for the same reason as its siblings: consumers implement it only through the publicEntityTypeConfigurationSqlitebase. - Walkthrough: the
new void Configure(...)redeclaration (line 17) is the entire body. SQLite is used mainly in integration tests and in-memory scenarios where a schema-less relational store is convenient. - Where it's used: discovered by
EntityDataSourceRegistryand applied only to a SQLite context.
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 SQL Server marker interface, the production default engine for both apps. Same structure as the Cosmos and SQLite markers.
- Depends on:
IEntityTypeConfigurationBase<TEntity, TIdentifierType>,AuditableBaseEntity<TIdentifierType>. - Concept: the provider-split pattern from
IEntityTypeConfigurationBase.internal(line 13) keeps it out of the public API; consumers derive from the publicEntityTypeConfigurationSQLServerbase and the framework supplies the marker. - Walkthrough:
new void Configure(...)(line 17) is the only member. The SQL Server model pass filters on this type so SQL Server configurations apply only to SQL Server contexts. - Where it's used: discovered by
EntityDataSourceRegistry; the default routing for every entity that does not opt into another engine.
EFReadRepository<TEntity, TIdentifierType>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Repositories·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:15· Level 6 · class (internal)
- What it is: the EF Core implementation of
IReadRepository<TEntity, TIdentifierType>: the full read surface (get all, get by id, get by ids, count, exists, lookup projection) with no mutation. It is the query half of the repository family. - Depends on:
IReadRepository<TEntity, TIdentifierType>(implements it),AuditableBaseEntity<TIdentifierType>,BaseLookup<TIdentifierType>, and EF Core'sDbContext/DbSet<TEntity>plusConcurrentDictionaryfrom the BCL. - Concept introduced: read-side query hygiene and N+1 avoidance.
[Rubric §12: Performance & Scalability]assesses deliberate query shaping, and this class makes several such choices visible:- Tracking control:
Table(line 239) is tracked,TableNoTracking(line 242) callsAsNoTracking(), and the get methods defaultasTracking: falseso read paths do not pay change-tracker cost. - Split-query heuristic:
ApplyIncludes(lines 256-272) opts a query intoAsSplitQuery()the moment any include targets a collection navigation, avoiding the cartesian row explosion EF's default single-query JOIN would cause. Whether a path is a collection navigation is decided by a reflection walk cached per path inCollectionIncludeCache(lines 278-299), so the reflection runs once per distinct include string. - Cached projection trees:
GetAllForLookupAsync(line 74) builds aSelectexpression mappingIdand a named property toBaseLookup<TIdentifierType>; the expression is built once per(EntityType, PropertyName)pair viaGetOrBuildLookupSelector(lines 104-126) and stored inLookupSelectorCache(line 99), so repeated lookups pay only a dictionary hit. CountAsyncoverAnyAsync:ExistsAsync(lines 201-236) usesCountAsync(predicate) > 0; the comment at lines 196-200 documents that this is a workaround for a Cosmos provider bug that emits invalid SQL forAnyAsyncwith a predicate.
- Tracking control:
- Walkthrough:
_context(line 21) and theEntitiesaccessor (line 23,_context.Set<TEntity>()) are the starting point for every query.GetAllAsync(lines 26-54) composes tracking,IgnoreQueryFilters, includes,where,orderBy, and an optionalselectprojection.GetByIdAsync(line 154) usesFindAsyncfor the identity-map fast path; the includes overload (line 164) falls back toFirstOrDefaultAsync. The fourTable*properties (lines 239-248) hand callers explicit control over tracking and single-vs-split query strategy. - Why it's built this way: the class is
internalandvirtualthroughout, so the public contract is the interface and the profiling decorator can override members. Consumers never new it up directly; the factory does. - Where it's used: created by
RepositoryFactoryfor every read repository resolution, optionally wrapped byEFReadRepositoryDecorator; it is also the base class of the read-writeEFRepository.
EFReadRepositoryDecorator<TEntity, TIdentifierType>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Repositories·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepositoryDecorator.cs:15· Level 6 · class (internal)
- What it is: a decorator that wraps every
IReadRepositoryoperation in a MiniProfiler timing step, adding per-call timing visibility without touching the query logic. - Depends on:
IReadRepository<TEntity, TIdentifierType>,AuditableBaseEntity<TIdentifierType>,BaseLookup<TIdentifierType>, andProfilingHelper. - Concept: the Decorator pattern applied to the repository layer for observability, the same pattern that drives the CQRS decorator pipeline.
[Rubric §2: Design Patterns]assesses whether cross-cutting behavior is layered by composition rather than baked into the core;[Rubric §13: Observability]assesses timing visibility. - Walkthrough: the
_innerfield (line 21) holds the wrapped repository; every read method delegates throughProfilingHelper.ProfileAsync(ClassName, nameof(Method), () => _inner.Method(...))(for example line 31), withClassNamefixed to"EFReadRepository"(line 20). The fourTable*queryable properties (lines 83-86) pass straight through to_innerwith no wrapping, since they return deferredIQueryableand there is nothing to time. - Where it's used: applied by
RepositoryFactoryas the outer layer overEFReadRepositoryonly whenUseMiniProfileris enabled.
EntityTypeConfiguration<TEntity, TIdentifierType>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:29· Level 6 · class (abstract)
- What it is: the engine-aware configuration base that holds all the portable mapping logic. It reads a single
[UseDataSource(...)]attribute to learn its target engine and then applies the matching table/container mapping and key-generation conventions. Moving an entity between engines becomes a one-attribute change with no edits to the configuration body. - Depends on:
EntityTypeConfigurationBase<TEntity, TIdentifierType>(extends it, so it inherits theDomainEventsexclusion), all three provider markers (SQLServer, Sqlite, Cosmos),UseDataSourceAttribute,DataSource,NamespaceConventions,EntityTypeExtensions(IsIdValueGenerated),CosmosIntIdValueGenerator, and EF Core. - Concept introduced: attribute-driven, engine-portable EF configuration.
[Rubric §8: Data Architecture]assesses systematic, convention-driven schema mapping instead of per-entity boilerplate, and this class is where that convention lives. Because it implements all three provider marker interfaces (lines 30-33), it is discovered for every engine's model pass;ApplicationDbContext.ApplyConfigurationsForEntitiesInContextthen applies it only to the model whose physical source the entity actually routes to (driven by the same[UseDataSource]), so discovery and routing agree by construction.[Rubric §15: Best Practices]: naming, schema derivation, and key strategy are computed once here, not copy-pasted into every entity. - Walkthrough
Configure(lines 38-50): callsbase.Configure(theDomainEventsexclusion), then readsGetType().GetCustomAttribute<UseDataSourceAttribute>()?.DataSource(line 44) and throws an actionableInvalidOperationExceptionif the attribute is absent, so a misconfigured class fails loudly at model build.ApplyEngineConventions(lines 58-100): aprotected statichelper (shared by the shim bases) that switches on the engine. It first readstypeof(TEntity).IsIdValueGenerated(line 62), theEntityTypeExtensionsmember backed by[IdValueGenerated].- SQL Server (lines 66-73):
ToTable(typeof(TEntity).Name, NamespaceConventions.GetModuleName(...) ?? "dbo")maps the entity to a table in its module schema;HasKey(p => p.Id); thenValueGeneratedOnAdd()orValueGeneratedNever()depending onisIdValueGenerated. - SQLite (lines 75-82): same but with no schema (
ToTable(typeof(TEntity).Name)) andUseIdentityColumn(1, 1)for generated ids. - Cosmos (lines 84-95): maps every entity in a module to one container (module name, or the entity name as fallback) with
HasPartitionKey(p => p.Id), since Cosmos requires related documents to share a container for navigations to work; generated ids use a client-sideHasValueGenerator<CosmosIntIdValueGenerator>()because Cosmos has no server-side identity. - The
defaultcase (lines 97-98) throws for any unimplemented engine.
- Why it's built this way: folding the per-engine mechanics into one base (rather than duplicating them across three provider classes) is the reason the framework can strip relational-only constructs and degrade cross-source relationships automatically while keeping the same configuration body portable (see ADR-006, database-per-service, and
CrossDataSourceDegradeConvention). - Where it's used: the direct base of the three engine shims (SQLServer, Sqlite, Cosmos); concrete per-entity configurations in both apps derive from one of those shims.
ReadRepositoryExtensions
MMCA.Common.Application ·
MMCA.Common.Application.Extensions·MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ReadRepositoryExtensions.cs:10· Level 6 · class (static)
- What it is: adds a
GetByIdOrFailAsyncextension member toIReadRepository<TEntity, TIdentifierType>, turning the null-returning lookup into aResult<TEntity>that fails withError.NotFoundwhen the entity is absent. - Depends on:
IReadRepository<TEntity, TIdentifierType>,AuditableBaseEntity<TIdentifierType>,Result,Error. - Concept: C#
extension(T)members (see primer) applied to an infrastructure abstraction from the Application layer.[Rubric §15: Best Practices]: it removes the load-then-null-check-then-404 boilerplate otherwise repeated in every command handler. - Walkthrough: the
extension<TEntity, TIdentifierType>(IReadRepository<...> repository)block (line 12) hostsGetByIdOrFailAsync(lines 27-48). It callsGetAllAsyncwith awhere: e => e.Id.Equals(id)predicate (rather thanGetByIdAsync) so the call still flows through the fullincludespipeline (lines 34-38), takesFirstOrDefault, and onnullreturnsResult.Failure(Error.NotFound.WithSource(source).WithTarget(typeof(TEntity).Name))(lines 43-44), stamping both the caller source and the entity type onto the error. - Where it's used: called by command handlers across the modules whenever they need to load an entity or fail with a typed not-found error.
EFRepository<TEntity, TIdentifierType>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Repositories·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:14· Level 7 · class (internal sealed)
- What it is: the concrete read-write repository. It extends
EFReadRepository(inheriting the entire query surface) and adds mutation:AddAsync,AddRangeAsync,UpdateAsync,UpdateRange,SetOriginalRowVersion,ExecuteDeleteAsync,Save,SaveChangesAsync. - Depends on:
EFReadRepository<TEntity, TIdentifierType>(base),IRepository<TEntity, TIdentifierType>(implements it),AuditableBaseEntity<TIdentifierType>, EF Core'sDbContext. - Concept introduced: safe update of possibly-tracked entities and defensive context hygiene.
[Rubric §8: Data Architecture]assesses correct EF usage that avoids duplicate-tracking and broken-context traps.UpdateAsync(lines 43-61) handles the common disconnected-entity trap: callingDbSet.Updateon an entity whose key is already tracked throws. The fix (lines 50-54) does an O(1) identity-map lookup viaEntities.Local.FindEntry(entity.Id)(never a database round-trip) and, when the entity is already tracked, patches it in place withtrackedEntry.CurrentValues.SetValues(entity); only otherwise does it callEntities.Update(entity). OnDbUpdateException,GetFullErrorTextAndRollbackEntityChanges(lines 103-129) resets all Added/Modified entries toUnchangedand persists that reset so the context is left usable, then rethrows with the full error text. - Walkthrough
AddAsync/AddRangeAsync(lines 21-32): thin wrappers overEntities.AddAsync/AddRangeAsync.UpdateRange(lines 64-68): bulkEntities.UpdateRange, with no local-tracking check (the caller owns consistency).SetOriginalRowVersion(lines 71-80): plants the client's last-known token as_context.Entry(entity).Property(nameof(AuditableBaseEntity<>.RowVersion)).OriginalValue, so EF's optimistic-concurrency check compares it against the stored value on the next save; no-ops when the row version is null or empty.ExecuteDeleteAsync(lines 83-89): EF bulk delete (Where(where).ExecuteDeleteAsync) that bypasses the change tracker, used for hard purges (outbox pruning, anonymized data).Save/SaveChangesAsync(lines 92-96): delegate to the context; used by callers that operate outside the unit of work.
- Why it's built this way:
internal sealedkeeps the implementation off the public API; callers only ever seeIRepository. The tracked-entity detection avoids an EF anti-pattern that commonly bites teams attaching request-scoped disconnected entities to a live context. - Where it's used: instantiated by
RepositoryFactory.Createand reached throughIRepositoryby command handlers.
EFRepositoryDecorator<TEntity, TIdentifierType>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Repositories·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepositoryDecorator.cs:13· Level 7 · class (internal sealed)
- What it is: the MiniProfiler timing decorator for the read-write
IRepository. It extendsEFReadRepositoryDecorator(inheriting the profiled read methods) and adds profiled write methods. - Depends on:
EFReadRepositoryDecorator<TEntity, TIdentifierType>(base),IRepository<TEntity, TIdentifierType>,AuditableBaseEntity<TIdentifierType>,ProfilingHelper. - Concept: the Decorator pattern from
EFReadRepositoryDecorator, extended to write operations.[Rubric §13: Observability]assesses granular timing of persistence. - Walkthrough: the
_innerfield (line 20) holds the wrappedIRepository; each mutation wraps_innerinProfilingHelper.ProfileAsync/Profile/ aBeginStepscope (for the synchronousUpdateRange, lines 34-38). The one deliberate exception isSetOriginalRowVersion(lines 40-41), which passes straight through unprofiled because it is an in-memory metadata operation with no I/O worth timing. - Where it's used: applied by
RepositoryFactory.CreateoverEFRepositorywhenUseMiniProfileris enabled.
EntityTypeConfigurationCosmos<TEntity, TIdentifierType>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationCosmos.cs:19· Level 7 · class (abstract)
- What it is: a thin shim base that fixes the engine to
DataSource.CosmosDB. It carries no mapping logic of its own; all of it lives in the engine-awareEntityTypeConfiguration. - Depends on:
EntityTypeConfiguration<TEntity, TIdentifierType>(extends it),UseDataSourceAttribute,DataSource. - Concept: the shim exists purely as ergonomics: deriving from it is equivalent to deriving from
EntityTypeConfigurationand annotating the concrete class with[UseDataSource(DataSource.CosmosDB)].[Rubric §15: Best Practices](one clear, discoverable base per engine). - Walkthrough: the entire class is the
[UseDataSource(DataSource.CosmosDB)]attribute (line 18) plus the class declaration (line 19); there is no body. At model build, the engine-aware base reads that attribute and runs the Cosmos branch ofApplyEngineConventions. - Where it's used: base class for any concrete entity configuration routed to Cosmos (a secondary persistence path, not the primary for ADC or Store production).
EntityTypeConfigurationSqlite<TEntity, TIdentifierType>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSqlite.cs:18· Level 7 · class (abstract)
- What it is: the SQLite shim base that fixes the engine to
DataSource.Sqlite. Same shape as the Cosmos and SQL Server shims; the attribute value is the only difference. - Depends on:
EntityTypeConfiguration<TEntity, TIdentifierType>,UseDataSourceAttribute,DataSource. - Concept: see
EntityTypeConfigurationCosmos: deriving from this base equals deriving fromEntityTypeConfigurationand annotating with[UseDataSource(DataSource.Sqlite)]. - Walkthrough:
[UseDataSource(DataSource.Sqlite)](line 17) plus the bodyless class declaration (line 18). The engine-aware base maps a schema-less table and usesUseIdentityColumn(1, 1)for generated keys. - Where it's used: base for SQLite entity configurations, primarily in integration tests and in-memory scenarios.
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:18· Level 7 · class (abstract)
- What it is: the SQL Server shim base that fixes the engine to
DataSource.SQLServer, the production default. Same structure as the Cosmos and SQLite shims. - Depends on:
EntityTypeConfiguration<TEntity, TIdentifierType>,UseDataSourceAttribute,DataSource. - Concept: see
EntityTypeConfigurationCosmos: deriving from this base equals deriving fromEntityTypeConfigurationand annotating with[UseDataSource(DataSource.SQLServer)]. - Walkthrough:
[UseDataSource(DataSource.SQLServer)](line 17) plus the bodyless class declaration (line 18). The engine-aware base derives the table name from the entity class name and the SQL schema from the module namespace (the segment beforeDomain, viaNamespaceConventions), and configures key generation from[IdValueGenerated]. - Where it's used: the base class for the great majority of per-entity configurations in the Conference, Engagement, Identity, and Notification modules across both apps.
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
- What it is: the factory contract for creating repository instances bound to a specific
DbContext, with optional MiniProfiler wrapping folded in. - Depends on:
IRepository<TEntity, TIdentifierType>,IReadRepository<TEntity, TIdentifierType>,AuditableAggregateRootEntity<TIdentifierType>,AuditableBaseEntity<TIdentifierType>, EF Core'sDbContext. - Concept: a factory over
DbContextbecause repositories must bind to a specific context instance (the one chosen by the entity's physical data source), which plain DI cannot express.[Rubric §2: Design Patterns](factory abstraction at the persistence boundary). - Walkthrough: two methods:
Create<TEntity, TIdentifierType>(DbContext)(line 19) constrained toAuditableAggregateRootEntityand returningIRepository, so only aggregate roots get a write repository; andCreateReadOnly<TEntity, TIdentifierType>(DbContext)(line 30) constrained to anyAuditableBaseEntityand returningIReadRepository. The constraint split mirrors the aggregate-root-only write rule from the domain. - Where it's used: implemented by
RepositoryFactoryand consumed by the unit-of-work implementation and the testing infrastructure.
RepositoryFactory
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Repositories.Factory·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:13· Level 8 · class (sealed)
- What it is: the concrete
IRepositoryFactory: it builds anEFRepository/EFReadRepositoryover a suppliedDbContextand, when profiling is enabled, wraps it in the matching MiniProfiler decorator. - Depends on:
IServiceProvider(forActivatorUtilities),IApplicationSettings,AuditableAggregateRootEntity<TIdentifierType>,AuditableBaseEntity<TIdentifierType>. - Concept reinforced: factory plus conditional decorator.
[Rubric §2: Design Patterns]and[Rubric §13: Observability].Create(lines 24-40) andCreateReadOnly(lines 48-64) useActivatorUtilities.CreateInstanceto build the base repository with the chosen context injected, then wrap it in anEFRepositoryDecorator/EFReadRepositoryDecoratoronly when_applicationSettings.UseMiniProfileris true (lines 33, 57). Profiling is therefore a zero-overhead, config-toggled opt-in rather than a compile-time choice. - Why it's built this way: the unit of work needs a repository over a specific context instance (selected by data source), which off-the-shelf DI cannot produce; the factory does, and folds the optional profiling decision into the same point of construction so callers never branch on "is profiling on."
- Where it's used: called by the unit-of-work implementation's
GetRepository/GetReadRepositorywhen a handler asks for a repository over a given physical source.
PushNotificationConfiguration
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.Notifications·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/Notifications/PushNotificationConfiguration.cs:15· Level 8 · internal sealed class
- What it is: the EF Core mapping for the
PushNotificationaggregate root, the broadcast record of a notification sent to a set of recipients. It maps that entity into theNotificationschema and shapes its scalar columns (PushNotificationConfiguration.cs:15). - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>(the engine-fixing base it extends,PushNotificationConfiguration.cs:16); the domain typePushNotificationand itsPushNotificationStatusenum (PushNotificationConfiguration.cs:3); theUseDatabaseAttributeit is annotated with (PushNotificationConfiguration.cs:14); EF Core'sEntityTypeBuilder<TEntity>andIEntityTypeConfiguration<TEntity>machinery (BCL/NuGet,PushNotificationConfiguration.cs:1-2). - Concept introduced, overriding the auto-derived database and schema with
[UseDatabase]. By default this framework derives an entity's physical routing from its namespace: the SQL schema and the logical database name both come from the namespace segment beforeDomain(seeNamespaceConventions,NamespaceConventions.cs:16). That rule works cleanly for a module such asMMCA.Store.Sales.Domain.Orders(schema and databaseSales), but it misfires for framework-owned entities:PushNotificationlives inMMCA.Common.Domain.Notifications.PushNotifications, so the segment beforeDomainisCommonand the entity would land in aCommonschema and aCommondatabase. Two overrides fix that. The class-level[UseDatabase("Notification")](PushNotificationConfiguration.cs:14) replaces the derived logical database name, so hosts that declare aDataSources:Notificationconnection string get a dedicated notification database, and hosts that do not simply collapse these tables onto theDefaultsource (single-database behavior stays intact; seeUseDatabaseAttributeandDataSourceResolver). Thebuilder.ToTable(...)call insideConfigure(PushNotificationConfiguration.cs:24) replaces the derived SQL schema fromCommontoNotification. Together they place the notification tables under their own schema and let them route to their own database when one is configured.[Rubric §8, Data Architecture]assesses how deliberately data is partitioned, keyed, and typed at the storage boundary; here schema isolation and a string-persisted status enum are chosen explicitly rather than left to convention.[Rubric §7, Microservices Readiness]assesses whether a module can be lifted into its own service without a rewrite; routing notifications to a named logical database is exactly the boundary that lets the notification store move to its own physical database (ADR-006) with no code change. - Walkthrough
[UseDatabase("Notification")](PushNotificationConfiguration.cs:14): the class-level attribute read eagerly byEntityDataSourceRegistryto route everyPushNotificationto theNotificationlogical database.- Base class (
PushNotificationConfiguration.cs:16): extendsEntityTypeConfigurationSQLServer<PushNotification, PushNotificationIdentifierType>, a thin shim carrying[UseDataSource(DataSource.SQLServer)]that delegates all mapping logic toEntityTypeConfiguration<TEntity, TIdentifierType>. base.Configure(builder)(PushNotificationConfiguration.cs:21): runs the shared engine-aware pipeline. That base (EntityTypeConfiguration.cs:38) null-checks the builder, calls its own base to exclude the in-memoryDomainEventscollection from mapping for aggregate roots (EntityTypeConfigurationBase.cs:25-33), reads the[UseDataSource]engine, and for SQL Server sets the table name to the entity name, the schema to the derived module name (hereCommon), the primary key, and, becausePushNotificationcarriesIdValueGeneratedAttribute,Property(Id).ValueGeneratedOnAdd()(EntityTypeConfiguration.cs:66-73). Note what this base does not do: it configures no soft-delete filter, audit columns, orRowVersiontoken here; those cross-cutting concerns are stamped and filtered centrally byApplicationDbContext, not in this configuration.builder.ToTable(nameof(PushNotification), "Notification")(PushNotificationConfiguration.cs:24): re-maps the table (still namedPushNotification) into theNotificationschema, overriding theCommonschema the base just derived.Title(PushNotificationConfiguration.cs:26-28): required,HasMaxLength(200).Body(PushNotificationConfiguration.cs:30-32): required,HasMaxLength(2000).SentByUserId(PushNotificationConfiguration.cs:34-35) andRecipientCount(PushNotificationConfiguration.cs:37-38): both required.Status(PushNotificationConfiguration.cs:40-43): required,HasConversion<string>()withHasMaxLength(20). ThePushNotificationStatusenum is persisted as its member name rather than an ordinal integer, so the stored value is self-describing and reordering or inserting enum members never silently corrupts existing rows.
- Why it's built this way: the auto-derivation convention keeps per-module entities zero-configuration, but framework-owned entities under
MMCA.Common.Domainneed an explicit escape hatch so they do not all pile into aCommonschema and database. Keeping the two overrides (database via attribute, schema viaToTable) side by side in one small configuration makes the notification store's placement obvious and lets it become a real database-per-service source when a host opts in (ADR-006). Persisting the status as a string is a maintainability choice that trades a couple of bytes per row for migration-safe enum evolution. - Where it's used: discovered by assembly scan and applied by
ApplicationDbContextduring model building, and read up front byEntityDataSourceRegistryto routePushNotificationto its physical source. The mapped table backs the SignalR push pipeline (SignalRPushNotificationSender,NotificationHub) and the per-user inbox rows configured by its sibling below.
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 8 · internal sealed class
- What it is: the EF Core mapping for the
UserNotificationaggregate root, one per-user inbox row per notification, carrying read/unread state. It shares the exact placement shape of its siblingPushNotificationConfigurationand adds two filtered indexes that make the inbox queries cheap (UserNotificationConfiguration.cs:15). - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>(base,UserNotificationConfiguration.cs:16); the domain typeUserNotification(UserNotificationConfiguration.cs:3);UseDatabaseAttribute(UserNotificationConfiguration.cs:14); EF Core'sEntityTypeBuilder<TEntity>(BCL/NuGet,UserNotificationConfiguration.cs:1-2). - Concept introduced, filtered (partial) unique indexes that respect soft-delete. The
[UseDatabase("Notification")]routing and theToTable(..., "Notification")schema override are identical to the sibling above (seePushNotificationConfigurationfor the full teaching of that shape); this section focuses on what differs, the indexing. Every entity in this framework is soft-deleted (rows setIsDeleted = true, never physically removed), so a plain unique index on(UserId, PushNotificationId)would forbid a user from ever re-receiving a notification whose prior inbox row was soft-deleted. A filtered index (HasFilter("[IsDeleted] = 0")) sidesteps that: the uniqueness constraint applies only to live rows, and the second, non-unique filtered index narrows the physical index to just the non-deleted rows the unread-count query actually scans.[Rubric §8, Data Architecture]covers indexing strategy as a first-class storage concern; the deliberate composite keys here map directly to the two access patterns (one-inbox-row-per-user-per-notification, and unread lookups).[Rubric §12, Performance & Scalability]assesses whether hot read paths are supported by targeted indexes; the(UserId, IsRead)filtered index is sized for the notification-badge count that every authenticated page issues.[Rubric §30, Compliance, Privacy & Data Governance]covers how governance rules like soft-delete are honored at the storage layer; theIsDeleted = 0filter keeps soft-deleted inbox history out of both the uniqueness rule and the query index without hard-deleting it. - Walkthrough
[UseDatabase("Notification")]and base class (UserNotificationConfiguration.cs:14-16): same routing and sameEntityTypeConfigurationSQLServer<...>base as the sibling, soUserNotificationlands in the sameNotificationlogical database, and (because it carriesIdValueGeneratedAttribute) gets a store-generated identity key from the base pipeline.base.Configure(builder)+ToTable(UserNotificationConfiguration.cs:21-24): identical mechanism to the sibling, mapping theUserNotificationtable into theNotificationschema over the auto-derivedCommonschema.UserId(UserNotificationConfiguration.cs:26-27) andPushNotificationId(UserNotificationConfiguration.cs:29-30): both required. These are a scalar foreign-key column pair, not an EF navigation, so the two notification entities stay decoupled and survive being routed to a separate physical source (cross-source relationships degrade to scalar FKs; the link is resolved by navigation populators, not a database constraint).IsRead(UserNotificationConfiguration.cs:32-34): required withHasDefaultValue(false), so a freshly inserted inbox row is unread at the database default even if the column is not written.ReadOn(UserNotificationConfiguration.cs:36): mapped as-is (nullableDateTime?), no extra constraints; it stays null until the domain'sMarkAsReadstamps it.- Unique filtered index
(UserId, PushNotificationId)(UserNotificationConfiguration.cs:39-41):IsUnique().HasFilter("[IsDeleted] = 0")guarantees at most one live inbox entry per user per notification while still allowing soft-deleted history. - Filtered index
(UserId, IsRead)(UserNotificationConfiguration.cs:44-45): non-unique,HasFilter("[IsDeleted] = 0"), sized for the fast "this user's unread notifications" lookup that drives the unread badge.
- Why it's built this way: the pairing is deliberate:
PushNotificationis the broadcast fact (one row per send),UserNotificationis the fan-out inbox (one row per recipient), and keeping them as two aggregates joined by a scalarPushNotificationId(rather than an EF navigation) is what lets the whole notification store move to its own database without a foreign-key constraint spanning physical sources (ADR-006). The filtered indexes are the concession that makes soft-delete and a hot unread-count query coexist: uniqueness that ignores tombstones, and a lookup index that never scans them. - Where it's used: discovered and applied by
ApplicationDbContextduring model building and registered withEntityDataSourceRegistryfor routing. The mapped table backs the per-user notification inbox surfaced through the SignalR pipeline and the inbox/read-state APIs.