Skip to content

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.

InterfaceFilter key (GranitFilterNames)Filter expressionDefault
ISoftDeletableSoftDelete!e.IsDeletedEnabled
IActiveActivee.ActivatedEnabled
IProcessingRestrictableProcessingRestrictable!e.IsProcessingRestrictedEnabled
IMultiTenantMultiTenante.TenantId == currentTenant.IdEnabled
IPublishablePublishablee.IsPublishedEnabled
IHasMergeTombstoneMergeTombstonee.MergedIntoId == nullEnabled

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.

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.

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 apply
var deletedPatients = await db.Patients
.IgnoreQueryFilters([GranitFilterNames.SoftDelete])
.Where(p => p.IsDeleted)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
// Bypass multiple specific filters
var allPatients = await db.Patients
.IgnoreQueryFilters([
GranitFilterNames.SoftDelete,
GranitFilterNames.MultiTenant])
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

For entities implementing ITranslatable<T>, ApplyGranitConventions automatically configures:

  • Foreign key: Translation.ParentId → Parent.Id with cascade delete
  • Unique index: (ParentId, Culture)
  • Culture column: max length 20 (BCP 47)

Query extensions for translated entities:

// Eager load translations for a culture
var patients = await db.Patients
.IncludeTranslations<Patient, PatientTranslation>("fr")
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
// Filter by translation property
var results = await db.Patients
.WhereTranslation<Patient, PatientTranslation>("fr", t => t.DisplayName.Contains("Jean"))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

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:

  1. TEntity implements IMultiTenant
  2. ICurrentTenant.IsAvailable is false (no tenant header)
  3. The subclass passes ICurrentTenant to 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 base
internal 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);

The data layer bypass is one of three security layers. See Authorization — Host access for the full architecture.