EF Core Isomorphic Integration & Interceptor Guide
For teams accustomed to Microsoft’s official ORM ecosystem, BitzOrcas.Modern provides production-grade support within src/Framework/BitzOrcas.Infrastructure.EfCore. Beyond implementing ICommandRepository<T, TId>, the adapter leverages SaveChanges Interceptors to automate audit metadata injection, Personally Identifiable Information (PII) field-level encryption, and CAP transactional outbox publication during the database commit lifecycle.
1. Fluent Entity Mapping: IEntityTypeConfiguration<T>
Domain aggregate roots defined in the Contracts tier are mapped inside independent configuration classes in Infrastructure, keeping domain code free of database-specific dependencies:
using BitzOrcas.Sandbox.Domain;using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BitzOrcas.Sandbox.Infrastructure.Mapping;
/// <summary>/// EF Core configuration mapping for the Note aggregate root./// </summary>public sealed class NoteConfiguration : IEntityTypeConfiguration<Note>{ public void Configure(EntityTypeBuilder<Note> builder) { // Map physical table name; magic T_ prefixes are strictly prohibited builder.ToTable("SandboxNote");
builder.HasKey(x => x.Id); builder.Property(x => x.Id) .ValueGeneratedNever(); // Snowflake IDs generated by application layer
// Map column alias Name and enforce length constraint builder.Property(x => x.Title) .HasColumnName("Name") .HasMaxLength(Note.TitleMaxLength) .IsRequired();
builder.Property(x => x.TenantId) .HasMaxLength(64) .IsRequired();
// Optimistic concurrency token and soft delete builder.Property(x => x.Version) .IsConcurrencyToken();
builder.Property(x => x.IsDeleted) .IsRequired(); }}2. Core Interceptors: Audit Backfill & PII Encryption
BitzOrcasDbContext mounts two primary interceptors:
2.1 Audit Metadata Injection: AuditSaveChangesInterceptor
Injects the current authenticated tenant user and timestamp into entities implementing IAuditableEntity:
using System;using System.Threading;using System.Threading.Tasks;using BitzOrcas.Application.Abstractions.Tenancy;using BitzOrcas.Domain.Contracts;using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Diagnostics;
namespace BitzOrcas.Infrastructure.EfCore;
/// <summary>/// Interceptor backfilling audit metadata on mutating entities./// </summary>public sealed class AuditSaveChangesInterceptor(ICurrentTenant currentTenant) : SaveChangesInterceptor{ public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { if (eventData.Context is null) { return ValueTask.FromResult(result); }
var now = DateTimeOffset.UtcNow; var tenant = currentTenant.Tenant; var currentUserId = tenant.IsAvailable ? tenant.EffectiveTenantId : "system";
foreach (var entry in eventData.Context.ChangeTracker.Entries<IAuditableEntity>()) { if (entry.State == EntityState.Added) { entry.Entity.CreateTime = now; entry.Entity.CreateId = currentUserId; } else if (entry.State == EntityState.Modified) { entry.Entity.ModifyTime = now; entry.Entity.ModifyId = currentUserId; } }
return ValueTask.FromResult(result); }}2.2 PII Protection: PiiSaveChangesInterceptor
Prior to database persistence, interceptors inspect entity properties marked with [PiiEncrypted], applying AES-GCM encryption on write and decrypting transparently on read, ensuring database breaches never expose plaintext confidential data.
3. Global Multitenancy Query Filters
In BitzOrcasDbContext.OnModelCreating, the framework automatically registers global query filters on all types implementing ITenantEntity:
protected override void OnModelCreating(ModelBuilder modelBuilder){ base.OnModelCreating(modelBuilder);
// Scan and apply all IEntityTypeConfiguration classes in the assembly modelBuilder.ApplyConfigurationsFromAssembly(typeof(BitzOrcasDbContext).Assembly);
// Apply tenant scoping filters automatically foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType)) { var parameter = System.Linq.Expressions.Expression.Parameter(entityType.ClrType, "e"); var filter = System.Linq.Expressions.Expression.Lambda( System.Linq.Expressions.Expression.Equal( System.Linq.Expressions.Expression.Property(parameter, nameof(ITenantEntity.TenantId)), System.Linq.Expressions.Expression.Property( System.Linq.Expressions.Expression.Constant(_tenantContext), nameof(ITenantContext.TenantId))), parameter);
entityType.SetQueryFilter(filter); } }}4. Transactional Outbox Coordination (EfCoreUnitOfWork)
To ensure physical atomicity between database mutations and domain event publishing, EfCoreUnitOfWork joins EF Core transactions with the DotNetCore.CAP transactional outbox:
public async Task<int> CommitAsync(CancellationToken cancellationToken = default){ // Begin physical transaction attached to CAP publisher using var transaction = await _dbContext.Database.BeginTransactionAsync(_capPublisher, autoCommit: false, cancellationToken); try { var writtenCount = await _dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return writtenCount; } catch { await transaction.RollbackAsync(cancellationToken); throw; }}5. Related Architecture Decisions & Deep Dives
- Architecture Overview: Multi-ORM Dual-Engine Architecture & Selection Matrix
- Dapper Performance: Dapper Read-Side Performance & Connection Pooling
- ADR Reference: ADR 0304: Transactional Outbox & Event Dispatch Invariants