Persistence
The problem
Section titled “The problem”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.
EF Core interceptors
Section titled “EF Core interceptors”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]
1. AuditedEntityInterceptor
Section titled “1. AuditedEntityInterceptor”Targets entities implementing IAuditedEntity. On every save:
| State | Fields set |
|---|---|
| Added | CreatedAt, CreatedBy, TenantId (if IMultiTenant) |
| Modified | ModifiedAt, 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; }}2. SoftDeleteInterceptor
Section titled “2. SoftDeleteInterceptor”Targets entities implementing ISoftDeletable. When EF Core detects a
Deleted state, the interceptor:
- Changes the state from
DeletedtoModified. - Sets
IsDeleted = true,DeletedAt, andDeletedBy. - 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; }}3. VersioningInterceptor
Section titled “3. VersioningInterceptor”Targets entities implementing IVersionedEntity. On insert:
- Assigns a
BusinessId(human-readable sequential identifier scoped to the entity type). - Sets
Versiontomax(Version) + 1for thatBusinessId.
This gives you an immutable version history without a separate history table.
Automatic query filters
Section titled “Automatic query filters”ApplyGranitConventions scans all entity types in the model and registers
a single HasQueryFilter per entity. The filter combines all applicable
interfaces with AND logic:
| Interface | Filter |
|---|---|
ISoftDeletable | WHERE IsDeleted = false |
IActive | WHERE IsActive = true |
IMultiTenant | WHERE TenantId = @currentTenantId |
IProcessingRestrictable | WHERE IsProcessingRestricted = false |
IPublishable | WHERE PublicationStatus = Published |
An entity implementing both ISoftDeletable and IMultiTenant gets:
WHERE "e"."IsDeleted" = FALSE AND "e"."TenantId" = @__tenantIdIsolated DbContext checklist
Section titled “Isolated DbContext checklist”Every *.EntityFrameworkCore package that owns a DbContext must
follow this checklist. No exceptions — the architecture tests enforce it.
1. ProjectReference to Granit.Persistence
Section titled “1. ProjectReference to Granit.Persistence”<ProjectReference Include="..\Granit.Persistence\Granit.Persistence.csproj" />2. Constructor injection
Section titled “2. Constructor injection”Inject ICurrentTenant? and IDataFilter? — both optional, defaulting
to null:
public class InvoiceDbContext( DbContextOptions<InvoiceDbContext> options, ICurrentTenant? currentTenant = null, IDataFilter? dataFilter = null) : DbContext(options){ // ...}3. Call ApplyGranitConventions
Section titled “3. Call ApplyGranitConventions”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;}5. DependsOn attribute
Section titled “5. DependsOn attribute”[DependsOn(typeof(GranitPersistenceModule))]public class InvoiceEntityFrameworkCoreModule : GranitModule{ // ...}6. No manual HasQueryFilter
Section titled “6. No manual HasQueryFilter”Already covered above. ApplyGranitConventions handles all standard
filters. Manual filters cause duplicates or silent overrides.
7. TenantId type
Section titled “7. TenantId type”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.
Data seeding
Section titled “Data seeding”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); }}Putting it all together
Section titled “Putting it all together”Here is what happens when you call SaveChangesAsync on an entity that
implements IAuditedEntity, ISoftDeletable, and IMultiTenant:
On insert:
AuditedEntityInterceptorsetsCreatedAt,CreatedBy,TenantId.SoftDeleteInterceptor— no action (entity is not being deleted).- Row is inserted.
On update:
AuditedEntityInterceptorsetsModifiedAt,ModifiedBy.SoftDeleteInterceptor— no action.- Row is updated.
On delete:
AuditedEntityInterceptorsetsModifiedAt,ModifiedBy.SoftDeleteInterceptorchanges state toModified, setsIsDeleted,DeletedAt,DeletedBy.- Row is updated (not deleted).
On query:
- EF Core applies the combined query filter:
WHERE IsDeleted = false AND TenantId = @tenantId - Application code sees only active, non-deleted, tenant-scoped records.
Further reading
Section titled “Further reading”- Persistence reference — configuration options, migration commands, and API surface
- Multi-Tenancy concept — tenant resolution and isolation strategies
- Compliance concept — how audit trails and soft delete support GDPR and ISO 27001