Skip to content

Persistence — EF Core, Isolated DbContexts

Every module in a modular framework needs audit trails, soft delete, and tenant isolation. Without centralized support, developers end up copying the same boilerplate: setting CreatedAt in every repository, writing WHERE IsDeleted = false in every query, forgetting ModifiedBy on updates. One missed filter and you surface deleted records or leak data across tenants.

Granit eliminates this by intercepting EF Core’s SaveChanges pipeline and auto-applying query filters. Application code deals with domain logic; the framework handles the plumbing.

Six SaveChangesInterceptor implementations run in sequence before every SaveChanges / SaveChangesAsync call. This page covers the three you meet first; see Interceptors for the full set.

flowchart LR
    A[SaveChanges called] --> B[AuditedEntityInterceptor]
    B --> C[VersioningInterceptor]
    C --> D[ConcurrencyStampInterceptor]
    D --> E[DomainEventDispatcherInterceptor]
    E --> F[EntityLifecycleEventInterceptor]
    F --> G[SoftDeleteInterceptor]
    G --> H[Database]

SoftDeleteInterceptor runs last on purpose: it flips EntityState.Deleted to Modified, which would hide the original state from every interceptor before it.

Audit tracking splits across two interfaces in Granit.Domain, so an entity can opt into creation stamps without carrying modification stamps:

InterfaceFieldsSet when
ICreationAuditedObjectCreatedAt, CreatedBy (+ TenantId if IMultiTenant)Added
IModificationAuditedObjectModifiedAt, ModifiedByModified

The interceptor pivots on the interfaces, not on a base class — inheriting FullAuditedEntity / FullAuditedAggregateRoot is a convenience, not a requirement.

Timestamps come from the injected TimeProvider (never DateTime.Now). User identity comes from ICurrentUserService. Tenant ID comes from ICurrentTenant.

public class Invoice : AggregateRoot, ICreationAuditedObject, IModificationAuditedObject, IMultiTenant
{
public Guid? TenantId { get; set; }
public string Number { get; set; } = string.Empty;
public decimal Amount { get; set; }
// These are set automatically — never assign them manually
public DateTimeOffset CreatedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTimeOffset? ModifiedAt { get; set; }
public string? ModifiedBy { get; set; }
}

Targets entities implementing ISoftDeletable. When EF Core detects a Deleted state, the interceptor:

  1. Changes the state from Deleted to Modified.
  2. Sets IsDeleted = true, DeletedAt, and DeletedBy.
  3. The row stays in the database — no physical DELETE is issued.
public class Document : AggregateRoot, ISoftDeletable
{
public string Title { get; set; } = string.Empty;
// Managed by the interceptor
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}

Targets entities implementing IVersioned (Granit.Domain). On insert:

  • Assigns a VersionId (stable GUID shared across all versions of the same logical entity).
  • Sets Version to max(Version) + 1 for that VersionId.

This gives you an immutable version history without a separate history table.

ApplyGranitConventions scans all entity types and registers one named HasQueryFilter per applicable interface (EF Core 10). Each filter is independent and individually bypassable:

InterfaceFilter keySQL condition
ISoftDeletableSoftDeleteWHERE IsDeleted = false
IActiveActiveWHERE Activated = true
IMultiTenantMultiTenantWHERE TenantId = @currentTenantId
IProcessingRestrictableProcessingRestrictableWHERE IsProcessingRestricted = false
IPublishablePublishableWHERE IsPublished = true
IHasMergeTombstoneMergeTombstoneWHERE MergedIntoId IS NULL

An entity implementing both ISoftDeletable and IMultiTenant gets two named filters, both applied by EF Core as AND conditions:

WHERE "e"."IsDeleted" = FALSE AND "e"."TenantId" = @__tenantId

Bypass a single filter for one query without touching the others:

// Only soft delete bypassed — multi-tenant still applies
var rows = await db.Patients
.IgnoreQueryFilters([GranitFilterNames.SoftDelete])
.ToListAsync(ct);

Every *.EntityFrameworkCore package that owns a DbContext must follow this checklist. No exceptions — the architecture tests enforce it.

<ProjectReference Include="..\Granit.Persistence\Granit.Persistence.csproj" />

Inherit GranitDbContext and pass ICurrentTenant plus an optional IDataFilter to the base constructor:

internal sealed class InvoiceDbContext(
DbContextOptions<InvoiceDbContext> options,
ICurrentTenant currentTenant,
IDataFilter? dataFilter = null)
: GranitDbContext(options, currentTenant, dataFilter)
{
public DbSet<Invoice> Invoices => Set<Invoice>();
}

Deriving is what makes the tenant filter safe. EF Core only parameterises a query-filter value when it reads it from a member of the DbContext instance. A value captured from a constructor-injected service is inlined as a literal into the compiled SQL and frozen for the lifetime of the cached model — request A pins WHERE TenantId = 'A' and request B reuses the frozen plan. Because CurrentTenantId is an instance property on GranitDbContext, EF Core emits @ef_filter__CurrentTenantId and re-binds it on every command.

OnModelCreating is sealed on the base class — the conventions pass is not something a module can skip, reorder, or duplicate. Put your entity configuration in OnGranitModelCreating instead:

