Skip to content

Granit.Persistence

Granit.Persistence provides the data access layer for the framework. It automates ISO 27001 audit trails, GDPR-compliant soft delete, domain event dispatching, entity versioning, and multi-tenant query filtering — all through EF Core interceptors and conventions. No manual WHERE clauses, no forgotten audit fields.

  • DirectoryGranit.Persistence/ Base interceptors, conventions, data seeding
    • Granit.Persistence.Migrations Zero-downtime batch migrations (Expand & Contract)
    • Granit.Persistence.Migrations.Wolverine Durable outbox-backed migration dispatch
PackageRoleDepends on
Granit.PersistenceInterceptors, ApplyGranitConventions, data seedingGranit.Core, Granit.Timing, Granit.Guids, Granit.Security
Granit.Persistence.MigrationsExpand & Contract migrations with batch processingGranit.Persistence, Granit.Timing
Granit.Persistence.Migrations.WolverineDurable migration dispatch via Wolverine outboxGranit.Persistence.Migrations, Granit.Wolverine
graph TD
    P[Granit.Persistence] --> C[Granit.Core]
    P --> T[Granit.Timing]
    P --> G[Granit.Guids]
    P --> S[Granit.Security]
    PM[Granit.Persistence.Migrations] --> P
    PM --> T
    PMW[Granit.Persistence.Migrations.Wolverine] --> PM
    PMW --> W[Granit.Wolverine]
[DependsOn(typeof(GranitPersistenceModule))]
public class AppModule : GranitModule { }

Registers all four interceptors and IDataFilter.

Every *.EntityFrameworkCore package that owns a DbContext must follow this checklist. No exceptions.

public class AppointmentDbContext(
DbContextOptions<AppointmentDbContext> options,
ICurrentTenant? currentTenant = null,
IDataFilter? dataFilter = null)
: DbContext(options)
{
public DbSet<Appointment> Appointments => Set<Appointment>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppointmentDbContext).Assembly);
// MANDATORY — applies all query filters (soft delete, multi-tenant, active, etc.)
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);
}
}

Registration in the module:

public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddGranitDbContext<AppointmentDbContext>(options =>
{
options.UseNpgsql(context.Configuration.GetConnectionString("Default"));
});
}

AddGranitDbContext<T> handles interceptor wiring automatically — it calls UseGranitInterceptors(sp) internally with ServiceLifetime.Scoped.

Four interceptors execute on every SaveChanges call, in this order:

OrderInterceptorBehavior
1AuditedEntityInterceptorPopulates CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, auto-generates Id, injects TenantId
2VersioningInterceptorSets BusinessId and Version on IVersioned entities
3DomainEventDispatcherInterceptorCollects domain events before save, dispatches after commit
4SoftDeleteInterceptorConverts DELETE to UPDATE for ISoftDeletable entities

Resolves audit data from DI:

  • Who: ICurrentUserService.UserId (falls back to "system")
  • When: IClock.Now (never DateTime.UtcNow)
  • Id: IGuidGenerator (sequential GUIDs for clustered indexes)
Entity stateAction
AddedSets CreatedAt, CreatedBy. Auto-generates Id if Guid.Empty. Injects TenantId if IMultiTenant and null.
ModifiedProtects CreatedAt/CreatedBy from overwrite. Sets ModifiedAt, ModifiedBy.

Converts physical DELETE to soft delete:

DELETE FROM Patients WHERE Id = @id
↓ intercepted ↓
UPDATE Patients SET IsDeleted = true, DeletedAt = @now, DeletedBy = @userId WHERE Id = @id

Only applies to entities implementing ISoftDeletable.

Collects and dispatches domain events transactionally:

  1. SavingChanges — scans change tracker for IDomainEventSource entities, collects events, clears event lists
  2. SavedChanges — dispatches events after commit via IDomainEventDispatcher
  3. SaveChangesFailed — discards events (transaction rolled back)

Uses AsyncLocal<List<IDomainEvent>> for thread safety across concurrent SaveChanges calls in the same async flow.

Default dispatcher is NullDomainEventDispatcher (no-op). Granit.Wolverine replaces it with real message bus dispatch.

For IVersioned entities on EntityState.Added:

  • Generates BusinessId if empty (first version of a new entity)
  • Determines Version from change tracker (starting at 1)
  • Modified entities are untouched — versioning is explicit (create a new entity with same BusinessId)

ApplyGranitConventions builds a single combined HasQueryFilter per entity using expression trees. Five filter types, joined with AND:

InterfaceFilter expressionDefault
ISoftDeletable!e.IsDeletedEnabled
IActivee.IsActiveEnabled
IProcessingRestrictable!e.IsProcessingRestrictedEnabled
IMultiTenante.TenantId == currentTenant.IdEnabled
IPublishablee.IsPublishedEnabled

Filters are re-evaluated per query via DataFilter’s AsyncLocal state — EF Core extracts FilterProxy boolean properties as parameterized expressions.

