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)— binds and validatesDatabaseOptions; registersAuditableEntitySaveChangesInterceptorandDomainEventsInterceptor.AddHeroDbContext<TContext>(services)— registers a DbContext with provider switching, interceptors attached, and the connection string resolved at runtime.
Base type
BaseDbContext— extends Finbuckle’sMultiTenantDbContext. InOnModelCreatingit applies the global tenant filter (everything exceptIGlobalEntity) and the namedSoftDeletequery filter. InSaveChangesAsyncit setsTenantNotSetMode.Overwriteso unsaved entities without an explicit tenant inherit the current one.
Specification pattern
Specification<T>— composable query base with fluentWhere(),Include(),Include(string),OrderBy()/Then…,AsNoTrackingQuery()(no-op; it’s the default),AsSplitQuery(), andIgnoreQueryFilters(). Multi-Wherecombines via expression-tree AND.SpecificationExtensions.ApplySpecification<T>(IQueryable<T>, Specification<T>)— turns a spec into anIQueryable<T>.
Interceptors
AuditableEntitySaveChangesInterceptor— fills inCreatedAt,CreatedBy,UpdatedAt,UpdatedByfromICurrentUserfor anyIAuditableEntityrow entering Added or Modified state.DomainEventsInterceptor— afterSaveChangesAsyncsucceeds, walks tracked entities implementingIHasDomainEvents, dispatches each event through Mediator, then clears the lists.
Model-builder helpers
ApplyTenantIsolationByDefault(modelBuilder)— marks every entity that isn’tIGlobalEntityasIsMultiTenant().AppendGlobalQueryFilter<T>(modelBuilder, expr)— appends a global filter without overwriting existing ones.
Options
DatabaseOptions—Provider(PostgreSQLdefault orMSSQL),ConnectionString,MigrationsAssembly.
How modules consume it
// Your module's DbContextpublic sealed class CatalogDbContext( IMultiTenantContextAccessor accessor, DbContextOptions<CatalogDbContext> options, IOptions<DatabaseOptions> settings) : BaseDbContext(accessor, options, settings){ public DbSet<Product> Products => Set<Product>(); public DbSet<Brand> Brands => Set<Brand>();
protected override void OnModelCreating(ModelBuilder builder) { builder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); builder.HasDefaultSchema("catalog"); base.OnModelCreating(builder); // CRITICAL — must be last }}
// Register in CatalogModule.ConfigureServicesservices.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(Guid brandId) : Specification<Product>{ public ActiveProductsByBrandSpec(Guid brandId) { Where(p => p.BrandId == brandId && p.IsActive); Include(p => p.Images); OrderByDescending(p => p.CreatedAt); }}
// 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: db.MyEntities.IgnoreNamedQueryFilter("ActiveOnly")The kit already uses this pattern for SoftDelete so cross-tenant audit queries can IgnoreQueryFilters() while still respecting soft-delete (using IgnoreNamedQueryFilter("SoftDelete") would be the inverse).
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.
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 —
IgnoreQueryFiltersvsIgnoreNamedQueryFilterdiscipline.