Use EF Core by default for new module-owned relational persistence in this baseline.
The scaffold now generates an EF-ready Persistence folder for every new module so the default path is explicit instead of implied.
For a newly scaffolded module, begin in src/Modules/{Module}/{Module}.Infrastructure/Persistence/ and work from these files in order:
{Module}PersistenceDefaults.cs{Module}PersistenceDbContext.cs{Module}EntityTypeConfigurationExample.cs{Module}PersistenceOptionsExtensions.cs{Module}PersistenceDbContextFactory.cs{Module}DatabaseMigration.cs
Use these existing module references when you need a concrete example:
- Smallest EF module: ../src/Modules/SampleFeature/SampleFeature.Infrastructure/Persistence/SampleFeaturePersistenceDbContext.cs
- First production-grade EF teaching slice: ../src/Modules/Blog/Blog.Infrastructure/Persistence/BlogPersistenceDbContext.cs
- EF Core Npgsql provider and migrations history table wiring: ../src/Modules/Platform/Platform.Infrastructure/Persistence/PlatformPersistenceOptionsExtensions.cs
- Specialized raw SQL pattern, not the default baseline: ../src/Modules/KnowledgeBase/KnowledgeBase.Infrastructure/Persistence/PostgresKnowledgeBaseStore.cs
The generated persistence shell is intentionally small:
PersistenceDefaultsanchors the module schema and migrations history table.PersistenceDbContextsets the module schema and gives you the place to apply real mappings explicitly.EntityTypeConfigurationExampleshows the intended EF mapping shape with a persistence-only record name, without adding a live table by default.PersistenceOptionsExtensionscentralizes Npgsql provider wiring. Both EF- and Dapper-style modules obtain their connection through the sharedIPostgresDataSourceResolver; seeadr/ADR-POSTGRES-DATA-SOURCE-RESOLVER.md.PersistenceDbContextFactorysupports design-time migrations.DatabaseMigrationgives the runtime migration runner a module-owned EF anchor.
The scaffold does not call ApplyConfigurationsFromAssembly(...) by default. The first real mapping should be wired with an explicit modelBuilder.ApplyConfiguration(...) call so persistence onboarding stays deliberate.
When the module gets its first persisted aggregate or projection:
- Replace
{Module}EntityTypeConfigurationExample.cswith a real persistence record and matchingIEntityTypeConfiguration<T>. - Keep persistence record names infrastructure-owned, such as
BlogPostRecordorPublishedBlogPostProjectionRecord, especially when the relational shape diverges from the domain type. - Add explicit
modelBuilder.ApplyConfiguration(new YourPersistenceConfiguration())calls in{Module}PersistenceDbContext.cswhen the first real mapping is ready. - Add optimistic concurrency tokens where concurrent writes matter.
- Generate the first migration only after the real mapping replaces the scaffold example.
- Keep the schema module-local and avoid cross-module foreign keys.
Raw SQL is still valid, but it should be a deliberate exception, not the starting point for a new module.
Good reasons to choose a specialized SQL adapter include:
- focused read models or projections where EF adds little value
- bulk operations or Postgres-specific features that EF does not express clearly
- measured hot paths where EF is the proven bottleneck
- infrastructure-heavy inbox, outbox, or coordination tables with tight SQL control requirements
Even in those cases:
- keep the adapter in
.Infrastructure - keep migrations and schema ownership explicit
- keep cross-module boundaries unchanged
- do not copy a raw SQL module as the baseline for a fresh scaffold
If you are creating a new module and you do not already have a measured reason to avoid EF Core, stay with the scaffolded EF path.
The architecture tests enforce that read surfaces in module Infrastructure prefer .Select() projections over .Include() eager loading. This prevents full entity graphs from being loaded when only a subset of data is needed on query paths.
The governing test is EfQueryProjectionGuardrailTests.ReadSurfaceFilesMustNotUseEagerLoadingInclude. It structurally detects read surfaces by scanning for files that implement IBlogPostReadQueries-style interfaces, IQueryHandler<,>, I*QueryService, or I*Reader. Write-side stores that only serve command handlers for aggregate loading are excluded.
When a module has both query handlers and command handlers operating on the same entity family, separate the read and write concerns at the Infrastructure layer:
- Read queries (
I{Entity}ReadQueries) — implement with.Select()projections into a flat intermediate type, then map to the domain type in memory. Register as a dedicated class (e.g.BlogPostReadQueries) and wire query handlers to this interface. - Write store (
I{Entity}Store) — implement with.Include()for aggregate reconstitution before mutation. This is legitimate and is excluded from the projection guardrail because the file does not implement a read surface.
The Blog module demonstrates this split: BlogPostReadQueries serves the three query handlers with .Select() projections, while BlogPostStore serves command handlers with .Include() for aggregate loading. This separation makes the BP-035 expectation structural rather than relying on naming conventions.