A domain event is an immutable record that captures what happened. Inherit from DomainEvent (which implements IDomainEvent) and include all data consumers need:
Key design rule: include enough data in the event so that handlers never need cross-boundary entity access. The event is the contract between boundaries.
If you prefer not to use the base record, implement IDomainEvent directly:
When you need to raise an event on a persistence lifecycle transition (created / updated / deleted)
and the event’s data comes straight from the entity, you can skip the hand-written RaiseEvent() call.
Declare it with [Raises<TEvent>(on: ...)] (from Pragmatic.Authoring) on an entity that derives from
DomainEventSource — the source generator wires the raise, and the LifecycleEventsInterceptor fires it
at the right moment, filling the event constructor from entity members that match by name.
usingPragmatic.Authoring;
usingPragmatic.Events;
[Entity<Guid>]
[Raises<OrderPlaced>] // On = EntityLifecycle.Created (the default)
[Raises<OrderCancelled>(EntityLifecycle.Deleted)] // stackable — one attribute per event
// OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total, ...) is filled from Id/CustomerId/Total.
}
When the entity is inserted, OrderPlaced is raised and dispatched automatically after the commit — no
handler-side or entity-side code beyond the attribute. (A soft delete is treated as Deleted, not
Updated.)
How On behaves:
On an entity deriving from DomainEventSource, the generator raises the event at the On
transition, filling the constructor from matching entity members (no custom body).
On a domain method — a mutation, an action — the generator wires the raise as well, so do not
raise the event in the body too or it goes out twice; On is ignored there.
On defaults to EntityLifecycle.Created.
Diagnostics:
PRAG2750 — an entity with [Raises<T>] must derive from DomainEventSource (otherwise the
generated events cannot be raised).
PRAG2751 — an event constructor parameter that matches no entity member is passed default
(warning); rename the parameter to match, or raise the event from a domain method instead.
For events that carry data not present on the entity, keep raising them explicitly with RaiseEvent()
(Step 2) or from a domain method.
Handlers can inject any service from DI. They run as internal calls (authorization filters are skipped), so handler-triggered actions are not blocked by the HTTP user’s permissions.
Option A also registers the in-memory dispatcher (via TryAdd), so an application that wants an
outbox instead registers its own first and wins. Option B does not — call
AddInMemoryDomainEvents() yourself alongside it, or the handlers sit in DI with nothing to call
them.
Add the interceptor to your DbContext configuration:
usingPragmatic.Events.EFCore;
services.AddDbContext<AppDbContext>(options=>
{
options.UseNpgsql(connectionString);
options.UseDomainEvents(); // LifecycleEventsInterceptor — raises [Raises<T>], nothing more
});
The interceptor raises; it does not dispatch. After SaveChangesAsync() commits, the events are taken off the tracked IHasDomainEvents entities and dispatched by whoever performed the write — the generated invoker of a mutation or action, the batch of a composition, or EfCoreUnitOfWork itself for a plain save. All three run in the scope that asked for the write, so a handler sees the tenant and the user of the request. No manual dispatch code needed.
Dispatch above happens after the commit — but if the process dies in that window, the events
are lost. When you need them to survive a crash (or to fan out reliably across replicas), turn on the
transactional outbox. It ships in Pragmatic.Events.EFCore (namespace
Pragmatic.Events.EFCore.Outbox) — you do not need Pragmatic.Messaging.
The outbox writes each event into an __EventOutbox table in the same transaction as the entity
change, and a background service delivers them. Wiring is three steps:
3. Register the outbox (interceptor, options, and the background delivery service):
usingPragmatic.Events.EFCore.Outbox;
services.AddEventOutbox<AppDbContext>(o=>
{
o.BatchSize =100; // entries per poll (>= 1, default 100)
o.PollingInterval = TimeSpan.FromSeconds(5); // between polls (> 0, default 5s)
o.MaxAttempts =5; // before a row is left as poison (>= 1, default 5)
});
What you get and what to keep in mind:
At-least-once delivery. An event can be dispatched more than once (crash between dispatch and
mark-processed). Write idempotent handlers — IDomainEvent.EventId is a natural dedup key, which is
another reason to inherit from DomainEvent.
Multi-replica safe. Rows are claimed atomically (ClaimedBy/ClaimedUntil), so two workers never
deliver the same entry.
Poison messages. After MaxAttempts the row is abandoned — no dead-letter table; inspect its
LastError column.
Trace + tenant propagation. The W3C trace context and the originating tenant are restored across the
async delivery boundary.
Combines cleanly with UseDomainEvents. The outbox interceptor clears the entity’s events at
capture time, so the post-commit hand-over finds nothing left to take: the event is delivered once,
through the outbox.
Source-generated DbContext? You don’t hand-wire the three steps above. Mark the boundary
[EnableEventOutbox] and the generator maps __EventOutbox, adds the interceptor, registers the
delivery service, and includes the table in the schema metadata. The boundary project must reference
Pragmatic.Events.EFCore (otherwise the generator emits PRAG2752 instead of silently doing nothing).
Two boundaries sharing one physical database share a single __EventOutbox table.
Events are the recommended mechanism for cross-boundary communication:
Booking boundary Billing boundary
----------------- ------------------
Reservation.Confirm()
-> RaiseEvent(ReservationConfirmed)
-> SaveChangesAsync()
-> post-commit hand-over (invoker, batch, or unit of work)
-> ReservationConfirmedHandler (creates invoice)
Invoice.MarkAsPaid()
-> RaiseEvent(InvoicePaid)
-> SaveChangesAsync()
-> post-commit hand-over (invoker, batch, or unit of work)
-> InvoicePaidHandler (notifies Booking)
Handlers interact with other boundaries through typed boundary interfaces (IBillingActions, IBookingActions), never through direct entity or repository access. Events carry all the data handlers need.