Entity Lifecycle Events
Every time you save an entity, the same boilerplate appears: emit a PatientCreatedEvent,
build a PatientEto, publish it before the commit so the outbox stays consistent.
Entity lifecycle events eliminate that boilerplate. Mark your entity with one interface,
and Granit auto-dispatches the right events at the right time — every time.
How it works
Section titled “How it works”EntityLifecycleEventInterceptor hooks into EF Core’s SaveChanges pipeline and
scans the change tracker for entities that opt into lifecycle events. For each
qualifying entity, it builds and dispatches the appropriate events automatically.
sequenceDiagram
participant App as Application
participant EF as EF Core
participant IC as EntityLifecycleEventInterceptor
participant DD as IDomainEventDispatcher
participant ID as IIntegrationEventDispatcher
App->>EF: SaveChangesAsync()
EF->>IC: SavingChanges (before SQL)
IC->>IC: Scan ChangeTracker
IC->>ID: DispatchAsync(ETOs) — outbox enrolled in same TX
EF->>EF: Execute SQL, COMMIT
EF->>IC: SavedChanges (after commit)
IC->>DD: DispatchAsync(domain events) — read committed data
Two dispatch timings are used deliberately:
| Phase | Events | Why |
|---|---|---|
SavingChanges (before commit) | ETOs — EntityCreatedEto<T>, … | Wolverine enrolls the outbox message in the same database transaction → atomic |
SavedChanges (after commit) | Domain events — EntityCreatedEvent<T>, … | Handlers can safely read committed data |
Opt-in levels
Section titled “Opt-in levels”Choose the level that matches your needs:
flowchart TD
A[Entity] --> B{Cross-service consumers?}
B -- No --> C[IEmitEntityLifecycleEvents\nLocal domain events only]
B -- Yes --> D[IHasEntityEto<TEto>\nLocal + distributed ETOs]
style C fill:#0ea5e9,color:#fff
style D fill:#6366f1,color:#fff
Level 1 — Local events only
Section titled “Level 1 — Local events only”public class Appointment : AggregateRoot, IEmitEntityLifecycleEvents{ public Guid PatientId { get; set; } public DateTimeOffset ScheduledAt { get; set; } public AppointmentStatus Status { get; private set; }}On SaveChanges, Granit auto-dispatches:
| EF Core state | IEmitEntityLifecycleEvents dispatch |
|---|---|
Added | EntityCreatedEvent<Appointment> |
Modified | EntityUpdatedEvent<Appointment> |
Modified + ISoftDeletable.IsDeleted flips false → true | EntityDeletedEvent<Appointment> |
Deleted | EntityDeletedEvent<Appointment> |
Level 2 — Local + distributed
Section titled “Level 2 — Local + distributed”public class Invoice : AggregateRoot, IHasEntityEto<InvoiceEto>{ public Guid PatientId { get; set; } public decimal TotalAmount { get; set; } public string Currency { get; set; } = "EUR"; public InvoiceStatus Status { get; private set; }
// Called by the interceptor before SaveChanges — snapshot of current state public InvoiceEto ToEto() => new(Id, PatientId, TotalAmount, Currency, Status);}
// Flat, serializable — crosses service boundaries safelypublic sealed record InvoiceEto( Guid InvoiceId, Guid PatientId, decimal TotalAmount, string Currency, InvoiceStatus Status);IHasEntityEto<TEto> extends IEmitEntityLifecycleEvents — you get both local and
distributed events automatically:
| EF Core state | Local event (after commit) | Distributed ETO (before commit) |
|---|---|---|
Added | EntityCreatedEvent<Invoice> | EntityCreatedEto<InvoiceEto> |
Modified | EntityUpdatedEvent<Invoice> | EntityUpdatedEto<InvoiceEto> |
| Soft delete | EntityDeletedEvent<Invoice> | EntityDeletedEto<InvoiceEto> |
Deleted | EntityDeletedEvent<Invoice> | EntityDeletedEto<InvoiceEto> |
Handling lifecycle events
Section titled “Handling lifecycle events”Local — domain event handler
Section titled “Local — domain event handler”using Granit.Events;
public sealed class AppointmentCreatedHandler( INotificationWriter notifications) : ILocalEventHandler<EntityCreatedEvent<Appointment>>{ public async Task HandleAsync( EntityCreatedEvent<Appointment> evt, CancellationToken ct = default) { // Entity is the live EF entity — read-only at this point (committed) await notifications.SendAsync( evt.Entity.PatientId, $"Appointment scheduled for {evt.Entity.ScheduledAt:f}", ct).ConfigureAwait(false); }}Register the handler:
services.AddScoped< ILocalEventHandler<EntityCreatedEvent<Appointment>>, AppointmentCreatedHandler>();Distributed — integration event handler
Section titled “Distributed — integration event handler”using Granit.Events;
public sealed class InvoiceCreatedIntegrationHandler : IDistributedEventHandler<EntityCreatedEto<InvoiceEto>>{ public async Task HandleAsync( EntityCreatedEto<InvoiceEto> evt, CancellationToken ct = default) { // evt.Eto is the flat, serialized snapshot — safe to use in any context InvoiceEto invoice = evt.Eto; await SyncToAccountingSystemAsync(invoice, ct).ConfigureAwait(false); }}With Wolverine, Granit auto-discovers handler methods — no explicit DI registration needed for distributed handlers.
EntityLifecycleEventInterceptor is registered automatically by GranitPersistenceModule.
No extra configuration is required to enable lifecycle events.
[DependsOn(typeof(GranitPersistenceModule))]public sealed class AppModule : GranitModule { }Local domain events (EntityCreatedEvent<T>, …) are dispatched via IDomainEventDispatcher
after SaveChanges. With GranitEventBusModule, they route to registered
ILocalEventHandler<T> implementations.
[DependsOn( typeof(GranitPersistenceModule), typeof(GranitEventBusWolverineModule))] // Wolverine replaces NullIntegrationEventDispatcherpublic sealed class AppModule : GranitModule { }GranitEventBusWolverineModule registers WolverineIntegrationEventDispatcher, which
replaces the default no-op. ETOs are dispatched via IMessageBus and enrolled in
Wolverine’s transactional outbox — atomically with the SaveChanges transaction.
Designing a good ETO
Section titled “Designing a good ETO”An ETO (Event Transfer Object) is a flat, serializable DTO. The same rules as any
IIntegrationEvent apply — but entity lifecycle ETOs have one additional constraint:
ToEto() is called before the commit, so the snapshot must be self-contained.
// ✅ GOOD — primitive types, Guids, value typespublic sealed record PatientEto( Guid PatientId, string FirstName, string LastName, DateOnly DateOfBirth, bool IsActive);
// ❌ BAD — lazy-loaded navigation, not serializablepublic sealed record PatientEto( Patient Patient, // EF entity — not serializable ICollection<Appointment> Appointments); // Lazy-loaded, causes N+1
// ❌ BAD — mutable, capturing internal state improperlypublic sealed record PatientEto( ICurrentTenant Tenant); // DI service reference — crashes in deserializerKeep ETOs flat and stable. Adding properties is backwards-compatible (consumers ignore unknown fields). Removing or renaming properties is a breaking change.
Soft delete detection
Section titled “Soft delete detection”EntityLifecycleEventInterceptor maps soft deletes to EntityDeletedEvent<T> —
not EntityUpdatedEvent<T> — when it detects the ISoftDeletable.IsDeleted property
transitioning from false to true:
// Entity modified, IsDeleted transitions false → true:// → EntityDeletedEvent<T> (NOT EntityUpdatedEvent<T>)// → EntityDeletedEto<TEto> (NOT EntityUpdatedEto<TEto>)This means consumers handle deletion uniformly, regardless of whether it is a hard
delete (EntityState.Deleted) or a GDPR soft delete.
Explicit integration events on aggregates
Section titled “Explicit integration events on aggregates”Entity lifecycle events cover the generic CRUD case. For domain-specific integration
events — where the event payload depends on the business operation, not just
the entity state — use AddDistributedEvent() on your aggregate root:
public sealed class LegalAgreement : AggregateRoot{ public Guid PatientId { get; private set; } public bool IsSigned { get; private set; }
public void Sign(Guid signedBy, DateTimeOffset signedAt) { IsSigned = true;
// Domain event — local, dispatched after commit AddDomainEvent(new AgreementSignedEvent(Id, signedBy));
// Integration event — distributed, dispatched before commit (outbox) AddDistributedEvent(new AgreementSignedIntegration(Id, PatientId, signedAt)); }}DomainEventDispatcherInterceptor collects both:
| Source | Collected in | Dispatched in |
|---|---|---|
IDomainEventSource.DomainEvents (via AddDomainEvent) | SavingChanges | SavedChanges (after commit) |
IIntegrationEventSource.IntegrationEvents (via AddDistributedEvent) | SavingChanges | SavingChanges (before commit — outbox) |
Performance notes
Section titled “Performance notes”EntityLifecycleEventInterceptor uses compiled Expression factories cached in a
ConcurrentDictionary keyed by (entityType, EntityState). After the first
SaveChanges for a given entity type, factory lookup is as fast as a dictionary
read — no MakeGenericType, no Activator.CreateInstance on the hot path.
Similarly, IEntityEtoProvider.GetEto() is resolved via a compiled expression that
calls ToEto() through the interface dispatch — zero per-call reflection.
Testing
Section titled “Testing”[Fact]public async Task SaveChangesAsync_AddedInvoice_EmitsEntityCreatedEvent(){ // Arrange IDomainEventDispatcher domainDispatcher = Substitute.For<IDomainEventDispatcher>(); domainDispatcher.DispatchAsync(Arg.Any<IReadOnlyList<IDomainEvent>>(), Arg.Any<CancellationToken>()) .Returns(Task.CompletedTask);
IIntegrationEventDispatcher integrationDispatcher = Substitute.For<IIntegrationEventDispatcher>(); integrationDispatcher.DispatchAsync(Arg.Any<IReadOnlyList<IIntegrationEvent>>(), Arg.Any<CancellationToken>()) .Returns(Task.CompletedTask);
EntityLifecycleEventInterceptor interceptor = new(domainDispatcher, integrationDispatcher); DbContextOptions<TestDbContext> options = new DbContextOptionsBuilder<TestDbContext>() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(interceptor) .Options;
await using TestDbContext context = new(options); context.Invoices.Add(new Invoice { PatientId = Guid.NewGuid(), TotalAmount = 150m });
// Act await context.SaveChangesAsync();
// Assert — domain event dispatched after commit await domainDispatcher.Received(1).DispatchAsync( Arg.Is<IReadOnlyList<IDomainEvent>>(events => events.Count == 1 && events[0] is EntityCreatedEvent<Invoice>), Arg.Any<CancellationToken>());
// Assert — ETO dispatched before commit (Wolverine outbox) await integrationDispatcher.Received(1).DispatchAsync( Arg.Is<IReadOnlyList<IIntegrationEvent>>(events => events.Count == 1 && events[0] is EntityCreatedEto<InvoiceEto>), Arg.Any<CancellationToken>());}Public API summary
Section titled “Public API summary”| Type | Kind | Package | Role |
|---|---|---|---|
IEmitEntityLifecycleEvents | Interface | Granit | Opt-in marker — local domain events |
IHasEntityEto<TEto> | Interface | Granit | Opt-in — local + distributed ETOs |
IEntityEtoProvider | Interface | Granit | Bridge for non-generic interceptor dispatch |
EntityCreatedEvent<T> | Record | Granit | IDomainEvent emitted on insert |
EntityUpdatedEvent<T> | Record | Granit | IDomainEvent emitted on update |
EntityDeletedEvent<T> | Record | Granit | IDomainEvent emitted on delete (hard or soft) |
EntityCreatedEto<TEto> | Record | Granit | IIntegrationEvent emitted on insert |
EntityUpdatedEto<TEto> | Record | Granit | IIntegrationEvent emitted on update |
EntityDeletedEto<TEto> | Record | Granit | IIntegrationEvent emitted on delete |
EntityLifecycleEventInterceptor | Interceptor | Granit.Persistence | EF Core hook — auto-dispatch |
IIntegrationEventDispatcher | Interface | Granit | Dispatch channel for ETOs |
NullIntegrationEventDispatcher | Implementation | Granit.Persistence | No-op default — replaced by Wolverine |
WolverineIntegrationEventDispatcher | Implementation | Granit.EventBus.Wolverine | Outbox-backed production dispatch |
See also
Section titled “See also”- Event Bus — local and distributed event bus concepts, handler registration
- Interceptors — full interceptor pipeline (audit, soft delete, domain events)
- Persistence — isolated DbContext pattern,
AggregateRoot.AddDomainEvent() - Wolverine — outbox, at-least-once delivery, context propagation