Event Aggregator Pattern in C#: Decoupled Messaging
Lesson 107 decoupled a publisher from its subscribers. It still needed a direct reference to raise its own event. This lesson removes even that.
Lesson 107's OrderService was a genuine win: it raised OrderPlaced and had zero references to InventoryUpdater or OrderConfirmationEmailer. But look closely at what was still required for that to work — a composition root that held a direct reference to orderService itself, in order to write orderService.OrderPlaced += inventoryUpdater.HandleOrderPlaced;. Every subscriber, and the code doing the wiring, still needed to know OrderService specifically exists, by type, in order to reach its event.
Now picture a larger application: an analytics module fifteen files away from OrderService, in a completely different part of the codebase, owned by a different team, that also wants to react to orders being placed. Does it take a constructor dependency on OrderService just to reach one event? Does every module that ever wants to react to anything end up needing a reference to every publisher in the system? At small scale this is fine. At the scale of a real application with dozens of publishers and dozens of subscribers scattered across unrelated features, direct event subscription — even lesson 107's decoupled version — still doesn't scale. This lesson introduces the tool that fixes it: an event aggregator.
What Is It?
The Simple Explanation
An event aggregator is a single, shared, injectable object that sits in the middle of a publish/subscribe relationship. Instead of Component A holding a reference to Component B in order to subscribe to B's event, both A and B hold a reference to the same third object — the aggregator. A publishes to the aggregator; B subscribes through the aggregator. Neither one ever needs to know the other's type, or that the other exists at all.
The Technical Definition
An event aggregator (sometimes called an in-process message bus — a name worth being careful with, covered under Common Confusion) is a service exposing two generic operations: Publish<TEvent>(TEvent evt), which broadcasts an event-data object of a given type to everyone currently subscribed to that type, and Subscribe<TEvent>(Action<TEvent> handler), which registers a handler to be called whenever an event of that type is published. Internally, the aggregator maintains its own mapping from event type to a list of subscribed handlers — it is, in effect, a general-purpose, reusable version of the single OrderPlaced event lesson 107 built by hand, generalized to handle any number of event types without a dedicated event declaration for each one.
Lesson 107 — One Publisher's Own Event
- Subscribers need a reference to that specific publisher's type
- A new publisher means a new dedicated
eventdeclaration - Works well for one publisher, one event, many subscribers
Event Aggregator — Shared Hub
- Publishers and subscribers only need a reference to the shared aggregator
- A new event type is just a new class — no aggregator code changes
- Works for many-to-many communication across unrelated modules
Why Does It Exist?
The Problem — Direct Subscription Doesn't Scale to Many-to-Many
Lesson 107 decoupled what happens after an event fires from the publisher raising it. But it left one coupling in place: to subscribe to OrderService.OrderPlaced at all, you need a compile-time reference to the OrderService type — an assembly reference, a using, a constructor parameter somewhere. In a small application with a handful of publishers, that's a non-issue. In a real application with dozens of independent features — orders, inventory, analytics, notifications, loyalty, fraud detection, audit logging — each one potentially caring about events raised by several of the others, this breaks down in three concrete ways:
- Every subscriber needs a reference to every publisher it cares about. An analytics module that wants to react to order placement, inventory changes, and login events ends up depending on
OrderService,InventoryService, andAuthServicedirectly — exactly the kind of tangled, many-to-many dependency graph event-driven design was supposed to prevent in the first place. - Adding a new publisher-to-subscriber connection means both sides — or at minimum the composition root — must know both types. A brand-new, unrelated module can't simply announce "I care about orders" without someone wiring it directly to
OrderService. - The composition root becomes a single, enormous file of wiring — every publisher-subscriber pair in the entire application, hand-connected, one line at a time, growing without bound as the application grows.
The Solution — One Shared Hub, Known to Everyone, Coupling No One to Anyone
An event aggregator breaks the direct A-knows-about-B relationship entirely. Every publisher and every subscriber depends on exactly one thing: the aggregator's interface. A brand-new analytics module can subscribe to OrderShipped the moment it's written, with no changes anywhere else in the codebase — not to OrderService, not to the composition root, not to any other subscriber. The aggregator is the only shared dependency, and it's a genuinely generic, reusable piece of infrastructure — write it once, and it serves every publish/subscribe relationship in the application, for any event type.
This is the same underlying goal as lesson 107 — decouple publishers from subscribers — taken one step further. Lesson 107 decoupled subscribers from each other; an event aggregator additionally decouples subscribers from the publisher's specific type. The publisher doesn't even need its own event declaration anymore — Publish<TEvent> replaces it for every event type, uniformly.
Big Picture
Analytics module
needs: reference to OrderService
needs: reference to InventoryService
needs: reference to AuthService
(couples to every publisher it cares about)
Analytics module
needs: IEventAggregator (only)
subscribes to: OrderShipped, StockLow, UserLoggedIn
(couples to ONE shared abstraction, not to any publisher)
How It Works
public interface IEventAggregator
{
void Publish<TEvent>(TEvent evt);
void Subscribe<TEvent>(Action<TEvent> handler);
}
- Both operations are generic over the event's own type — one interface handles every possible event, forever, with no changes needed for a new event type.
private readonly Dictionary<Type, List<Delegate>> _subscribers = new();
typeof(TEvent)is the key; every handler subscribed for that exact event type is stored in its list.
public void Subscribe<TEvent>(Action<TEvent> handler)
{
var key = typeof(TEvent);
if (!_subscribers.TryGetValue(key, out var list))
_subscribers[key] = list = new List<Delegate>();
list.Add(handler);
}
public void Publish<TEvent>(TEvent evt)
{
if (!_subscribers.TryGetValue(typeof(TEvent), out var list)) return;
foreach (var handler in list.ToArray()) // copy — see Under the Hood
((Action<TEvent>)handler).Invoke(evt);
}
- Neither
PublishnorSubscribementionsOrderService,Analytics, or any specific publisher/subscriber type — the aggregator itself never knows what event types exist in advance.
Simple Example
using System;
using System.Collections.Generic;
public interface IEventAggregator
{
void Publish<TEvent>(TEvent evt);
void Subscribe<TEvent>(Action<TEvent> handler);
}
public class EventAggregator : IEventAggregator
{
private readonly Dictionary<Type, List<Delegate>> _subscribers = new();
public void Subscribe<TEvent>(Action<TEvent> handler)
{
var key = typeof(TEvent);
if (!_subscribers.TryGetValue(key, out var list))
_subscribers[key] = list = new List<Delegate>();
list.Add(handler);
}
public void Publish<TEvent>(TEvent evt)
{
if (!_subscribers.TryGetValue(typeof(TEvent), out var list)) return;
foreach (var handler in list.ToArray())
((Action<TEvent>)handler).Invoke(evt);
}
}
public record UserSignedUp(string Email);
class Program
{
static void Main()
{
IEventAggregator bus = new EventAggregator();
// Two unrelated subscribers, neither knowing the other exists
bus.Subscribe<UserSignedUp>(e => Console.WriteLine($"[Welcome Email] Sending to {e.Email}"));
bus.Subscribe<UserSignedUp>(e => Console.WriteLine($"[Analytics] Recorded signup for {e.Email}"));
bus.Publish(new UserSignedUp("sam@example.com"));
// [Welcome Email] Sending to sam@example.com
// [Analytics] Recorded signup for sam@example.com
}
}
Code → Meaning → Result: Whatever code calls bus.Publish(new UserSignedUp(...)) has no idea a welcome-email handler or an analytics handler exists. Both subscribers found out about UserSignedUp purely by subscribing to the type, through the shared bus — no direct reference to whoever eventually publishes it.
Real-World Example — Order Fulfillment, Fully Decoupled
Here is lesson 107's order-placement scenario, rebuilt with an aggregator. OrderFulfillmentService, AnalyticsModule, and NotificationModule are three completely independent classes, in three imagined completely independent parts of a codebase — none of them reference each other, or even know the other two exist.
using System;
// ─── Event, shared only as a plain data type ───
public record OrderShipped(int OrderId, string TrackingNumber, decimal OrderTotal);
// ─── Publisher — order-fulfillment module. Knows only IEventAggregator. ───
public class OrderFulfillmentService
{
private readonly IEventAggregator _bus;
public OrderFulfillmentService(IEventAggregator bus) => _bus = bus;
public void ShipOrder(int orderId, decimal orderTotal)
{
var trackingNumber = $"TRK-{orderId:D6}";
// ... actual shipping logic here ...
_bus.Publish(new OrderShipped(orderId, trackingNumber, orderTotal));
}
}
// ─── Subscriber 1 — analytics module. Never heard of OrderFulfillmentService. ───
public class AnalyticsModule
{
public AnalyticsModule(IEventAggregator bus)
=> bus.Subscribe<OrderShipped>(e =>
Console.WriteLine($" [Analytics] Recorded ${e.OrderTotal:N2} shipment for order #{e.OrderId}"));
}
// ─── Subscriber 2 — notification module. Never heard of AnalyticsModule either. ───
public class NotificationModule
{
public NotificationModule(IEventAggregator bus)
=> bus.Subscribe<OrderShipped>(e =>
Console.WriteLine($" [Notify] Order #{e.OrderId} shipped — tracking {e.TrackingNumber}"));
}
// ─── Composition root — the only place that wires anything together ───
class Program
{
static void Main()
{
IEventAggregator bus = new EventAggregator();
_ = new AnalyticsModule(bus);
_ = new NotificationModule(bus);
var fulfillment = new OrderFulfillmentService(bus);
fulfillment.ShipOrder(orderId: 2002, orderTotal: 89.99m);
// [Analytics] Recorded $89.99 shipment for order #2002
// [Notify] Order #2002 shipped — tracking TRK-002002
}
}
Compare this to lesson 107's composition root, which had to write orderService.OrderPlaced += inventoryUpdater.HandleOrderPlaced; — a line that named both types explicitly. Here, the composition root constructs each module with the same shared bus and nothing more; no line of wiring code names both a publisher and a subscriber together. A brand-new FraudDetectionModule could be added tomorrow with a constructor that does bus.Subscribe<OrderShipped>(...), and neither OrderFulfillmentService nor any existing subscriber would need a single line changed.
Analogy
Lesson 107's notice board analogy already moved you past a phone tree. An event aggregator is a further step: not one notice board per publisher, but one radio station for the whole building, broadcasting on many different channels (event types). A department that wants order updates doesn't need to know which office originally announced them — it just tunes its radio to the "OrderShipped" channel. The station itself doesn't know or care who's listening on any channel, and a brand-new department can start tuning in tomorrow without the station's engineers touching a single wire. Every publisher and every subscriber only ever needs to know about the station — never about each other.
Under the Hood
An event aggregator is not new machinery at the CLR level — it's ordinary generics, an ordinary dictionary, and ordinary delegate invocation, arranged as a piece of shared infrastructure. A few implementation details are worth being deliberate about:
- Type identity is the routing key.
typeof(TEvent)is what connects aPublish<OrderShipped>call to everySubscribe<OrderShipped>handler — there's no string matching, no reflection over method names, just a dictionary lookup keyed by the CLRTypeobject. - Copy the handler list before iterating. The
.ToArray()call inPublishmatters for the same reason lesson 106 emphasized raising events via a local copy: if a handler subscribes or unsubscribes from within another handler that's currently running, mutating the live list mid-iteration would throw or produce inconsistent behavior. Iterating a snapshot avoids that. - Same synchronous, in-process reality as lesson 107. Just like a plain
event,Publishas shown here calls every handler synchronously, on the calling thread, in subscription order, before returning. An event aggregator is a routing mechanism, not a threading or queueing mechanism — it doesn't make anything asynchronous by itself. A production-grade aggregator built for a multi-threaded application would additionally need locking around the subscriber dictionary itself, exactly as lesson 191 covered for custom event accessors. - Lifetime matters, exactly as it did for events. An aggregator that lives for the whole application (typically registered as a singleton in a dependency injection container) holds a reference to every handler ever subscribed to it. A subscriber that's shorter-lived than the aggregator and never unsubscribes reproduces the exact lapsed listener leak from lesson 191 — the aggregator doesn't remove this concern, it just moves the strong-reference chain one hop further, from publisher-holds-subscriber to aggregator-holds-subscriber.
Common Confusion
1. "Message bus" sounds like a message queue — inside one process, it isn't
The term "message bus" is used for this in-process pattern, but it is a completely different thing from the message brokers (RabbitMQ, Kafka, Azure Service Bus) that lesson 107 explicitly set aside for a later, distributed-systems topic. The EventAggregator in this lesson lives entirely inside one process's memory, has no durability (a published event that nobody happened to be subscribed to at that instant is simply gone — there's no "replay it later"), and involves no network hop whatsoever. If you hear "event aggregator" or "in-process message bus" in a job posting or a codebase, assume this lesson's pattern unless the context specifically says otherwise; if you hear "message queue," "broker," or a specific product name (Kafka, RabbitMQ, SQS), that's the different, later topic.
2. "An event aggregator replaces the event keyword everywhere" — no, it's a different tool for a different scale
A class that has exactly one well-known set of subscribers — a UI control's Click event, a domain object's own StatusChanged event — is still perfectly well served by a plain event, exactly as lessons 105–106 taught. An aggregator earns its complexity specifically when the number of independent, unrelated subscriber modules grows large enough that direct references between them become the actual maintenance burden. Reaching for an aggregator for a single, simple, two-party notification is unnecessary indirection.
Common Mistakes
Mistake 1 — Treating the aggregator as durable or asynchronous by default
Publishing an event and assuming a subscriber that registers itself moments later will still somehow receive it, or assuming Publish returns immediately while handlers run "in the background."
Remember it's synchronous and has zero memory of past events by default — a subscriber must be registered before Publish is called for that specific event to receive it, exactly like a plain event from lesson 105.
Mistake 2 — Forgetting to unsubscribe from a singleton-scoped aggregator
A short-lived object subscribes in its constructor and is never seen again — exactly the lapsed listener leak from lesson 191, just via the aggregator's internal dictionary instead of a class's own event field.
Design subscriptions with the same lifetime discipline as any event subscription: unsubscribe explicitly when the subscriber's own lifetime ends, or design the aggregator's subscription API to return an IDisposable "subscription token" that unsubscribes on Dispose() — a common refinement of the simple version shown here.
Mistake 3 — Routing every single call in an application through the aggregator "for consistency"
Using Publish/Subscribe even for a simple, direct, two-party interaction that a plain method call or a lesson-107-style dedicated event would express far more clearly.
Reach for an aggregator specifically for genuinely decoupled, many-to-many, cross-module notifications. A direct call remains simpler to read, simpler to debug (a normal call stack, not a type-keyed dictionary lookup), and appropriate whenever the relationship really is just two specific parties talking to each other.
When Should I Use It?
- When multiple, unrelated modules across a growing application need to react to the same facts, and you don't want every one of them to depend directly on whichever module happens to raise them.
- When you expect new publishers and new subscribers to be added independently over time, by different people or teams, without coordinating changes to a shared composition root.
- Skip it for a simple, stable, two-party relationship — a plain
event(lessons 105–106) is simpler, more discoverable (an IDE can find every subscriber of a specific event with "Find References"; a type-keyed aggregator generally cannot), and appropriate when there's exactly one well-known publisher and a small, known set of subscribers. - Skip it, too, for cross-service or cross-process communication — that remains the message-broker territory lesson 107 deliberately left for later; an in-process aggregator has no answer for a subscriber that's temporarily offline or a message that must survive a crash.
Mental Model
This lesson: publishers and subscribers both depend only on
IEventAggregator — never on each other.Publish<TEvent> / Subscribe<TEvent> = a generic, type-routed, reusable version of a single hand-written event.Still synchronous, still in-process, still not a message queue — same lifetime discipline as any event subscription applies.
Key Takeaway
- An event aggregator is a shared, injectable service with
Publish<TEvent>andSubscribe<TEvent>, letting publishers and subscribers communicate without either one holding a direct reference to the other. - It routes by event type, using a dictionary keyed by
Typeinternally — genuinely reusable across every event type in an application, unlike a dedicatedeventfield per publisher. - It solves the many-to-many coupling problem that lesson 107's decoupled-but-still-directly-referenced events left in place, at the cost of losing some IDE discoverability (no single "Find References" for all subscribers of a type).
- It's still synchronous, in-process, and has no built-in durability — not to be confused with a distributed message queue/broker, which is a different, later topic with entirely different guarantees.
- The same subscription-lifetime discipline from lesson 191 still applies: a subscriber shorter-lived than the aggregator must unsubscribe, or it leaks exactly like any other lapsed event listener.
Check Your Understanding
You've seen how an event aggregator removes the last direct coupling that lesson 107's event-driven design still had. Let's confirm the details.
1. In lesson 107's design, what direct coupling still existed between a subscriber and the event it subscribed to, that an event aggregator removes?
Show answer
Correct: B
Why B is correct: Even though the publisher didn't reference its subscribers, subscribing to OrderService.OrderPlaced still required knowing about OrderService as a specific type — that's exactly the coupling an event aggregator's shared, type-routed Publish/Subscribe removes.
Why A is incorrect: Lesson 107's subscribers were plain, independent classes with no inheritance relationship to the publisher at all.
Why C is incorrect: C# has no such file-colocation requirement for subscribing to an event; this was never a real constraint.
Why D is incorrect: This lesson's entire motivation is that a real remaining coupling existed — knowledge of the publisher's specific type — even in lesson 107's decoupled design.
Reinforcement: Event aggregation removes the "subscriber must know the publisher's type" coupling that a direct event, even a well-designed one, still has.
2. How does a simple EventAggregator implementation route a published event to the correct subscribers?
Show answer
Correct: B
Why B is correct: typeof(TEvent) is the routing key in both Publish and Subscribe — a dictionary keyed by Type maps directly from an event's type to the list of handlers registered for exactly that type.
Why A is incorrect: Routing is based on the event's type, not its property values — two different OrderShipped instances with different data route identically, to the same subscribers.
Why C is incorrect: This would be wasteful and error-prone; the type-keyed dictionary lookup means only handlers for the exact published type are invoked, not every subscriber in the system.
Why D is incorrect: The example implementation requires no marker interface — any plain class or record works as an event type, since generics and typeof(TEvent) handle the routing without any special contract on the event type itself.
Reinforcement: Type identity, via typeof(TEvent), is the entire routing mechanism — no reflection over names or property inspection needed.
3. A team wants order fulfillment to durably notify a separate microservice running on a different machine, even if that service is temporarily offline when the order ships. Is the in-process EventAggregator from this lesson the right tool?
Show answer
Correct: B
Why B is correct: This matches the Common Confusion section directly — this lesson's aggregator lives entirely in one process's memory with no durability or cross-process delivery. A durable, cross-machine, "deliver even if offline" requirement is exactly the distributed message-broker territory this lesson (and lesson 107) explicitly set aside for later.
Why A is incorrect: There's no network transport, serialization, or cross-process delivery in this implementation at all — it only works within a single running process's memory.
Why C is incorrect: Singleton scope affects the aggregator's lifetime within one process — it has no bearing on whether events can reach a genuinely separate machine or service.
Why D is incorrect: The problem isn't the topic (orders) — it's the requirement (durability, cross-process delivery). This aggregator is fine for in-process order-related notifications; it's simply the wrong tool for the specific durable, cross-machine scenario described.
Reinforcement: Recognize "durable" and "cross-process" as the signal that you need a message broker, not an in-process event aggregator.
You've now taken event-driven design as far as it goes in-process — from a raw multicast delegate, through the standard pattern, through decoupled publishers, to a fully shared aggregator where no two modules need to know each other exist. Next: a completely different kind of code reuse — extension methods, the mechanism behind every LINQ call you've ever written.