Skip to main content
Version: 3.1.3

In-Memory Events

The in-memory event bus provides local publish/subscribe within a single process. Events are dispatched synchronously to all registered ISubscriber<TEvent> handlers within the same DI scope. No external infrastructure is required.

Installation​

The in-memory event bus is part of RCommon.Core, which is included when you install the main package:

NuGet Package
dotnet add package RCommon.Core

Configuration​

Register the in-memory event bus using WithEventHandling<InMemoryEventBusBuilder> on the RCommon builder, then call AddSubscriber to wire each handler to its event type:

using RCommon;
using RCommon.EventHandling;
using RCommon.EventHandling.Producers;

builder.Services.AddRCommon()
.WithEventHandling<InMemoryEventBusBuilder>(eventHandling =>
{
eventHandling.AddSubscriber<OrderPlaced, OrderPlacedHandler>();
eventHandling.AddSubscriber<OrderShipped, OrderShippedHandler>();
});

AddSubscriber automatically ensures PublishWithEventBusEventProducer — the built-in producer that delegates to IEventBus.PublishAsync — is registered; a subscriber with no matching producer is never invoked, so there is no scenario where you'd want one wired without the other. Calling AddProducer<PublishWithEventBusEventProducer>() explicitly still works and is harmless (idempotent), but is no longer required for the common case:

builder.Services.AddRCommon()
.WithEventHandling<InMemoryEventBusBuilder>(eventHandling =>
{
// Explicit and still supported, but redundant as of this version -- AddSubscriber
// below registers this automatically.
eventHandling.AddProducer<PublishWithEventBusEventProducer>();
eventHandling.AddSubscriber<OrderPlaced, OrderPlacedHandler>();
});

Additional subscribers can be registered for the same event type; all of them will be called.

Additional AddSubscriber / AddProducer overloads​

If your handler needs constructor arguments that DI can't resolve on its own, use the factory-delegate overload of AddSubscriber instead of the parameterless one:

eventHandling.AddSubscriber<OrderPlaced, OrderPlacedHandler>(
sp => new OrderPlacedHandler(sp.GetRequiredService<ILogger<OrderPlacedHandler>>(), extraArg: 42));

AddProducer<T> has two additional overloads beyond the parameterless AddProducer<T>() shown above:

// Factory delegate -- same idea as AddSubscriber's factory overload.
eventHandling.AddProducer<MyCustomProducer>(sp => new MyCustomProducer(sp.GetRequiredService<ISomeDependency>()));

// Pre-built instance.
var producer = new MyCustomProducer(preconfiguredClient);
eventHandling.AddProducer(producer);

The instance overload (AddProducer<T>(T producer)) has a behaviorally significant side effect: if the instance you pass also implements IHostedService, it is additionally registered as a hosted service, so it participates in the host's start/stop lifecycle alongside being resolvable as IEventProducer. The parameterless and factory-delegate overloads do not do this.

If you're registering a different producer (not PublishWithEventBusEventProducer) alongside subscribers on a custom IEventHandlingBuilder, RCommon's startup diagnostics will warn (once) if it ever finds a builder type with recorded subscriptions but zero registered producers — since that combination means the subscribers will silently never fire.

Modular composition

WithEventHandling<InMemoryEventBusBuilder> is cache-aware. When multiple modules call it, the cached InMemoryEventBusBuilder is reused and each configuration delegate runs against the same instance — so subscriber and producer registrations from every module accumulate. AddProducer<T> deduplicates by concrete producer type: if OrderingModule and NotificationsModule both call eventHandling.AddProducer<AuditProducer>(), exactly one AuditProducer descriptor is registered. AddSubscriber<TEvent, THandler> accumulates normally, so multiple modules can register handlers for the same event. See Modular Composition for the full conflict matrix.

Defining an event​

Events must implement ISerializableEvent. Use ISyncEvent for sequential dispatch or IAsyncEvent for concurrent dispatch:

ISyncEvent/IAsyncEvent control dispatch ordering, not transport

Despite the "Sync" name, ISyncEvent does not mean "in-memory only" and IAsyncEvent does not mean "out-of-process." Both extend ISerializableEvent and are fully serializable — an ISyncEvent is persisted to and drained from the transactional outbox just like any other event. The only thing these markers select is how the router dispatches a batch: ISyncEvent events are dispatched sequentially (awaited one at a time, preserving order), IAsyncEvent events concurrently. Choose based on whether ordering between events matters, not on which transport you expect.

using RCommon.Models.Events;

public class OrderPlaced : ISyncEvent
{
public OrderPlaced(Guid orderId, DateTime placedAt)
{
OrderId = orderId;
PlacedAt = placedAt;
}

public OrderPlaced() { }

public Guid OrderId { get; }
public DateTime PlacedAt { get; }
}

Implementing a subscriber​

Implement ISubscriber<TEvent> and register the class in the DI container via AddSubscriber:

