Skip to content
fullstackhero

Reference

Eventing.Abstractions building block

Dependency-free contracts for cross-module and cross-service events — IIntegrationEvent, IIntegrationEventHandler, IEventBus, IEventSerializer.

views 0 Last updated

The Eventing.Abstractions block exists for one reason: contracts assemblies should not pull in EF Core, RabbitMQ, or the Eventing runtime when they declare an event. This block has zero NuGet dependencies — just five interfaces. It’s the smallest building block in the kit.

What it ships

Five interfaces. That’s it.

IIntegrationEvent.cs
public interface IIntegrationEvent
{
Guid Id { get; }
DateTime OccurredOnUtc { get; }
string? TenantId { get; } // null for global, non-tenant-scoped events
string CorrelationId { get; } // ties events to requests and traces
string Source { get; } // e.g. "Modules.Identity"
}
// IIntegrationEventHandler.cs
public interface IIntegrationEventHandler<in TEvent> where TEvent : IIntegrationEvent
{
Task HandleAsync(TEvent @event, CancellationToken ct = default);
}
// IEventBus.cs
public interface IEventBus
{
Task PublishAsync(IIntegrationEvent @event, CancellationToken ct = default);
Task PublishAsync(IEnumerable<IIntegrationEvent> events, CancellationToken ct = default);
}
// IEventSerializer.cs
public interface IEventSerializer
{
string Serialize(IIntegrationEvent @event);
IIntegrationEvent? Deserialize(string payload, string eventTypeName);
}
// IEventTenantScope.cs — ambient tenant context for event dispatch
public interface IEventTenantScope
{
IDisposable Begin(string? tenantId);
}

IEventTenantScope is the piece that makes background publishing safe in a multi-tenant world: the bus calls Begin(event.TenantId) before resolving handlers, because a MultiTenantDbContext captures its tenant at construction time. The default implementation is a no-op; the Multitenancy module registers a Finbuckle-backed one.

Pattern: where each interface lives

src/Modules/Notifications/
├── Modules.Notifications.Contracts/
│ └── (no references except Eventing.Abstractions + Core)
└── Modules.Notifications/
└── (references Eventing — gets InMemoryEventBus, OutboxDispatcher, etc.)
src/Modules/Chat/
├── Modules.Chat.Contracts/
│ ├── IntegrationEvents/MentionedInChannelIntegrationEvent.cs
│ │ └── implements IIntegrationEvent (Eventing.Abstractions)
│ └── (no reference to Modules.Notifications.Contracts)
└── Modules.Chat/
└── publishes via IEventBus (Eventing.Abstractions)

When Chat publishes a MentionedInChannelIntegrationEvent, Notifications consumes it through IIntegrationEventHandler<MentionedInChannelIntegrationEvent>. Neither runtime references the other; both just reference the event type declared in Modules.Chat.Contracts. The Eventing runtime (and your event bus of choice) wires the publish-to-subscribe path at runtime.

How modules consume the contracts

Define a custom event in your contracts assembly — this is Chat’s real mention event:

src/Modules/Chat/Modules.Chat.Contracts/Events/MentionedInChannelIntegrationEvent.cs
public sealed record MentionedInChannelIntegrationEvent(
Guid Id,
DateTime OccurredOnUtc,
string? TenantId,
string CorrelationId,
string Source,
Guid ChannelId,
string? ChannelName,
Guid MessageId,
string AuthorUserId,
string MentionedUserId,
string BodyPreview) : IIntegrationEvent;

Publish from a handler in the runtime module:

await eventBus.PublishAsync(new MentionedInChannelIntegrationEvent(
Id: Guid.NewGuid(),
OccurredOnUtc: DateTime.UtcNow,
TenantId: currentUser.GetTenant(),
CorrelationId: correlationId,
Source: "Modules.Chat",
/* …payload fields… */), ct).ConfigureAwait(false);

Consume from another module’s runtime project:

public sealed class MentionedInChannelNotifyHandler(/* deps */)
: IIntegrationEventHandler<MentionedInChannelIntegrationEvent>
{
public Task HandleAsync(MentionedInChannelIntegrationEvent evt, CancellationToken ct = default)
{
// ...
}
}

Gotchas

  • No base class. IIntegrationEvent is a pure interface. Use sealed records to implement it; do not invent a base class in this block — it would force every contracts assembly to pull in that base type’s transitive deps.
  • Source is a free-form string. Use the canonical module name (Modules.Tickets, Modules.Chat) so logs and diagnostics stay greppable.
  • TenantId is a nullable string. Global, non-tenant-scoped events carry a null TenantId; consumers that key on tenant must guard (the Webhooks fanout handler skips null-tenant events entirely). The tenant id also drives IEventTenantScope — a null id leaves the ambient tenant context unchanged.
  • CorrelationId is a non-nullable string. It exists to tie events back to the request/trace that caused them — propagate the incoming correlation id, don’t invent a new one mid-chain.
  • Handler registration is open-generic friendly. The Webhooks module registers WebhookFanoutHandler<TEvent> against the open IIntegrationEventHandler<>, so one handler type covers every event. If you do this, be deliberate about the side effects.

Critical files

  • src/BuildingBlocks/Eventing.Abstractions/IIntegrationEvent.cs
  • src/BuildingBlocks/Eventing.Abstractions/IIntegrationEventHandler.cs
  • src/BuildingBlocks/Eventing.Abstractions/IEventBus.cs
  • src/BuildingBlocks/Eventing.Abstractions/IEventSerializer.cs
  • src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs
  • Eventing — the implementation block with InMemoryEventBus, RabbitMqEventBus, outbox/inbox stores.
  • Webhooks module — uses the open-generic IIntegrationEventHandler<> pattern to fan every event to subscribers.
  • Notifications module — consumes integration events into per-user inbox rows.