Skip to main content
Version: 3.1.2

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:

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

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; events produced sequentially
IAsyncEventMarker; events produced concurrently

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