Skip to content

Getting Started with Pragmatic.Events

This guide walks through raising and handling your first domain event, from event definition to EF Core auto-dispatch.

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" />

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;

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;
}

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.

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.

using Pragmatic.Events.Extensions;
// Register the in-memory dispatcher
builder.Services.AddInMemoryDomainEvents();
// Option A: Register handlers individually
builder.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].

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.

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>());
}

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.

  • 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.