protected override void OnGranitModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(InvoiceDbContext).Assembly);
}

4. Register the DbContext — AddGranitDbContext or AddGranitIsolatedDbContext

Section titled “4. Register the DbContext — AddGranitDbContext or AddGranitIsolatedDbContext”

The framework ships two extensions; pick based on whether the module is tenant-isolated (data physically separated per tenant) or shared (one database, tenant-scoped via query filters):

// Non-tenant-isolated: the standard path — auto-wires interceptors and
// applies UseGranitInterceptors (the 6 standard interceptors + any
// IGranitAutoInterceptor registered by other modules, e.g. AuditingChangeTrackingInterceptor).
public static IServiceCollection AddInvoicePersistence(
this IServiceCollection services,
Action<DbContextOptionsBuilder> configure)
{
services.AddGranitDbContext<InvoiceDbContext>(configure);
return services;
}

For modules that must support all three tenant-isolation strategies (SharedDatabase, DatabasePerTenant, SchemaPerTenant), use AddGranitIsolatedDbContext — the host’s TenantIsolation:Strategy setting picks the right factory at runtime, and an invalid value fails fast at startup:

// Tenant-isolated: ships ALL three strategies; appsettings picks one.
services.AddGranitIsolatedDbContext<InvoiceDbContext>(
configureShared: opts => opts.UseNpgsql(builder.Configuration.GetConnectionString("Invoicing")),
configureDatabasePerTenant: (opts, tenantCs) => opts.UseNpgsql(tenantCs),
configureSchemaPerTenant: opts => opts.UseNpgsql(builder.Configuration.GetConnectionString("Invoicing")),
configureTenantSchema: schema => schema.NamingConvention = TenantSchemaNamingConvention.Prefix);

Both helpers auto-wire AuditedEntityInterceptor, SoftDeleteInterceptor, VersioningInterceptor, and any IGranitAutoInterceptor registered by another module (e.g. AuditingChangeTrackingInterceptor) — manual AddInterceptors calls aren’t needed.

[DependsOn(typeof(GranitPersistenceModule))]
public class InvoiceEntityFrameworkCoreModule : GranitModule
{
// ...
}

Already covered above. ApplyGranitConventions handles all standard filters. Manual filters conflict with the named filter keys registered by the framework.

IMultiTenant entities use Guid? TenantId — never string, never non-nullable Guid. The nullable type is required because host-level entities (shared across tenants) legitimately have no tenant.

Granit provides IDataSeedContributor for deterministic, idempotent seed data:

public class InvoiceStatusSeedContributor : IDataSeedContributor
{
public int Order => 100; // Controls execution sequence
public async Task SeedAsync(
DataSeedContext context,
CancellationToken cancellationToken)
{
var dbContext = context.ServiceProvider
.GetRequiredService<InvoiceDbContext>();
if (await dbContext.InvoiceStatuses.AnyAsync(cancellationToken))
{
return; // Idempotent — skip if data exists
}
dbContext.InvoiceStatuses.AddRange(
new InvoiceStatus { Code = "DRAFT", LabelEn = "Draft", LabelFr = "Brouillon" },
new InvoiceStatus { Code = "SENT", LabelEn = "Sent", LabelFr = "Envoyee" });
await dbContext.SaveChangesAsync(cancellationToken);
}
}

Here is what happens when you call SaveChangesAsync on an entity that implements ICreationAuditedObject, IModificationAuditedObject, ISoftDeletable, and IMultiTenant:

On insert:

  1. AuditedEntityInterceptor sets CreatedAt, CreatedBy, TenantId.
  2. SoftDeleteInterceptor — no action (entity is not being deleted).
  3. Row is inserted.

On update:

  1. AuditedEntityInterceptor sets ModifiedAt, ModifiedBy.
  2. SoftDeleteInterceptor — no action.
  3. Row is updated.

On delete:

  1. AuditedEntityInterceptor sets ModifiedAt, ModifiedBy.
  2. SoftDeleteInterceptor changes state to Modified, sets IsDeleted, DeletedAt, DeletedBy.
  3. Row is updated (not deleted).

On query:

  1. EF Core applies the combined query filter: WHERE IsDeleted = false AND TenantId = @tenantId
  2. Application code sees only active, non-deleted, tenant-scoped records.

Accessing data — Reader/Writer separation

Section titled “Accessing data — Reader/Writer separation”

Application code does not use the DbContext directly. Each module exposes IXxxReader (read-only) and IXxxWriter (mutations) interfaces. Endpoints and handlers inject only the interface matching their intent:

// A read endpoint injects ONLY the Reader — no write access possible
private static async Task<Ok<List<BlobDescriptor>>> ListBlobsAsync(
IBlobDescriptorReader reader,
CancellationToken cancellationToken)
{
var blobs = await reader.FindAllAsync(cancellationToken).ConfigureAwait(false);
return TypedResults.Ok(blobs);
}

This separation is enforced by architecture tests and gives you:

  • Least privilege — a read endpoint cannot accidentally mutate data
  • ISO 27001 audit — the DI graph shows which components have write access
  • GDPR safety — Reader interfaces expose no Delete methods

See the CQRS concept for the full mental model, examples, and common mistakes to avoid.