Skip to content

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.

  • 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
PackageRoleWhen to use
GranitILocalEventBus, IDistributedEventBus interfacesAlways available — no extra dependency
Granit.EventsInProcessLocalEventBus, InProcessDistributedEventBusMonoliths, unit tests, development
Granit.Events.WolverineWolverineLocalEventBus, WolverineDistributedEventBusProduction — outbox-backed delivery
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
AspectILocalEventBusIDistributedEventBus
ScopeSame processCross-process, cross-service
TEvent constraintclass (any C# class)IIntegrationEvent (serializable DTO only)
DeliverySynchronous, in-processAt-least-once via outbox
OrderingGuaranteed (sequential in caller thread)Not guaranteed (network, retries, parallelism)
Retry on failureNo — exception propagates to callerYes — provider-dependent backoff
SerializationNone (same CLR process)JSON (must cross process boundary)
Survives crashNo — in-memory onlyYes — outbox persists before dispatch
Default providerInProcessLocalEventBusInProcessDistributedEventBus (dev only)
Production providerWolverineLocalEventBusWolverineDistributedEventBus
Use caseBusWhy
Cache invalidation after setting changeLocalSame process, handler runs synchronously before response returns
Audit trail persistence (ISO 27001)LocalAudit handler lives in same bounded context
Feature flag change loggingLocalIn-process handler via Granit.Auditing.ConfigurationChanges
Data import/export job completionLocalNotification handlers in same service (for now)
Cross-service workflow triggerDistributedConsumer may be in billing, notification, or analytics service
External system notification (webhook, ERP)DistributedNeeds durability — crash must not lose the event

Events and commands solve different problems. Do not mix them.

EventCommand
Consumers0 or more (broadcast)Exactly 1 (point-to-point)
DeliveryFire-and-forgetMust be processed
Retry / DLQNo (local) or provider-dependent (distributed)Yes — exponential backoff, dead letter queue
AbstractionILocalEventBus / IDistributedEventBusModule-specific dispatchers
Provider-swappable?YesNo (Wolverine-specific)
ExampleSettingChangedEventExecuteImportCommand

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);
}
}

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);

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.

Registered automatically by AddGranitEvents(). No configuration needed.

// Already registered by Settings, Features, DataExchange modules.
// Or explicitly in your host:
services.AddGranitEvents();

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.

// ✅ GOOD — flat record, serializable, only primitives and Guids
public sealed record InvoiceGeneratedEto(
Guid InvoiceId,
Guid CustomerId,
decimal TotalAmount,
string Currency,
DateTimeOffset GeneratedAt) : IIntegrationEvent;
// ❌ BAD — leaks EF entity, not serializable across services
public 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.

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:

HeaderSourceRestored by
X-Tenant-IdICurrentTenant.IdTenantContextBehavior
X-User-IdICurrentUserService.UserIdUserContextBehavior
X-User-FirstNameICurrentUserService.FirstNameUserContextBehavior
X-Actor-KindActorKind (User, System, ExternalSystem)UserContextBehavior
traceparentActivity.Current?.Id (W3C Trace Context)TraceContextBehavior
[Fact]
public async Task PlaceOrder_PublishesOrderPlacedEvent()
{
// Arrange
ILocalEventBus eventBus = Substitute.For<ILocalEventBus>();
OrderService service = new(eventBus, TimeProvider.System);
// Act
await service.PlaceOrderAsync(new PlaceOrderRequest("[email protected]"), CancellationToken.None);
// Assert
await eventBus.Received(1).PublishAsync(
Arg.Is<OrderPlacedEvent>(e => e.CustomerEmail == "[email protected]"),
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();
}
SymptomLikely causeFix
Handler never calledNot registered in DIAdd services.AddScoped<ILocalEventHandler<T>, ...>()
Distributed event lost on crashUsing in-process providerInstall Granit.Events.Wolverine + outbox
Handler runs but tenant is wrongMissing TenantContextBehaviorEnsure Granit.Wolverine is loaded
Handler called multiple timesAt-least-once delivery (normal)Make handler idempotent
InProcessDistributedEventBus warningExpected in devSwitch to Wolverine for production
CategoryKey typesPackage
Local busILocalEventBus, ILocalEventHandler<T>Granit
Distributed busIDistributedEventBus, IDistributedEventHandler<T>Granit
Integration markerIIntegrationEventGranit
Integration dispatcherIIntegrationEventDispatcherGranit
In-process providersInProcessLocalEventBus, InProcessDistributedEventBusGranit.Events
Wolverine providersWolverineLocalEventBus, WolverineDistributedEventBus, WolverineIntegrationEventDispatcherGranit.Events.Wolverine
DI extensionAddGranitEvents()Granit.Events
ModulesGranitEventsModule, GranitEventsWolverineModulerespective packages
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
  • Audit Log — audit trail leveraging local event bus
  • Settings — publishes SettingChangedEvent via local bus
  • Features — publishes FeatureOverrideChangedEvent via local bus
  • Wolverine — message bus, outbox, and context propagation
  • Persistence — domain events via IDomainEventDispatcher