Skip to content
Slicekit

Backend guides

Domain and integration events

Publish and handle domain and integration events over Wolverine, with the transactional outbox for reliability.

View .md
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.

TypeMarkerWhere it livesScopeTransport
Domain eventIDomainEventsrc/Slicekit.Core/Domain/Events/In-processWolverine local queue
Integration eventIIntegrationEventsrc/Slicekit.Core/IntegrationEvents/Cross-contextRabbitMQ 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
  • EventTaxonomyTests and LayerDependencyTests pass.
  • 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, under Domain/Events/.
  • The aggregate raises the event; nothing publishes from inside the aggregate.
  • A *Handler with a Handle(TEvent, ...) method lives in the reacting slice.
  • Cross-context contracts are IIntegrationEvent records under IntegrationEvents/, 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 --nologo is green.