Use IDataFilter to selectively bypass filters:

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.

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);

Register seed contributors that run at application startup:

public class CountryDataSeedContributor(AppDbContext db) : IDataSeedContributor
{
public async Task SeedAsync(DataSeedContext context, CancellationToken cancellationToken)
{
if (await db.Countries.AnyAsync(cancellationToken).ConfigureAwait(false))
return;
db.Countries.AddRange(
new Country { Code = "BE", Name = "Belgium" },
new Country { Code = "FR", Name = "France" });
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}

Register with services.AddTransient<IDataSeedContributor, CountryDataSeedContributor>().

DataSeedingHostedService orchestrates all contributors at startup. Errors are logged but do not block application start.

Hard-delete soft-deleted records past retention (ISO 27001):

await dbContext.PurgeSoftDeletedBeforeAsync<Patient>(
cutoff: DateTimeOffset.UtcNow.AddYears(-3),
batchSize: 1000,
cancellationToken);

Uses ExecuteDeleteAsync() with IgnoreQueryFilters() — provider-agnostic, bypasses interceptors.

Granit.Persistence supports three tenant isolation topologies:

StrategyIsolationComplexityUse case
SharedDatabaseQuery filter (TenantId column)LowMost applications
SchemaPerTenantPostgreSQL schema per tenantMediumStronger isolation, shared infra
DatabasePerTenantSeparate database per tenantHighMaximum isolation, regulated data

Configuration:

{
"TenantIsolation": {
"Strategy": "SharedDatabase"
},
"TenantSchema": {
"NamingConvention": "TenantId",
"Prefix": "tenant_"
}
}

Built-in schema activators: PostgresqlTenantSchemaActivator, MySqlTenantSchemaActivator, OracleTenantSchemaActivator.

Granit.Persistence.Migrations implements the Expand & Contract pattern for schema changes that would otherwise require downtime.

stateDiagram-v2
    [*] --> Expand: Add new column (nullable/default)
    Expand --> Migrate: Background batch backfill
    Migrate --> Contract: Remove old column
    Contract --> [*]
PhaseEF MigrationApplication
ExpandALTER TABLE ADD COLUMN (nullable)Writes to both old and new columns
MigrateBatch job backfills new columnReads from new column with fallback
ContractALTER TABLE DROP COLUMN (old)Reads/writes new column only
[MigrationCycle(MigrationPhase.Expand, "patient-fullname-v2")]
public partial class AddPatientFullName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>("FullName", "Patients", nullable: true);
}
}
registry.Register<AppDbContext>("patient-fullname-v2", async (context, batch, ct) =>
{
var patients = await context.Set<Patient>()
.Where(p => p.FullName == null)
.OrderBy(p => p.Id)
.Take(batch.Size)
.ToListAsync(ct)
.ConfigureAwait(false);
foreach (var p in patients)
p.FullName = $"{p.FirstName} {p.LastName}";
await context.SaveChangesAsync(ct).ConfigureAwait(false);
return new MigrationBatchResult(
patients.Count,
patients.Count < batch.Size ? null : patients[^1].Id.ToString());
});

Batch delegates must be idempotent — re-running over already-migrated rows must have no side effects.

{
"GranitMigrations": {
"DefaultBatchSize": 500,
"BatchExecutionTimeout": "00:05:00"
}
}
Granit.Persistence.Migrations+ .Wolverine
DispatchIn-memory Channel<T>Durable outbox
Restart safetyLost batches on crashSurvives restarts
Multi-instanceSingle node onlyDistributed

With Wolverine, the handler returns object[] — empty to stop, or [nextCommand] to cascade to the next batch via the durable outbox.

CategoryKey typesPackage
ModuleGranitPersistenceModuleGranit.Persistence
InterceptorsAuditedEntityInterceptor, SoftDeleteInterceptor, DomainEventDispatcherInterceptor, VersioningInterceptorGranit.Persistence
ExtensionsAddGranitPersistence(), AddGranitDbContext<T>(), UseGranitInterceptors(), ApplyGranitConventions()Granit.Persistence
Data seedingIDataSeeder, IDataSeedContributor, DataSeedContextGranit.Persistence
PurgingISoftDeletePurgeTarget, PurgeSoftDeletedBeforeAsync()Granit.Persistence
Query extensionsIncludeTranslations(), WhereTranslation(), OrderByTranslation()Granit.Persistence
Multi-tenancyTenantIsolationStrategy, ITenantSchemaProvider, ITenantConnectionStringProvider, ITenantSchemaActivatorGranit.Persistence
MigrationsMigrationPhase, MigrationCycleAttribute, IMigrationCycleRegistry, BatchMigrationDelegateGranit.Persistence.Migrations
Migration dispatchIMigrationBatchDispatcher, RunMigrationBatchCommandGranit.Persistence.Migrations
Wolverine dispatchGranitPersistenceMigrationsWolverineModuleGranit.Persistence.Migrations.Wolverine