Skip to content

Persistence

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.

Three SaveChangesInterceptor implementations run in sequence before every SaveChanges / SaveChangesAsync call:

flowchart LR
    A[SaveChanges called] --> B[AuditedEntityInterceptor]
    B --> C[SoftDeleteInterceptor]
    C --> D[VersioningInterceptor]
    D --> E[Database]

Targets entities implementing IAuditedEntity. On every save:

StateFields set
AddedCreatedAt, CreatedBy, TenantId (if IMultiTenant)
ModifiedModifiedAt, ModifiedBy

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

public class Invoice : AggregateRoot, IAuditedEntity, 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; }
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 IVersionedEntity. On insert:

  • Assigns a BusinessId (human-readable sequential identifier scoped to the entity type).
  • Sets Version to max(Version) + 1 for that BusinessId.

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

ApplyGranitConventions scans all entity types in the model and registers a single HasQueryFilter per entity. The filter combines all applicable interfaces with AND logic:

InterfaceFilter
ISoftDeletableWHERE IsDeleted = false
IActiveWHERE IsActive = true
IMultiTenantWHERE TenantId = @currentTenantId
IProcessingRestrictableWHERE IsProcessingRestricted = false
IPublishableWHERE PublicationStatus = Published

An entity implementing both ISoftDeletable and IMultiTenant gets:

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

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" />

Inject ICurrentTenant? and IDataFilter? — both optional, defaulting to null:

public class InvoiceDbContext(
DbContextOptions<InvoiceDbContext> options,
ICurrentTenant? currentTenant = null,
IDataFilter? dataFilter = null)
: DbContext(options)
{
// ...
}

At the end of OnModelCreating, after all entity configurations:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(InvoiceDbContext).Assembly);
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);
}

4. Wire interceptors in the extension method

Section titled “4. Wire interceptors in the extension method”

Use the (IServiceProvider, DbContextOptionsBuilder) overload of AddDbContextFactory with ServiceLifetime.Scoped. Resolve interceptors from the service provider:

public static IServiceCollection AddInvoicePersistence(
this IServiceCollection services,
string connectionString)
{
services.AddDbContextFactory<InvoiceDbContext>((sp, options) =>
{
options.UseNpgsql(connectionString);
options.AddInterceptors(
sp.GetRequiredService<AuditedEntityInterceptor>(),
sp.GetRequiredService<SoftDeleteInterceptor>());
}, ServiceLifetime.Scoped);
return services;
}
[DependsOn(typeof(GranitPersistenceModule))]
public class InvoiceEntityFrameworkCoreModule : GranitModule
{
// ...
}

Already covered above. ApplyGranitConventions handles all standard filters. Manual filters cause duplicates or silent overrides.

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 IAuditedEntity, 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.