Getting Started with Pragmatic.Events
This guide walks through raising and handling your first domain event, from event definition to EF Core auto-dispatch.
Prerequisites
Section titled “Prerequisites”Add the package references to your module’s .csproj:
<!-- Core events (required) --><PackageReference Include="Pragmatic.Events" />
<!-- EF Core auto-dispatch (optional, for automatic dispatch after SaveChanges) --><PackageReference Include="Pragmatic.Events.EFCore" />Step 1: Define a Domain Event
Section titled “Step 1: Define a Domain Event”A domain event is an immutable record that captures what happened. Inherit from DomainEvent (which implements IDomainEvent) and include all data consumers need:
using Pragmatic.Events;
namespace MyApp.Booking.Events;
public sealed record ReservationConfirmed( Guid ReservationId, Guid GuestId, decimal TotalAmount, string Currency, DateTimeOffset OccurredAt) : DomainEvent(OccurredAt);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:
public sealed record ReservationConfirmed( Guid ReservationId, Guid GuestId, decimal TotalAmount, string Currency, DateTimeOffset OccurredAt) : IDomainEvent;Step 2: Raise Events from Your Entity
Section titled “Step 2: Raise Events from Your Entity”Make your entity inherit from DomainEventSource and call RaiseEvent() during domain operations:
using Pragmatic.Events;using Pragmatic.Persistence.Entity;
[Entity<Guid>]public partial class Reservation : DomainEventSource, IEntity<Guid>{ public ReservationStatus Status { get; private set; } public decimal TotalAmount { get; private set; } public string Currency { get; private set; } = "EUR";
public VoidResult<IError> Confirm() { var result = TransitionTo(ReservationStatus.Confirmed); if (result.IsSuccess) { RaiseEvent(new ReservationConfirmed( Id, GuestId, TotalAmount, Currency, DateTimeOffset.UtcNow)); } return result; }}Events accumulate inside the entity. They are not dispatched until the entity is persisted and the EF Core interceptor fires.
You can also raise events during entity creation with a factory method:
public static Reservation Create(Guid guestId, decimal amount, string currency){ var reservation = new Reservation { GuestId = guestId, TotalAmount = amount, Currency = currency, Status = ReservationStatus.Pending };
reservation.RaiseEvent(new ReservationCreated( reservation.Id, guestId, amount, currency, DateTimeOffset.UtcNow));
return reservation;}Alternative: IHasDomainEvents
Section titled “Alternative: IHasDomainEvents”If your entity already has a base class (e.g., TPH/TPC hierarchy), implement IHasDomainEvents manually:
public class Reservation : MyExistingBaseClass, IHasDomainEvents{ private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly(); public void ClearDomainEvents() => _domainEvents.Clear();
public VoidResult<IError> Confirm() { // ... _domainEvents.Add(new ReservationConfirmed(/* ... */)); // ... }}The EF Core interceptor works with any entity that implements IHasDomainEvents.
Step 3: Create an Event Handler
Section titled “Step 3: Create an Event Handler”Implement IDomainEventHandler<T> and mark the class with [EventHandler] for source generator discovery:
using Pragmatic.Events;using Pragmatic.Events.Attributes;
namespace MyApp.Billing.EventHandlers;
[EventHandler]public sealed class ReservationConfirmedHandler( IBillingActions billingActions) : IDomainEventHandler<ReservationConfirmed>{ public async Task HandleAsync(ReservationConfirmed @event, CancellationToken ct = default) { await billingActions.CreateDraftInvoice( reservationId: @event.ReservationId, guestId: @event.GuestId, totalAmount: @event.TotalAmount, currency: @event.Currency, ct: ct).ConfigureAwait(false); }}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.
Step 4: Register Services
Section titled “Step 4: Register Services”using Pragmatic.Events.Extensions;
// Register the in-memory dispatcherbuilder.Services.AddInMemoryDomainEvents();
// Option A: Register handlers individuallybuilder.Services.AddDomainEventHandler<ReservationConfirmedHandler, ReservationConfirmed>();
// Option B: Scan assembly (not AOT-safe)builder.Services.AddDomainEventHandlersFromAssembly(typeof(ReservationConfirmedHandler).Assembly);
// Option C: Use [EventHandler] attribute + SG-generated registration (AOT-safe, recommended)// The Composition SG generates AddPragmaticEventHandlers() for classes marked with [EventHandler].Step 5: Wire Up EF Core Auto-Dispatch
Section titled “Step 5: Wire Up EF Core Auto-Dispatch”Add the interceptor to your DbContext configuration:
using Pragmatic.Events.EFCore;
services.AddDbContext<AppDbContext>((sp, options) =>{ options.UseNpgsql(connectionString); options.UseDomainEvents(sp); // adds DomainEventsInterceptor});After SaveChangesAsync() commits, the interceptor automatically collects events from all tracked IHasDomainEvents entities, clears them, and dispatches them. No manual dispatch code needed.
Step 6: Test Your Events
Section titled “Step 6: Test Your Events”Test that the entity raises the event:
[Fact]public void Confirm_RaisesReservationConfirmedEvent(){ var reservation = Reservation.Create(guestId, 300m, "EUR"); reservation.ClearDomainEvents(); // clear creation event
reservation.Confirm();
reservation.DomainEvents.Should().ContainSingle() .Which.Should().BeOfType<ReservationConfirmed>() .Which.TotalAmount.Should().Be(300m);}Test the handler in isolation:
[Fact]public async Task ReservationConfirmedHandler_CreatesDraftInvoice(){ var billingActions = Substitute.For<IBillingActions>(); var handler = new ReservationConfirmedHandler(billingActions);
await handler.HandleAsync(new ReservationConfirmed( Guid.NewGuid(), Guid.NewGuid(), 300m, "EUR", DateTimeOffset.UtcNow));
await billingActions.Received(1).CreateDraftInvoice( Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Is(300m), Arg.Is("EUR"), Arg.Any<CancellationToken>());}Cross-Boundary Pattern
Section titled “Cross-Boundary Pattern”Events are the recommended mechanism for cross-boundary communication:
Booking boundary Billing boundary----------------- ------------------Reservation.Confirm() -> RaiseEvent(ReservationConfirmed) -> SaveChangesAsync() -> DomainEventsInterceptor -> ReservationConfirmedHandler (creates invoice)
Invoice.MarkAsPaid() -> RaiseEvent(InvoicePaid) -> SaveChangesAsync() -> DomainEventsInterceptor -> 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.
Next Steps
Section titled “Next Steps”- See internals.md for dispatcher mechanics, error handling, and observability details.
- See the Showcase application (
examples/showcase/) for a full working example with Booking and Billing boundaries.