| name | add-integration-event |
| description | Publish a cross-module integration event via the Outbox and handle it idempotently in another module. Use when one module must react to something that happened in another. See .agents/rules/eventing.md. |
| argument-hint | [SourceModule] [EventName] [ConsumerModule] |
Add Integration Event
Cross-module communication goes through integration events + the Outbox (transactional, crash-safe) —
never a direct in-process call into another module's runtime, and never IEventBus.PublishAsync from a
handler. Full model: .agents/rules/eventing.md.
Step 1 — Define the event (source module's Contracts)
Modules.{Source}.Contracts/Events/{Event}IntegrationEvent.cs — implement IIntegrationEvent:
public sealed record {Event}IntegrationEvent(
Guid Id,
DateTime OccurredOnUtc,
string? TenantId,
string CorrelationId,
string Source,
Guid {Entity}Id,
string SomePayload) : IIntegrationEvent;
⚠️ Don't rename/move this type later — the outbox stores its assembly-qualified name; a rename makes
Type.GetType() return null and the message dead-letters. Keep the type name + namespace stable.
Step 2 — Publish via the Outbox (source handler)
Publishing needs no module registration — the outbox is framework-owned and the host wires it once. Inject IOutboxWriter (from FSH.Framework.Eventing.Abstractions) and add the event in the same unit of work:
public sealed class Do{Thing}CommandHandler({Source}DbContext db, IOutboxWriter outbox)
: ICommandHandler<Do{Thing}Command, Unit>
{
public async ValueTask<Unit> Handle(Do{Thing}Command command, CancellationToken cancellationToken)
{
var evt = new {Event}IntegrationEvent(
Id: Guid.CreateVersion7(),
OccurredOnUtc: DateTime.UtcNow,
TenantId: ,
CorrelationId: Guid.NewGuid().ToString(),
Source: "{Source}",
{Entity}Id: entity.Id,
SomePayload: "…");
await outbox.AddAsync(evt, cancellationToken).ConfigureAwait(false);
return Unit.Value;
}
}
The OutboxDispatcherHostedService later publishes it via IEventBus.
Step 3 — Handle it (consumer module)
Modules.{Consumer}/IntegrationEventHandlers/{Event}IntegrationEventHandler.cs — sealed, implement IIntegrationEventHandler<T>:
public sealed class {Event}IntegrationEventHandler({Consumer}DbContext db )
: IIntegrationEventHandler<{Event}IntegrationEvent>
{
public async Task HandleAsync({Event}IntegrationEvent @event, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(@event);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
Register the consumer's handlers in its ConfigureServices:
builder.Services.AddIntegrationEventHandlers(typeof({Consumer}Module).Assembly);
Gotchas
- Idempotency is free with the in-memory bus (the Inbox dedups by
{eventId, handlerName}) — don't hand-roll it.
- The in-memory bus runs handlers synchronously in the publisher's scope — keep the handler lean; a throw surfaces to the originating request. Published via the outbox, that scope belongs to the dispatcher, so the consumer runs on the next cycle and its failures never reach the caller. Don't let a caller (or a test) assume the side effect already happened; integration tests drain with
OutboxDrain.DrainAsync.
- If the handler reads a tenant-filtered DbContext from a background path (open-generic handler, Hangfire job), restore Finbuckle context first via
IMultiTenantContextSetter (see WebhookFanoutHandler).
- Module load order: the consumer must load before the publisher if it must react (
Order in [assembly: FshModule]) — e.g. Notifications (750) before Chat (800).
Checklist