Skip to content
fullstackhero

Reference

Persistence building block

EF Core base context with auto-applied multitenancy and soft-delete filters, the Specification pattern, audit + domain-event interceptors, and per-tenant connection strings.

views 0 Last updated

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 DatabaseOptions with validate-on-start, registers AuditableEntitySaveChangesInterceptor + DomainEventsInterceptor as ISaveChangesInterceptor, TimeProvider.System, and a startup logger that prints the active provider.
  • AddHeroDbContext<TContext>(services) — registers a DbContext via ConfigureHeroDatabase (provider switching from DatabaseOptions) with every registered ISaveChangesInterceptor attached.

Base type

  • BaseDbContext — extends Finbuckle’s MultiTenantDbContext. In OnModelCreating it appends the named SoftDelete query filter to every ISoftDeletable entity, then calls ApplyTenantIsolationByDefault() which marks every entity IsMultiTenant() unless it implements IGlobalEntity — tenant isolation is default-ON, opt-out only. In SaveChangesAsync it sets TenantNotSetMode.Overwrite so unsaved entities without an explicit tenant inherit the current one. It also honours a per-tenant ConnectionString from AppTenantInfo in OnConfiguring.

Specification pattern

  • Specification<T> — composable query base with protected Where(), Include() / Include(string), OrderBy() / OrderByDescending() / ThenBy() / ThenByDescending(), and ApplySortingOverride() (whitelist-mapped client sort expressions like "Name,-CreatedOn" — no reflection). AsNoTracking is on by default. Multi-Where combines via expression-tree AND.
  • SpecificationOfTResult (Specification<T, TResult>) — projection variant with a Select expression.
  • SpecificationExtensions.ApplySpecification(...) — turns a spec (entity or projected) into an IQueryable.

Interceptors

  • AuditableEntitySaveChangesInterceptor — fills in CreatedOnUtc/CreatedBy (Added) and LastModifiedOnUtc/LastModifiedBy (Modified, including owned-entity changes) from ICurrentUser + TimeProvider. It also converts hard deletes into soft deletes: an ISoftDeletable entry in Deleted state is flipped to Modified with IsDeleted/DeletedOnUtc/DeletedBy set (and cascade-deleted owned references restored so the UPDATE doesn’t null their columns).
  • DomainEventsInterceptor — after SaveChangesAsync succeeds, collects events from tracked IHasDomainEvents entities (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’t IGlobalEntity (and isn’t already explicitly marked) as IsMultiTenant().AdjustUniqueIndexes(). Must run after ApplyConfigurationsFromAssembly so unique indexes exist to widen.
  • AppendGlobalQueryFilter<TInterface>(modelBuilder, filterName, expr) — registers a named filter on every entity implementing TInterface; named filters compose with Finbuckle’s anonymous tenant filter via AND.

Options

  • DatabaseOptions (lives in FSH.Framework.Shared) — Provider (POSTGRESQL default or MSSQL, 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 ConfigureServices
builder.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);
}
}
// Handler
var 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);
}
}
// register
services.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>.AsNoTracking is true by 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.Overwrite in SaveChangesAsync means 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 remember entity.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-default Guid and tracks the child as Modified instead of Added — the insert silently never happens. ProductImage, TicketComment, and Chat’s MessageReaction/MessageMention all carry this config for exactly that reason.

Critical files

  • src/BuildingBlocks/Persistence/Context/BaseDbContext.cs
  • src/BuildingBlocks/Persistence/Specifications/Specification.cs
  • src/BuildingBlocks/Persistence/PersistenceExtensions.cs
  • src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs