The Eventing block is the runtime side of the kit’s integration-event story. It ships two IEventBus implementations — InMemoryEventBus (synchronous, in-process) and RabbitMqEventBus (durable, cross-service) — an EF Core-backed outbox store for transactional delivery, an inbox store for receiver-side idempotency, a JsonEventSerializer, and the auto-discovery hook that registers every IIntegrationEventHandler<TEvent> in your module assemblies.
What it ships
Extensions
AddEventingCore(services, configuration)— registersJsonEventSerializer, a no-opIEventTenantScopedefault (the Multitenancy module swaps in a Finbuckle-backed one), and picks theIEventBusfromEventingOptions:Provider(InMemorydefault,RabbitMQ). Adds the outbox dispatcher hosted service whenUseHostedServiceDispatcheris true.AddEventingForDbContext<TDbContext>(services)— registersEfCoreOutboxStore<TDbContext>+EfCoreInboxStore<TDbContext>(scoped) plus theOutboxDispatcherscoped service.AddIntegrationEventHandlers(services, assemblies[])— scans the supplied assemblies forIIntegrationEventHandler<TEvent>implementations and registers them scoped in DI.
Event bus implementations
InMemoryEventBus— in-process. For each event it sets the tenant context first (IEventTenantScope.Begin(event.TenantId)), creates a fresh DI scope, resolves the matching handlers, checks the inbox (skip if already processed by that handler), awaits each handler in order, then marks the inbox row. Great for dev/test and single-process production hosts.RabbitMqEventBus— durable, cross-service; usesRabbitMQ.Clientand publishes to a durable topic exchange (EventingOptions:RabbitMQ).
Outbox / inbox
IOutboxStore(AddAsync,GetPendingBatchAsync,MarkAsProcessedAsync,MarkAsFailedAsync) +EfCoreOutboxStore<TDbContext>— serialize integration events into anOutboxMessagestable in your module’s DbContext.AddAsyncrides the same DbContext as your business write, so inside an open transaction the event commits atomically with it.IInboxStore+EfCoreInboxStore<TDbContext>— dedupe table keyed by (event id, handler name), so each handler processes an event at most once.OutboxDispatcher— scoped service; reads pending rows in batches (OutboxBatchSize, default 100), deserializes, publishes viaIEventBus, marks processed. A failing row incrementsRetryCount; afterOutboxMaxRetries(default 5) it’s flaggedIsDeadand skipped thereafter.OutboxDispatcherHostedService— background loop calling the dispatcher everyOutboxDispatchIntervalSeconds(default 10).OutboxMessage—Id,CreatedOnUtc,Type,Payload,TenantId,CorrelationId,ProcessedOnUtc,RetryCount,LastError,IsDead. ImplementsIGlobalEntity(background processors must scan across tenants).InboxMessage—Id+HandlerName(composite key),EventType,ProcessedOnUtc,TenantId. AlsoIGlobalEntity.
Serializer
JsonEventSerializer— System.Text.Json; the outbox stores the event’s type name alongside the payload so the dispatcher can rehydrate the concrete event for publishing.
How modules consume Eventing
Register against your DbContext during module startup:
public void ConfigureServices(IHostApplicationBuilder builder){ builder.Services.AddHeroDbContext<TicketsDbContext>(); builder.Services.AddEventingForDbContext<TicketsDbContext>();}There are two publish paths, and the kit uses both:
Durable (outbox) — write the event to IOutboxStore next to your business change; the dispatcher publishes it later. This is what Identity does for UserRegisteredIntegrationEvent:
public async ValueTask<Unit> Handle(ResolveTicketCommand cmd, CancellationToken ct){ var ticket = await _db.Tickets.FindAsync([cmd.TicketId], ct).ConfigureAwait(false); ticket!.Resolve(cmd.ResolutionNote); await _db.SaveChangesAsync(ct).ConfigureAwait(false);
await _outbox.AddAsync(new TicketResolvedIntegrationEvent(/* … */), ct).ConfigureAwait(false); return Unit.Value;}Immediate — call IEventBus.PublishAsync directly and handlers run right away (in-process with the InMemory bus). Chat’s mention events and the tenant-lifecycle events go this way; you trade durability for latency.
The OutboxDispatcher picks up pending rows on the next interval (or you can call DispatchAsync from a Hangfire job if you don’t want a hosted-service loop). The receiving side declares a handler:
public sealed class TicketResolvedNotifyHandler(/* … */) : IIntegrationEventHandler<TicketResolvedIntegrationEvent>{ public async Task HandleAsync(TicketResolvedIntegrationEvent evt, CancellationToken ct = default) { // write a notification, send an email, etc. }}Host registers handlers in bulk via AddIntegrationEventHandlers against all module marker assemblies:
builder.Services.AddIntegrationEventHandlers(moduleAssemblies);Configuration
{ "EventingOptions": { "Provider": "InMemory", // or "RabbitMQ" "OutboxBatchSize": 100, "OutboxMaxRetries": 5, "EnableInbox": true, "OutboxDispatchIntervalSeconds": 10, "UseHostedServiceDispatcher": true, "RabbitMQ": { "Host": "rabbitmq", "Port": 5672, "UserName": "guest", "Password": "guest", "VirtualHost": "/", "ExchangeName": "fsh.events", "QueuePrefix": "fsh", "UseSsl": false, "PublishRetryCount": 3, "PublishRetryDelayMs": 1000 } }}Set UseHostedServiceDispatcher = false when you’d rather drive the dispatcher from Hangfire on a fixed schedule (more deterministic for some ops setups).
How to extend
Add another transport
Implement IEventBus; register your implementation in place of InMemoryEventBus / RabbitMqEventBus. Bus consumers don’t care which one is wired.
Add a side-channel like Outbox-to-Kafka
OutboxDispatcher is small and replaceable. Subclass or wrap it to publish to Kafka in addition to RabbitMQ, or to write to multiple destinations.
Skip the outbox for cheap fire-and-forget
The outbox is opt-in: IEventBus.PublishAsync already goes straight to the bus. If an event doesn’t need transactional durability (a cache invalidation hint, a metrics ping), just publish it directly and skip IOutboxStore — that’s exactly what Chat and the tenant-lifecycle events do.
Gotchas
- Domain events vs integration events are different things in the kit. Domain events fire inside the SaveChanges interceptor (synchronous, in-module, via Mediator). Integration events go through the outbox and
IEventBus(asynchronous, cross-module / cross-service). Use the right one — domain events for invariants and bookkeeping inside the module, integration events for everything else. - Outbox dispatcher is scoped per cycle. Each poll gets a fresh DbContext scope; a failing publish increments the row’s
RetryCount(withLastErrorrecorded) and is retried on the next interval. AtOutboxMaxRetriesthe row is flaggedIsDeadand skipped from then on — monitor/clean dead rows, they don’t retry themselves. - Inbox dedupes per handler, by
(EventId, HandlerName). A redelivered event is skipped only for handlers that already completed it; if you mint two events with the sameId, the second is silently dropped for every handler. Always generate a freshGuidper event. - Background publishers need the tenant context set.
InMemoryEventBusdoes this for you viaIEventTenantScope(readingevent.TenantId) before resolving handlers — aMultiTenantDbContextcaptures its tenant at construction, so setting it later is too late. If you build your own bus or dispatch path, preserve this ordering or tenant-filtered handlers NRE. - RabbitMQ publishing retries in-process (
PublishRetryCount/PublishRetryDelayMs), but consumers own their durability via inbox + retry semantics.
Critical files
src/BuildingBlocks/Eventing/ServiceCollectionExtensions.cssrc/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cssrc/BuildingBlocks/Eventing/RabbitMq/RabbitMqEventBus.cssrc/BuildingBlocks/Eventing/Outbox/OutboxDispatcher.cssrc/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs
Related
- Eventing.Abstractions — the dependency-free contracts.
- Notifications module — consumes integration events into inbox rows.
- Webhooks module — uses an open-generic handler to fan all events to HTTP subscribers.