Backend guides
Domain and integration events
Publish and handle domain and integration events over Wolverine, with the transactional outbox for reliability.
On this page
This is the how-to companion to CQRS and domain events, which covers the concepts. Here you wire the real thing: define an event, raise it from an aggregate, handle it, and route an integration event out to other services through the transactional outbox.
Two event types
Slicekit distinguishes two kinds of event, each with its own marker, location, and transport.
| Type | Marker | Where it lives | Scope | Transport |
|---|---|---|---|---|
| Domain event | IDomainEvent | src/Slicekit.Core/Domain/Events/ | In-process | Wolverine local queue |
| Integration event | IIntegrationEvent | src/Slicekit.Core/IntegrationEvents/ | Cross-context | RabbitMQ exchange slicekit.integration |
Both markers are empty interfaces in src/Slicekit.Core/Domain/Primitives/. The split is enforced by
EventTaxonomyTests: an IDomainEvent must live under Domain.Events, an IIntegrationEvent under
IntegrationEvents, and implementing both is rejected.
Keep the verbs straight: aggregates raise domain events to record a fact; dispatchers and handlers publish events to send them somewhere. They are not interchangeable.
1. Define a domain event
A domain event is a pure record marked with IDomainEvent. No EF Core, no ASP.NET, no Wolverine
types: anyone in any layer must be able to deserialize and react. Add it to a file under
src/Slicekit.Core/Domain/Events/ (the existing files group by aggregate, for example UserEvents.cs):
namespace Slicekit.Core.Domain.Events;
public sealed record ProjectCreatedEvent(Guid ProjectId, string Name) : IDomainEvent;
The Domain_Events_Must_Not_Depend_On_Infrastructure test keeps these records clean, so resist the
urge to reach for a DateTimeOffset.UtcNow default or an injected service inside the record.
2. Raise it from the aggregate
The aggregate owns its invariants and records what happened by raising the event. AggregateRoot
exposes a protected Raise that appends to an internal Events list; it sends nothing yet.
public sealed class Project : AggregateRoot<Guid>
{
public static Project Create(Guid ownerId, string name)
{
var project = new Project { Id = Guid.CreateVersion7(), OwnerId = ownerId, Name = name };
project.Raise(new ProjectCreatedEvent(project.Id, name));
return project;
}
}
User.Create in src/Slicekit.Core/Domain/User.cs is the canonical example: it raises
UserCreatedEvent the same way.
How raised events become published
The wiring lives in src/Slicekit.Core/Configuration/Messaging.cs:
opts.UseEntityFrameworkCoreTransactions();
opts.Policies.AutoApplyTransactions();
opts.PublishDomainEventsFromEntityFrameworkCore<IAggregateRoot>(x => x.Events);
After your slice handler calls db.SaveChangesAsync, Wolverine’s middleware scrapes each aggregate’s
Events collection and publishes them in-process over a durable local queue. Two consequences:
- Handlers run only if the transaction commits. Raised events live and die with
SaveChanges. A rollback drops them. AutoApplyTransactions()is load-bearing. Without it the scraper is registered but never engages, and raised events are silently discarded. If a handler that looks correct never fires, check this line first.
3. Handle the event
Handlers are discovered by convention: a *Handler class with a Handle method whose first parameter
is the event type. No interface, no registration. Place the handler inside the slice that owns the
reaction, alongside its command and handler.
using Slicekit.Core.Domain.Events;
using Wolverine;
namespace Slicekit.Core.Features.Projects.Welcome;
public sealed class NotifyProjectCreatedHandler(IMessageBus bus)
{
public ValueTask Handle(ProjectCreatedEvent evt) =>
bus.PublishAsync(new SendTemplateEmailCommand
{
Recipient = /* ... */,
Subject = "Your project is ready",
Template = EmailTemplate.Welcome,
Model = /* ... */
});
}
Many handlers can react to one event; they fan out independently. Real examples both react to
UserCreatedEvent: Features/Auth/Welcome/SendWelcomeEmailHandler.cs sends a welcome email, and
Auditing/SecurityAuditEventHandler.cs writes an audit row. A handler may take a CancellationToken
and inject services as extra parameters, exactly like a command handler.
4. Route an integration event through the outbox
An integration event is the public, stable contract that other services consume. It is published to
the slicekit.integration RabbitMQ exchange. The template ships with none: IntegrationEvents/ is empty
until your application defines its first cross-context contract.
Define it under src/Slicekit.Core/IntegrationEvents/, using only primitives, strings, and enums:
namespace Slicekit.Core.IntegrationEvents;
public sealed record OrderPlacedIntegrationEvent(
Guid OrderId,
Guid CustomerId,
decimal Total,
DateTimeOffset PlacedAtUtc) : IIntegrationEvent;
Integration_Events_Must_Not_Depend_On_Internal_Types blocks these from referencing Domain.Events,
Identity, Features, EF Core, or Wolverine. The contract must stay decoupled from your internals.
Do not publish an integration event from an aggregate. The aggregate raises a domain event; a separate translator handler turns that into the public contract:
// Features/Orders/PublishOrderPlacedIntegrationEvent/Handler.cs
public sealed class PublishOrderPlacedIntegrationEventHandler(IMessageBus bus)
{
public Task Handle(OrderPlacedEvent evt, CancellationToken ct) =>
bus.PublishAsync(new OrderPlacedIntegrationEvent(
evt.OrderId, evt.CustomerId, evt.Total, DateTimeOffset.UtcNow));
}
The routing is already configured in Messaging.cs:
opts.Publish(x => x.MessagesImplementing<IIntegrationEvent>().ToRabbitExchange("slicekit.integration"));
Outside the testing environment Wolverine persists outgoing messages with Postgres
(PersistMessagesWithPostgresql) and uses durable local queues. That is the transactional outbox: the
integration event is written in the same transaction as your state change and relayed to RabbitMQ once
the transaction commits. If the process dies mid-flight, the relay resumes, so delivery is
at-least-once. Write consumers to be idempotent.
Events are not commands
A domain event records a fact and fans out to any number of handlers. A Wolverine command
(SendTemplateEmailCommand, CleanupExpiredTokensCommand) targets exactly one handler and triggers a
specific action; it implements neither marker. Reach for a command when the producer has a single
receiver in mind, when the work must run after the transaction commits without influencing its outcome,
or when you want Wolverine’s retry semantics on a step that might fail. Commands ride the same outbox.
Verify
dotnet test api/tests/Slicekit.Architecture.Tests --nologo
EventTaxonomyTestsandLayerDependencyTestspass.- A new event in the wrong namespace fails
EventTaxonomyTests. - An integration event leaking a domain type fails
Integration_Events_Must_Not_Depend_On_Internal_Types.
Checklist
- Domain event is a pure record marked
IDomainEvent, underDomain/Events/. - The aggregate raises the event; nothing publishes from inside the aggregate.
- A
*Handlerwith aHandle(TEvent, ...)method lives in the reacting slice. - Cross-context contracts are
IIntegrationEventrecords underIntegrationEvents/, primitives only. - A translator handler maps the domain event to the integration event; the outbox carries it to RabbitMQ.
-
dotnet test api/tests/Slicekit.Architecture.Tests --nologois green.