The Persistence block is the EF Core configuration layer every module sits on top of. It ships a BaseDbContext that auto-applies tenant isolation and the soft-delete query filter, the Specification<T> pattern for composable queries, two ISaveChangesInterceptor implementations (audit + domain events), and a DatabaseOptions switch between PostgreSQL and SQL Server. Around 400-600 lines of code.
What it ships
Extensions
AddHeroDatabaseOptions(services, configuration)— bindsDatabaseOptionswith validate-on-start, registersAuditableEntitySaveChangesInterceptor+DomainEventsInterceptorasISaveChangesInterceptor,TimeProvider.System, and a startup logger that prints the active provider.AddHeroDbContext<TContext>(services)— registers a DbContext viaConfigureHeroDatabase(provider switching fromDatabaseOptions) with every registeredISaveChangesInterceptorattached.
Base type
BaseDbContext— extends Finbuckle’sMultiTenantDbContext. InOnModelCreatingit appends the namedSoftDeletequery filter to everyISoftDeletableentity, then callsApplyTenantIsolationByDefault()which marks every entityIsMultiTenant()unless it implementsIGlobalEntity— tenant isolation is default-ON, opt-out only. InSaveChangesAsyncit setsTenantNotSetMode.Overwriteso unsaved entities without an explicit tenant inherit the current one. It also honours a per-tenantConnectionStringfromAppTenantInfoinOnConfiguring.
Specification pattern
Specification<T>— composable query base with protectedWhere(),Include()/Include(string),OrderBy()/OrderByDescending()/ThenBy()/ThenByDescending(), andApplySortingOverride()(whitelist-mapped client sort expressions like"Name,-CreatedOn"— no reflection).AsNoTrackingis on by default. Multi-Wherecombines via expression-tree AND.SpecificationOfTResult(Specification<T, TResult>) — projection variant with aSelectexpression.SpecificationExtensions.ApplySpecification(...)— turns a spec (entity or projected) into anIQueryable.
Interceptors
AuditableEntitySaveChangesInterceptor— fills inCreatedOnUtc/CreatedBy(Added) andLastModifiedOnUtc/LastModifiedBy(Modified, including owned-entity changes) fromICurrentUser+TimeProvider. It also converts hard deletes into soft deletes: anISoftDeletableentry in Deleted state is flipped to Modified withIsDeleted/DeletedOnUtc/DeletedByset (and cascade-deleted owned references restored so the UPDATE doesn’t null their columns).DomainEventsInterceptor— afterSaveChangesAsyncsucceeds, collects events from trackedIHasDomainEventsentities (clearing each list), then publishes each through Mediator. Handler failures are logged, not rethrown — the save is already committed; use the outbox for guaranteed delivery.
Model-builder helpers
ApplyTenantIsolationByDefault(modelBuilder)— marks every keyed, non-owned entity that isn’tIGlobalEntity(and isn’t already explicitly marked) asIsMultiTenant().AdjustUniqueIndexes(). Must run afterApplyConfigurationsFromAssemblyso unique indexes exist to widen.AppendGlobalQueryFilter<TInterface>(modelBuilder, filterName, expr)— registers a named filter on every entity implementingTInterface; named filters compose with Finbuckle’s anonymous tenant filter via AND.
Options
DatabaseOptions(lives inFSH.Framework.Shared) —Provider(POSTGRESQLdefault orMSSQL, matched case-insensitively),ConnectionString,MigrationsAssembly. An empty connection string fails validation at startup.
How modules consume it
// Your module's DbContext (this is the real CatalogDbContext)public sealed class CatalogDbContext : BaseDbContext{ public const string Schema = "catalog";
public CatalogDbContext( IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor, DbContextOptions<CatalogDbContext> options, IOptions<DatabaseOptions> settings, IHostEnvironment environment) : base(multiTenantContextAccessor, options, settings, environment) { }
public DbSet<Brand> Brands => Set<Brand>(); public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema(Schema); modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); base.OnModelCreating(modelBuilder); // CRITICAL — must be last }}
// Register in the module's ConfigureServicesbuilder.Services.AddHeroDbContext<CatalogDbContext>();Anywhere you need a tenant-scoped query, just await db.Products.ToListAsync(ct) — the filter is applied transparently.
For composable queries:
public sealed class ActiveProductsByBrandSpec : Specification<Product>{ public ActiveProductsByBrandSpec(Guid brandId) { Where(p => p.BrandId == brandId && p.IsActive); Include(p => p.Images); OrderByDescending(p => p.CreatedAtUtc); }}
// Handlervar products = await _db.Products .ApplySpecification(new ActiveProductsByBrandSpec(brandId)) .ToListAsync(ct).ConfigureAwait(false);Configuration
{ "DatabaseOptions": { "Provider": "PostgreSQL", "ConnectionString": "Host=localhost;Port=5432;Database=fsh;Username=postgres;Password=postgres", "MigrationsAssembly": "FSH.Starter.Migrations.PostgreSQL" }}OptionsBuilderExtensions.ConfigureHeroDatabase() picks UseNpgsql() or UseSqlServer() from the Provider value. Tenant-specific connection strings, when set on AppTenantInfo, override the default at request time via Finbuckle.
How to extend
Add a new interceptor
public sealed class FullTextSearchInterceptor : SaveChangesInterceptor{ public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default) { // rewrite a generated tsvector column based on Body, e.g. return base.SavingChangesAsync(eventData, result, ct); }}
// registerservices.AddScoped<ISaveChangesInterceptor, FullTextSearchInterceptor>();Then attach it to whichever DbContext should run it via the AddDbContext options.
Add a named query filter
modelBuilder.Entity<MyEntity>().HasQueryFilter("ActiveOnly", e => e.IsActive);// then to bypass just that filter at a call site:db.MyEntities.IgnoreQueryFilters(["ActiveOnly"])The kit already uses this pattern for soft delete: the filter is registered under the stable name QueryFilters.SoftDelete, so trash views and restore handlers call IgnoreQueryFilters([QueryFilters.SoftDelete]) and the tenant filter stays in force. Bare IgnoreQueryFilters() strips everything — including tenant isolation — so reserve it for root-gated cross-tenant queries and re-filter explicitly.
Gotchas
base.OnModelCreating(builder)must come last in your subclass’s override. The base applies the global tenant filter + soft-delete filter; if you run it first your per-entity configurations can stomp on those filters (or vice versa).Specification<T>.AsNoTrackingistrueby default. This is a deliberate guard against unbounded tracking of large query results. Call out tracked queries explicitly if you need write semantics.- Multi-
Where()calls combine via AND, not OR. The base doesn’t compose||; if you need OR, write it inside a single expression. TenantNotSetMode.OverwriteinSaveChangesAsyncmeans saving an entity without a tenant set will be silently filled in with the current tenant. That’s intentional — without it, every handler would have to rememberentity.TenantId = current.GetTenant()— but it does mean entities created in a null-tenant context get assigned to whichever tenant the current request belongs to. Don’t run write paths from background threads without restoring tenant context first.- Entity ids are client-generated
Guid.CreateVersion7()(set in the aggregate’s static factory), not database-generated. UUIDv7 keeps inserts index-friendly while letting the domain own the id. - Child entities reached only via a parent nav collection need
Property(x => x.Id).ValueGeneratedNever()in their configuration. Without it, EF sees the factory-assigned non-defaultGuidand tracks the child as Modified instead of Added — the insert silently never happens.ProductImage,TicketComment, and Chat’sMessageReaction/MessageMentionall carry this config for exactly that reason.
Critical files
src/BuildingBlocks/Persistence/Context/BaseDbContext.cssrc/BuildingBlocks/Persistence/Specifications/Specification.cssrc/BuildingBlocks/Persistence/PersistenceExtensions.cssrc/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs
Related
- Core —
BaseEntity,IHasTenant,ISoftDeletable,IGlobalEntity. - Multitenancy module — the Finbuckle setup
BaseDbContextbuilds on. - Architecture: multitenancy deep-dive —
IgnoreQueryFilters()(all filters) vsIgnoreQueryFilters([QueryFilters.SoftDelete])(named filter only) discipline.