Skip to content

Persistence — PostgreSQL Provider

Granit.Persistence.Postgres is the PostgreSQL provider for Granit’s persistence layer. It wires three things that every production Postgres deployment needs but that the base Granit.Persistence package deliberately leaves abstract: a distributed migration lock, schema-per-tenant isolation, and a lightweight health check that doesn’t drag EF Core onto the readiness probe path.

  • DirectoryGranit.Persistence.Postgres/
    • DirectoryExtensions/
      • PersistencePostgresHostApplicationBuilderExtensions.cs AddGranitPostgres()
      • NpgsqlDbContextOptionsExtensions.cs UseGranitNpgsql()
      • NpgsqlHealthChecksBuilderExtensions.cs AddGranitPostgresHealthCheck()
    • DirectoryInternal/
      • NpgsqlAdvisoryMigrationLock.cs pg_try_advisory_lock implementation
      • NpgsqlTenantSchemaActivator.cs SET search_path per tenant
      • NpgsqlTenantDbIsolator.cs schema-per-tenant migration isolation
    • GranitPersistencePostgresModule.cs module entrypoint

Declare the module dependency in your host module and call AddGranitPostgres():

[DependsOn(typeof(GranitPersistencePostgresModule))]
public sealed class AppModule : GranitModule { }

GranitPersistencePostgresModule depends on GranitPersistenceHostingModule — you do not need to declare both.

Called automatically by GranitPersistencePostgresModule. Registers three services via TryAddSingleton — safe to call multiple times, only the first registration wins:

ServiceImplementationRole
IGranitMigrationLockNpgsqlAdvisoryMigrationLockDistributed migration lock via pg_try_advisory_lock
ITenantSchemaActivatorNpgsqlTenantSchemaActivatorExecutes SET search_path = <tenant_schema> on each connection
ITenantDbIsolatorNpgsqlTenantDbIsolatorIsolates a DbContext to a tenant schema before running migrations

You can also call it directly on the host builder for more control:

builder.AddGranitPostgres();

Configures a DbContextOptionsBuilder with Granit’s opinionated Npgsql defaults:

services.AddDbContextFactory<AppointmentDbContext>((sp, options) =>
{
options.UseGranitNpgsql(connectionString);
options.UseGranitInterceptors(sp);
});

What it configures under the hood:

SettingValueWhy
EnableRetryOnFailuremax 3 retries, 30 s max delayHandles transient network failures and Kubernetes pod restarts
CommandTimeout30 sPrevents slow queries from blocking connection pool slots indefinitely

Override individual settings by passing npgsqlOptionsAction:

options.UseGranitNpgsql(connectionString, npgsql =>
{
// Override the default command timeout for a reporting context
npgsql.CommandTimeout(120);
});

When multiple replicas start simultaneously (rolling deployment, K8s readiness probe race), each instance tries to run MigrateAsync(). Without coordination, concurrent migrations corrupt the __EFMigrationsHistory table or cause duplicate DDL errors.

NpgsqlAdvisoryMigrationLock wraps pg_try_advisory_lock, a PostgreSQL-native session-level advisory lock keyed by a 64-bit integer. The lock name is hashed via hashtext() so arbitrary string resource names map safely to an integer key.

sequenceDiagram
    participant R1 as Replica 1
    participant R2 as Replica 2
    participant PG as PostgreSQL

    R1->>PG: pg_try_advisory_lock(hashtext('migrations'))
    PG-->>R1: true (lock acquired)
    R2->>PG: pg_try_advisory_lock(hashtext('migrations'))
    PG-->>R2: false (lock busy)
    R2-->>R2: Skip migration
    R1->>PG: MigrateAsync()
    R1->>PG: pg_advisory_unlock(hashtext('migrations'))
    PG-->>R1: unlocked

The advisory lock uses a raw DbConnection, not an EF Core DbContext. Advisory locks are session-scoped in PostgreSQL: they are tied to the physical connection and released automatically when the connection closes. EF Core’s connection pooling returns connections to the pool between operations — silently releasing the lock before migrations finish.

The raw connection is held open for the full duration of the migration and disposed (releasing the lock) only when the IAsyncDisposable handle is disposed.

For SchemaPerTenant deployments, NpgsqlTenantSchemaActivator executes SET search_path = <schema> on every connection open, routing all queries to the correct tenant schema transparently.

NpgsqlTenantDbIsolator applies the same isolation before running per-tenant migrations, ensuring that dotnet ef migrations targets the right schema.

See Multi-tenancy for isolation strategy configuration and trade-offs.

AddGranitPostgresHealthCheck() adds a readiness probe that opens a raw NpgsqlConnection and executes SELECT 1. No EF Core, no ORM overhead — just a TCP connect and a round-trip query:

builder.Services
.AddHealthChecks()
.AddGranitPostgresHealthCheck(
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "postgres",
failureStatus: HealthStatus.Unhealthy,
tags: ["readiness", "startup"]);

Health checks fire on every probe (every few seconds in Kubernetes). Running them through EF Core would allocate a DbContext, resolve interceptors, and potentially trigger model initialization — unnecessary work on the hot probe path. A direct NpgsqlConnection + SELECT 1 is ~10× cheaper and tests exactly what the readiness probe needs to know: can the pod reach the database.

Map health checks in Program.cs:

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("readiness"),
});
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false, // liveness is a TCP check only
});

Alternatively, use AddGranitDbContextHealthCheck<TContext>() from Granit.Persistence when you want to probe via an existing DbContext. Use AddGranitPostgresHealthCheck() for a lighter, EF-free probe.

MemberKindDescription
GranitPersistencePostgresModuleModuleEntry point — [DependsOn(GranitPersistenceHostingModule)]
AddGranitPostgres()IHostApplicationBuilder extensionRegisters migration lock, schema activator, tenant isolator
UseGranitNpgsql()DbContextOptionsBuilder extensionOpinionated Npgsql config (retry + timeout)
AddGranitPostgresHealthCheck()IHealthChecksBuilder extensionRaw-connection SELECT 1 health check