using RCommon.EventHandling.Subscribers;

public class OrderPlacedHandler : ISubscriber<OrderPlaced>
{
private readonly ILogger<OrderPlacedHandler> _logger;

public OrderPlacedHandler(ILogger<OrderPlacedHandler> logger)
{
_logger = logger;
}

public async Task HandleAsync(OrderPlaced @event, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Order {OrderId} placed at {PlacedAt}", @event.OrderId, @event.PlacedAt);
await Task.CompletedTask;
}
}

Two ways to register a subscriber (and why they're equivalent)​

A handler can be wired either through the event-handling builder or directly into DI:

// 1. Through the builder (recommended — also records the event→producer subscription
// and auto-registers the in-memory producer):
eventHandling.AddSubscriber<OrderPlaced, OrderPlacedHandler>();

// 2. Directly into the container:
services.AddScoped<ISubscriber<OrderPlaced>, OrderPlacedHandler>();

At dispatch time these resolve identically. The in-memory bus (InMemoryEventBus) resolves all ISubscriber<TEvent> registrations from the DI container for the event's runtime type, regardless of how they were registered — so a handler added with the raw AddScoped call is invoked just like one added via AddSubscriber. This holds for the outbox poller too, since it dispatches through the same in-memory producer.

Prefer AddSubscriber<TEvent, THandler>(): besides registering the handler, it records the event→producer subscription used for routing and auto-registers PublishWithEventBusEventProducer. Use the raw services.AddScoped<ISubscriber<TEvent>, THandler>() form only when you must register a handler outside a builder context (for example, from a module that does not have the event-handling builder in scope) — in that case ensure a producer is registered so the subscriber can actually be reached.

Publishing events​

Resolve IEventProducer or IEnumerable<IEventProducer> and call ProduceEventAsync. The router ensures the event reaches only the producers subscribed to it:

public class OrderService
{
private readonly IEventRouter _eventRouter;
private readonly IUnitOfWorkFactory _uowFactory;

public OrderService(IEventRouter eventRouter, IUnitOfWorkFactory uowFactory)
{
_eventRouter = eventRouter;
_uowFactory = uowFactory;
}

public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken)
{
using var uow = _uowFactory.CreateUnitOfWork();
// ... persist the order ...

_eventRouter.AddTransactionalEvent(new OrderPlaced(order.Id, DateTime.UtcNow));
await uow.SaveChangesAsync(cancellationToken);
await _eventRouter.RouteEventsAsync(cancellationToken);
}
}

Via IEventBus (direct, no routing layer)​

You can also publish directly through IEventBus without going through the router:

public class NotificationService
{
private readonly IEventBus _eventBus;

public NotificationService(IEventBus eventBus)
{
_eventBus = eventBus;
}

public async Task NotifyAsync(OrderPlaced @event, CancellationToken cancellationToken)
{
await _eventBus.PublishAsync(@event, cancellationToken);
}
}

Dynamic subscriptions​

IEventBus supports runtime subscriptions that do not require DI registration. These are resolved via ActivatorUtilities at publish time:

eventBus.Subscribe<OrderPlaced, OrderPlacedHandler>();

// or auto-discover all ISubscriber<T> interfaces on a handler type
eventBus.SubscribeAllHandledEvents<OrderPlacedHandler>();

API summary​

TypeDescription
InMemoryEventBusBuilderBuilder used with WithEventHandling<T> to configure the in-memory bus
IEventBusIn-process event bus; PublishAsync, Subscribe, SubscribeAllHandledEvents
InMemoryEventBusDefault IEventBus implementation; resolves handlers from a DI scope
ISubscriber<TEvent>Handler interface; implement HandleAsync
PublishWithEventBusEventProducerIEventProducer that delegates to IEventBus.PublishAsync
IEventRouterQueues transactional events and routes them to registered producers
InMemoryTransactionalEventRouterDefault IEventRouter; registered automatically by AddRCommon
ISyncEventMarker; selects sequential dispatch ordering (not a transport choice — still fully serializable / outbox-eligible)
IAsyncEventMarker; selects concurrent dispatch ordering (not a transport choice)

AddSubscriber / AddProducer overloads​

MethodDescription
AddSubscriber<TEvent,TEventHandler>()Registers TEventHandler as a scoped ISubscriber<TEvent>; auto-registers PublishWithEventBusEventProducer.
AddSubscriber<TEvent,TEventHandler>(Func<IServiceProvider,TEventHandler>)Same as above, constructing the handler via a factory delegate.
AddProducer<T>()Registers T as a singleton IEventProducer, deduplicated by concrete type.
AddProducer<T>(Func<IServiceProvider,T>)Same as above, constructing the producer via a factory delegate.
AddProducer<T>(T producer)Registers a pre-built producer instance. Also registers it as IHostedService if producer implements that interface.
RCommonRCommon