← Open in the full interactive course (progress tracking, search & more)

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

Event Aggregator — Shared Hub

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:

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.

The key insight

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

DIRECT EVENTS vs AN EVENT AGGREGATOR
LESSON 107 — DIRECT EVENT
Analytics module
  needs: reference to OrderService
  needs: reference to InventoryService
  needs: reference to AuthService
  (couples to every publisher it cares about)
EVENT AGGREGATOR
Analytics module
  needs: IEventAggregator (only)
  subscribes to: OrderShipped, StockLow, UserLoggedIn
  (couples to ONE shared abstraction, not to any publisher)

How It Works

BUILDING A SIMPLE EVENT AGGREGATOR, STEP BY STEP
1. DEFINE THE ABSTRACTION
public interface IEventAggregator
{
    void Publish<TEvent>(TEvent evt);
    void Subscribe<TEvent>(Action<TEvent> handler);
}
2. STORE HANDLERS KEYED BY EVENT TYPE
private readonly Dictionary<Type, List<Delegate>> _subscribers = new();
3. Subscribe ADDS A HANDLER TO THAT TYPE'S 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);
}
4. Publish CALLS EVERY HANDLER SUBSCRIBED TO THAT TYPE
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);
}

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

A Radio Station, Not a Phone Call

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:

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?

Mental Model

Lesson 107: publisher decoupled from subscribers, but subscribers still need the publisher's type.
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.