Events — Local & Distributed Event Bus with Wolverine
Think of events in two ways: a phone call (local — instant, same room, you hear the answer immediately) and registered mail (distributed — crosses boundaries, guaranteed delivery, but you don’t know when). Granit’s dual event bus, inspired by ABP Framework, gives you both — and lets you swap the transport without touching business code.
Package structure
Section titled “Package structure”DirectoryGranit/Events/ Interfaces — always available, zero dependencies
- ILocalEventBus publish in-process events
- ILocalEventHandler<T> handle local events
- IDistributedEventBus publish cross-service integration events
- IDistributedEventHandler<T> handle distributed events
- IIntegrationEvent marker for serializable event DTOs
DirectoryGranit.Events/ Default in-process providers (monolith, tests)
- InProcessLocalEventBus resolves handlers from DI, calls sequentially
- InProcessDistributedEventBus same — dev/test only, not durable
DirectoryGranit.Events.Wolverine/ Wolverine-backed providers (production)
- WolverineLocalEventBus publishes to Wolverine local queue
- WolverineDistributedEventBus publishes via IMessageBus with outbox
| Package | Role | When to use |
|---|---|---|
Granit | ILocalEventBus, IDistributedEventBus interfaces | Always available — no extra dependency |
Granit.Events | InProcessLocalEventBus, InProcessDistributedEventBus | Monoliths, unit tests, development |
Granit.Events.Wolverine | WolverineLocalEventBus, WolverineDistributedEventBus | Production — outbox-backed delivery |
When to use Local vs Distributed
Section titled “When to use Local vs Distributed”flowchart TD
A[I need to publish an event] --> B{Will consumers be\nin a different service?}
B -- Yes --> D["IDistributedEventBus\n(cross-service, durable)"]
B -- No --> C{Need at-least-once\ndelivery guarantee?}
C -- Yes --> D
C -- No --> E["ILocalEventBus\n(in-process, synchronous)"]
style D fill:#6366f1,color:#fff
style E fill:#0ea5e9,color:#fff
Comparison table
Section titled “Comparison table”| Aspect | ILocalEventBus | IDistributedEventBus |
|---|---|---|
| Scope | Same process | Cross-process, cross-service |
| TEvent constraint | class (any C# class) | IIntegrationEvent (serializable DTO only) |
| Delivery | Synchronous, in-process | At-least-once via outbox |
| Ordering | Guaranteed (sequential in caller thread) | Not guaranteed (network, retries, parallelism) |
| Retry on failure | No — exception propagates to caller | Yes — provider-dependent backoff |
| Serialization | None (same CLR process) | JSON (must cross process boundary) |
| Survives crash | No — in-memory only | Yes — outbox persists before dispatch |
| Default provider | InProcessLocalEventBus | InProcessDistributedEventBus (dev only) |
| Production provider | WolverineLocalEventBus | WolverineDistributedEventBus |
When to use which — real-world examples
Section titled “When to use which — real-world examples”| Use case | Bus | Why |
|---|---|---|
| Cache invalidation after setting change | Local | Same process, handler runs synchronously before response returns |
| Audit trail persistence (ISO 27001) | Local | Audit handler lives in same bounded context |
| Feature flag change logging | Local | In-process handler via Granit.Auditing.ConfigurationChanges |
| Data import/export job completion | Local | Notification handlers in same service (for now) |
| Cross-service workflow trigger | Distributed | Consumer may be in billing, notification, or analytics service |
| External system notification (webhook, ERP) | Distributed | Needs durability — crash must not lose the event |
Events vs Commands
Section titled “Events vs Commands”Events and commands solve different problems. Do not mix them.
| Event | Command | |
|---|---|---|
| Consumers | 0 or more (broadcast) | Exactly 1 (point-to-point) |
| Delivery | Fire-and-forget | Must be processed |
| Retry / DLQ | No (local) or provider-dependent (distributed) | Yes — exponential backoff, dead letter queue |
| Abstraction | ILocalEventBus / IDistributedEventBus | Module-specific dispatchers |
| Provider-swappable? | Yes | No (Wolverine-specific) |
| Example | SettingChangedEvent | ExecuteImportCommand |
Quick start — Local event
Section titled “Quick start — Local event”Step 1. Define the event as a record:
namespace MyApp.Events;
public sealed record OrderPlacedEvent(Guid OrderId, string CustomerEmail, DateTimeOffset Timestamp);Step 2. Create the handler:
using Granit.Events;
namespace MyApp.Handlers;
public sealed class OrderPlacedAuditHandler(IAuditingWriter writer) : ILocalEventHandler<OrderPlacedEvent>{ public async Task HandleAsync(OrderPlacedEvent localEvent, CancellationToken ct = default) { // Persist audit entry, send internal notification, update cache... await writer.WriteAsync(BuildAuditEntry(localEvent), ct).ConfigureAwait(false); }}Step 3. Register the handler in DI:
services.AddScoped<ILocalEventHandler<OrderPlacedEvent>, OrderPlacedAuditHandler>();Step 4. Publish from your business service:
public sealed class OrderService(ILocalEventBus eventBus, TimeProvider time){ public async Task PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct) { // ... create order in database ... await eventBus.PublishAsync( new OrderPlacedEvent(order.Id, request.CustomerEmail, time.GetUtcNow()), ct) .ConfigureAwait(false); }}Quick start — Distributed event
Section titled “Quick start — Distributed event”Step 1. Define the event — must implement IIntegrationEvent:
using Granit.Events;
namespace MyApp.IntegrationEvents;
// Flat, serializable DTO — no EF entities, no IQueryable, no internal domain objects.public sealed record OrderShippedEto( Guid OrderId, string TrackingNumber, DateTimeOffset ShippedAt) : IIntegrationEvent;Step 2. Create the handler:
public sealed class ShippingNotificationHandler : IDistributedEventHandler<OrderShippedEto>{ public async Task HandleAsync(OrderShippedEto integrationEvent, CancellationToken ct = default) { // Notify external ERP, send customer email, update tracking dashboard... }}Step 3. Publish:
await distributedEventBus.PublishAsync( new OrderShippedEto(orderId, tracking, timeProvider.GetUtcNow()), ct);Handler discovery and registration
Section titled “Handler discovery and registration”With Granit.Events (in-process provider), handlers are resolved from DI.
You must register them explicitly:
// In your module's ConfigureServices or DI extension:services.AddScoped<ILocalEventHandler<OrderPlacedEvent>, OrderPlacedAuditHandler>();services.AddScoped<IDistributedEventHandler<OrderShippedEto>, ShippingNotificationHandler>();Multiple handlers per event type are supported — all are called sequentially.
With Granit.Events.Wolverine, handlers that follow Wolverine’s naming
convention are auto-discovered. But ILocalEventHandler<T> implementations
still need explicit DI registration — Wolverine discovers by convention
(HandleAsync method), not by interface.
// Register in your module:services.AddScoped<ILocalEventHandler<OrderPlacedEvent>, OrderPlacedAuditHandler>();The Wolverine provider publishes to IMessageBus, and Wolverine routes to
handlers via its own discovery. Both paths work in parallel.
Provider configuration
Section titled “Provider configuration”Registered automatically by AddGranitEvents(). No configuration needed.
// Already registered by Settings, Features, DataExchange modules.// Or explicitly in your host:services.AddGranitEvents();Replaces in-process defaults when Granit.Events.Wolverine is loaded.
builder.AddGranitWolverine();builder.AddGranitWolverineWithPostgresql(); // Outbox + transport
// Granit.Events.Wolverine auto-replaces via [DependsOn] module systemEvents published via IDistributedEventBus are now outbox-backed:
they survive crashes, get retried on failure, and are delivered at-least-once.
For multi-tenant deployments with isolated databases per tenant:
builder.AddGranitWolverineWithPostgresqlPerTenant<AppDbContext>();Wolverine propagates X-Tenant-Id in message headers. The handler-side
TenantContextBehavior restores the tenant context automatically, ensuring
EF Core query filters apply correctly in background handlers.
Transactional guarantees and the outbox pattern
Section titled “Transactional guarantees and the outbox pattern”With the in-process provider, there are no guarantees. If the process crashes between publishing and handling, the event is lost.
With Wolverine, distributed events use the transactional outbox pattern:
sequenceDiagram
participant S as Service
participant DB as Database
participant OB as Outbox Table
participant W as Wolverine Worker
S->>DB: INSERT order
S->>OB: INSERT outbox message
Note over DB,OB: Same transaction — atomic
S->>DB: COMMIT
W->>OB: Read pending messages
W->>W: Deliver to handler
W->>OB: Mark as processed
The event is written to the outbox in the same database transaction as the business data. If the process crashes after COMMIT, Wolverine picks up the pending message on restart. This guarantees at-least-once delivery.
Integration event best practices
Section titled “Integration event best practices”// ✅ GOOD — flat record, serializable, only primitives and Guidspublic sealed record InvoiceGeneratedEto( Guid InvoiceId, Guid CustomerId, decimal TotalAmount, string Currency, DateTimeOffset GeneratedAt) : IIntegrationEvent;
// ❌ BAD — leaks EF entity, not serializable across servicespublic sealed record InvoiceGeneratedEto( Invoice Invoice, // EF entity — breaks module boundary Customer Customer, // Internal domain object IQueryable<LineItem> Items // Not serializable at all) : IIntegrationEvent;Naming: use XxxEto for integration events (e.g., OrderShippedEto) and XxxEvent
for domain events (e.g., OrderPlacedEvent). Never *Dto, never *Message.
Versioning: adding new fields is safe (JSON deserialization ignores unknowns). Removing or renaming fields is a breaking change — introduce a new event type instead.
Multi-tenancy and context propagation
Section titled “Multi-tenancy and context propagation”In the in-process provider, the handler inherits the caller’s ICurrentTenant
and ICurrentUserService context (same DI scope, same AsyncLocal).
With Wolverine, context is propagated via message headers:
| Header | Source | Restored by |
|---|---|---|
X-Tenant-Id | ICurrentTenant.Id | TenantContextBehavior |
X-User-Id | ICurrentUserService.UserId | UserContextBehavior |
X-User-FirstName | ICurrentUserService.FirstName | UserContextBehavior |
X-Actor-Kind | ActorKind (User, System, ExternalSystem) | UserContextBehavior |
traceparent | Activity.Current?.Id (W3C Trace Context) | TraceContextBehavior |
Testing
Section titled “Testing”Unit test: verify event is published
Section titled “Unit test: verify event is published”[Fact]public async Task PlaceOrder_PublishesOrderPlacedEvent(){ // Arrange ILocalEventBus eventBus = Substitute.For<ILocalEventBus>(); OrderService service = new(eventBus, TimeProvider.System);
// Act
// Assert await eventBus.Received(1).PublishAsync( Arg.Any<CancellationToken>());}Integration test: verify handler is called
Section titled “Integration test: verify handler is called”[Fact]public async Task OrderPlacedAuditHandler_PersistsAuditEntry(){ // Arrange — use InProcessLocalEventBus with real handler ServiceCollection services = new(); services.AddGranitEvents(); services.AddScoped<ILocalEventHandler<OrderPlacedEvent>, OrderPlacedAuditHandler>(); services.AddScoped<IAuditingWriter, FakeAuditingWriter>(); ServiceProvider sp = services.BuildServiceProvider();
ILocalEventBus bus = sp.GetRequiredService<ILocalEventBus>();
// Act await bus.PublishAsync(new OrderPlacedEvent(Guid.NewGuid(), "[email protected]", DateTimeOffset.UtcNow));
// Assert FakeAuditingWriter writer = (FakeAuditingWriter)sp.GetRequiredService<IAuditingWriter>(); writer.Entries.ShouldHaveSingleItem();}Common pitfalls
Section titled “Common pitfalls”Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause | Fix |
|---|---|---|
| Handler never called | Not registered in DI | Add services.AddScoped<ILocalEventHandler<T>, ...>() |
| Distributed event lost on crash | Using in-process provider | Install Granit.Events.Wolverine + outbox |
| Handler runs but tenant is wrong | Missing TenantContextBehavior | Ensure Granit.Wolverine is loaded |
| Handler called multiple times | At-least-once delivery (normal) | Make handler idempotent |
InProcessDistributedEventBus warning | Expected in dev | Switch to Wolverine for production |
Public API reference
Section titled “Public API reference”| Category | Key types | Package |
|---|---|---|
| Local bus | ILocalEventBus, ILocalEventHandler<T> | Granit |
| Distributed bus | IDistributedEventBus, IDistributedEventHandler<T> | Granit |
| Integration marker | IIntegrationEvent | Granit |
| Integration dispatcher | IIntegrationEventDispatcher | Granit |
| In-process providers | InProcessLocalEventBus, InProcessDistributedEventBus | Granit.Events |
| Wolverine providers | WolverineLocalEventBus, WolverineDistributedEventBus, WolverineIntegrationEventDispatcher | Granit.Events.Wolverine |
| DI extension | AddGranitEvents() | Granit.Events |
| Modules | GranitEventsModule, GranitEventsWolverineModule | respective packages |
Architecture diagram
Section titled “Architecture diagram”graph TD
subgraph "Business Modules"
SM[SettingManager]
FS[EfCoreFeatureStore]
EO[ExportOrchestrator]
end
subgraph "Event Bus Abstraction (Granit)"
LEB[ILocalEventBus]
DEB[IDistributedEventBus]
end
subgraph "Providers (swappable)"
IP["InProcessLocalEventBus\n(Granit.Events)"]
WL["WolverineLocalEventBus\n(Granit.Events.Wolverine)"]
WD["WolverineDistributedEventBus\n(Granit.Events.Wolverine)"]
end
subgraph "Handlers"
AH[Audit Log Handlers]
CH[Cache Invalidation]
EH[External Service Handlers]
end
SM --> LEB
FS --> LEB
EO --> LEB
LEB -.->|monolith| IP
LEB -.->|production| WL
DEB -.->|production| WD
IP --> AH
IP --> CH
WL --> AH
WL --> CH
WD --> EH
style LEB fill:#0ea5e9,color:#fff
style DEB fill:#6366f1,color:#fff
style IP fill:#94a3b8,color:#fff
style WL fill:#f59e0b,color:#fff
style WD fill:#f59e0b,color:#fff
See also
Section titled “See also”- Audit Log — audit trail leveraging local event bus
- Settings — publishes
SettingChangedEventvia local bus - Features — publishes
FeatureOverrideChangedEventvia local bus - Wolverine — message bus, outbox, and context propagation
- Persistence — domain events via
IDomainEventDispatcher