Skip to content

Unit of Work

The Unit of Work maintains a list of objects modified during a business transaction and coordinates atomic persistence of those changes. In Granit, DbContext is the Unit of Work: the ChangeTracker accumulates mutations, and SaveChangesAsync() persists them in a single transaction.

sequenceDiagram
    participant H as Handler / Endpoint
    participant DB as DbContext (Unit of Work)
    participant AI as AuditedEntityInterceptor
    participant VI as VersioningInterceptor
    participant DEI as DomainEventDispatcherInterceptor
    participant SDI as SoftDeleteInterceptor
    participant PG as PostgreSQL

    H->>DB: entity.Mutate()
    H->>DB: db.Add(newEntity)
    H->>DB: db.Remove(oldEntity)
    H->>DB: SaveChangesAsync()

    activate DB
    DB->>AI: SavingChanges -- CreatedAt/By, ModifiedAt/By, TenantId
    AI->>VI: SavingChanges -- BusinessId, Version
    VI->>DEI: SavingChanges -- collects IDomainEvent
    DEI->>SDI: SavingChanges -- DELETE to UPDATE (IsDeleted)
    SDI->>PG: BEGIN + INSERT/UPDATE
    PG-->>DB: COMMIT
    DB->>DEI: SavedChanges -- dispatch events
    deactivate DB
    DB-->>H: rows affected

Granit does not expose an explicit IUnitOfWork interface. The EF Core DbContext fulfills this role, augmented by a chain of interceptors that execute in a strictly defined order on each SaveChangesAsync().

Registered in src/Granit.Persistence/Extensions/DbContextOptionsBuilderExtensions.cs:

OrderInterceptorRole
1AuditedEntityInterceptorISO 27001 — CreatedAt/By, ModifiedAt/By, TenantId, auto-Id
2VersioningInterceptorBusinessId and Version on IVersioned entities
3DomainEventDispatcherInterceptorCollects IDomainEvent before save, dispatches after commit
4SoftDeleteInterceptorGDPR — converts DELETE to UPDATE (IsDeleted, DeletedAt/By)

The order is critical: SoftDeleteInterceptor is last because it changes EntityState.Deleted to Modified, which would hide the original state from preceding interceptors.

Each call to CreateDbContextAsync() returns a fresh DbContext instance — a new Unit of Work. Interceptors are injected automatically by the factory.

// Typical pattern: one UoW per operation
await using TContext db = await dbContextFactory
.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
db.DeliveryAttempts.Add(attempt);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

AutoApplyTransactions() wraps each Wolverine handler in a DB transaction. Outbox messages and domain changes are committed atomically in the same SaveChangesAsync().

src/Granit.Analyzers/SynchronousSaveChangesAnalyzer.cs emits a warning when synchronous SaveChanges() is called instead of SaveChangesAsync().

FileRole
src/Granit.Persistence/Interceptors/AuditedEntityInterceptor.csISO 27001 audit trail
src/Granit.Persistence/Interceptors/SoftDeleteInterceptor.csGDPR soft delete
src/Granit.Persistence/Interceptors/VersioningInterceptor.csAuto-versioning
src/Granit.Persistence/Interceptors/DomainEventDispatcherInterceptor.csAtomic domain events
src/Granit.Persistence/Extensions/DbContextOptionsBuilderExtensions.csInterceptor chain wiring
src/Granit.Persistence/Extensions/PersistenceServiceCollectionExtensions.csDI registration (all Scoped)
src/Granit.Wolverine.Postgresql/Extensions/WolverinePostgresqlHostApplicationBuilderExtensions.csTransactional outbox
src/Granit.Analyzers/SynchronousSaveChangesAnalyzer.csAnalyzer GR-EF001
ProblemUnit of Work solution
Partial writes on errorSaveChangesAsync() = atomic transaction
Audit trail scattered across each handlerInterceptors apply audit cross-cuttingly
Accidental hard-delete (GDPR)SoftDeleteInterceptor intercepts before DELETE
Domain events dispatched before commitDomainEventDispatcherInterceptor collects before, dispatches after
Synchronous SaveChanges() blocks the thread poolAnalyzer GR-EF001 detects and warns at compile-time
// The handler is unaware of interceptors -- they execute
// automatically on each SaveChangesAsync()
public static async Task Handle(
ArchivePatientCommand command,
PatientDbContext db,
CancellationToken cancellationToken)
{
Patient patient = await db.Patients.FindAsync([command.PatientId], cancellationToken)
?? throw new EntityNotFoundException(typeof(Patient), command.PatientId);
patient.Archive(); // ModifiedAt/By filled by AuditedEntityInterceptor
db.Remove(patient); // SoftDeleteInterceptor -> IsDeleted = true
// DomainEventDispatcherInterceptor collects PatientArchivedEvent
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
// 1 transaction: UPDATE (audit) + UPDATE (soft delete) + Outbox message
// Then dispatch PatientArchivedEvent
}