Query Filters — Soft Delete, Multi-Tenant, Active
ApplyGranitConventions registers one named HasQueryFilter per applicable
interface on each entity type (EF Core 10). Filters are independent — bypass one
without affecting the others.
| Interface | Filter key (GranitFilterNames) | Filter expression | Default |
|---|---|---|---|
ISoftDeletable | SoftDelete | !e.IsDeleted | Enabled |
IActive | Active | e.Activated | Enabled |
IProcessingRestrictable | ProcessingRestrictable | !e.IsProcessingRestricted | Enabled |
IMultiTenant | MultiTenant | e.TenantId == currentTenant.Id | Enabled |
IPublishable | Publishable | e.IsPublished | Enabled |
IHasMergeTombstone | MergeTombstone | e.MergedIntoId == null | Enabled |
For IHasMergeTombstone, ApplyGranitConventions also auto-applies the
MergedIntoId (Guid?) and MergedAt (DateTimeOffset?) columns plus an index on
MergedIntoId — no per-module mapping code. See
ADR-037 for the full merge
framework.
Filters are re-evaluated per query via DataFilter’s AsyncLocal state — EF Core
extracts FilterProxy boolean properties as parameterized expressions.
Runtime filter control — service scope
Section titled “Runtime filter control — service scope”Use IDataFilter to bypass a filter for all queries in the current async flow:
public class PatientAdminService(IDataFilter dataFilter, AppDbContext db){ public async Task<List<Patient>> GetAllIncludingDeletedAsync( CancellationToken cancellationToken) { using (dataFilter.Disable<ISoftDeletable>()) { return await db.Patients .ToListAsync(cancellationToken) .ConfigureAwait(false); } // Filter re-enabled automatically }}Scopes nest correctly — inner Enable/Disable calls restore the outer state
on disposal.
Per-query bypass (EF Core 10)
Section titled “Per-query bypass (EF Core 10)”Use IgnoreQueryFilters with one or more filter keys to bypass specific filters
for a single query, without affecting other queries in the same flow:
// Bypass only soft delete — multi-tenant and other filters still applyvar deletedPatients = await db.Patients .IgnoreQueryFilters([GranitFilterNames.SoftDelete]) .Where(p => p.IsDeleted) .ToListAsync(cancellationToken) .ConfigureAwait(false);
// Bypass multiple specific filtersvar allPatients = await db.Patients .IgnoreQueryFilters([ GranitFilterNames.SoftDelete, GranitFilterNames.MultiTenant]) .ToListAsync(cancellationToken) .ConfigureAwait(false);Translation conventions
Section titled “Translation conventions”For entities implementing ITranslatable<T>, ApplyGranitConventions automatically
configures:
- Foreign key:
Translation.ParentId → Parent.Idwith cascade delete - Unique index:
(ParentId, Culture) - Culture column: max length 20 (BCP 47)
Query extensions for translated entities:
// Eager load translations for a culturevar patients = await db.Patients .IncludeTranslations<Patient, PatientTranslation>("fr") .ToListAsync(cancellationToken) .ConfigureAwait(false);
// Filter by translation propertyvar results = await db.Patients .WhereTranslation<Patient, PatientTranslation>("fr", t => t.DisplayName.Contains("Jean")) .ToListAsync(cancellationToken) .ConfigureAwait(false);Common pitfalls
Section titled “Common pitfalls”Host admin bypass — EfStoreBase.Query(db)
Section titled “Host admin bypass — EfStoreBase.Query(db)”In host context (no active tenant), the MultiTenant filter produces
WHERE TenantId IS NULL — which returns no tenant data. This is the correct
safe default, but host administrators need cross-tenant visibility.
EfStoreBase solves this with its Query(db) helper method:
// Inside EfStoreBase<TEntity, TContext>protected IQueryable<TEntity> Query(TContext db) => _bypassTenantFilter ? db.Set<TEntity>().IgnoreQueryFilters([GranitFilterNames.MultiTenant]) : db.Set<TEntity>();The bypass activates only when:
TEntityimplementsIMultiTenantICurrentTenant.IsAvailableisfalse(no tenant header)- The subclass passes
ICurrentTenantto the base constructor
All built-in read methods (FindByIdAsync, FirstOrDefaultAsync, ListAsync,
PagedAsync, CountAsync, AnyAsync) use Query(db) automatically.
// Subclass opt-in — just pass ICurrentTenant to baseinternal sealed class EfInvoiceStore( IDbContextFactory<InvoicingDbContext> contextFactory, ICurrentTenant currentTenant) : EfStoreBase<Invoice, InvoicingDbContext>(contextFactory, currentTenant), IInvoiceReader, IInvoiceWriter{ // All read methods automatically bypass MultiTenant filter in host context. // Write methods (Add, Update, Delete) are unaffected — entities keep their TenantId.}For custom queries via ReadAsync (raw DbContext access), use Query(db) explicitly:
return await ReadAsync(async db => await Query(db) .Include(e => e.Lines) .FirstOrDefaultAsync(e => e.Id == id, ct) .ConfigureAwait(false), ct).ConfigureAwait(false);Defense in depth
Section titled “Defense in depth”The data layer bypass is one of three security layers. See Authorization — Host access for the full architecture.
See also
Section titled “See also”- Persistence overview — setup, isolated DbContext, host-owned migrations
- Interceptors — audit, soft delete, concurrency, events
- Zero-downtime migrations — Expand & Contract pattern
- Authorization — Host access — endpoint-level defense