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.
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.
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.
event declarationLesson 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:
OrderService, InventoryService, and AuthService directly — exactly the kind of tangled, many-to-many dependency graph event-driven design was supposed to prevent in the first place.OrderService.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.
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)
public interface IEventAggregator
{
void Publish<TEvent>(TEvent evt);
void Subscribe<TEvent>(Action<TEvent> handler);
}
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);
}
Publish nor Subscribe mentions OrderService, Analytics, or any specific publisher/subscriber type — the aggregator itself never knows what event types exist in advance.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.
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.
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.
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:
typeof(TEvent) is what connects a Publish<OrderShipped> call to every Subscribe<OrderShipped> handler — there's no string matching, no reflection over method names, just a dictionary lookup keyed by the CLR Type object..ToArray() call in Publish matters 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.event, Publish as 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.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.
event keyword everywhere" — no, it's a different tool for a different scaleA 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.
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.
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.
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.
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.IEventAggregator — never on each other.Publish<TEvent> / Subscribe<TEvent> = a generic, type-routed, reusable version of a single hand-written event.Publish<TEvent> and Subscribe<TEvent>, letting publishers and subscribers communicate without either one holding a direct reference to the other.Type internally — genuinely reusable across every event type in an application, unlike a dedicated event field per publisher.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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.