Simple Event Aggregator in C#

Simple Event Aggregator in C#

In this post, I’ll go through the inner workings of Event Aggregators, what it is, how to make one, and how to use it. Consider this another brain-dump of past knowledge to assist you in learning something new, or enhancing what you’ve previously known. This article focus on a single application; in another article, we’ll cover IPC (inter-process communication) in a similar fashion.

In C#, an Event Aggregator is a design pattern that facilitates loosely coupled communication between different parts of an application, particularly between components that do not have direct references to each other. It acts as a central hub or a message bus where publishers can send messages (events) and subscribers can listen for and react to those messages.

Common Usage Scenarios

  • MVVM Architectures:
    Facilitating communication between ViewModels without direct references.
  • Composite Applications:
    Enabling different modules or plugins to communicate without knowing about each other’s implementations.
  • Decoupling UI and Business Logic:
    Allowing UI components to react to changes in business logic without tight coupling.

Frameworks like Prism and Caliburn.Micro provide their own implementations of the Event Aggregator pattern. Even .NET MAUI provides its own Messaging Center, now known as, WeakReferenceMessenger in the CommunityToolkit.Mvvm. In past projects, I’ve created custom ones as well as relied on Prism Library’s built-in one.

How It Works

  • Publishers: Components that want to broadcast an event publish it to the Event Aggregator. They don’t need to know who or how many subscribers are listening.
  • Subscribers: Components that are interested in specific events subscribe to them through the Event Aggregator. When an event of that type is published, the Event Aggregator notifies all registered subscribers.
  • Loose Coupling: The key benefit is that publishers and subscribers are decoupled. They only interact with the Event Aggregator, not with each other directly. This reduces dependencies, making the codebase more flexible, maintainable, and testable.

Creating an Event Aggregator

The following implementation is simple and thread-safe. You can enhance it with:

  • Weak references to avoid memory leaks.
  • Async support for event handling.
  • Filtering or priority-based dispatch

Key Features

  • Allows multiple subscribers to listen for events.
  • Publishers can raise events without knowing who is subscribed.
  • Thread-safe implementation using ConcurrentDictionary.

C# Implementation

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;

public interface IEventAggregator
{
  void Subscribe<TEvent>(Action<TEvent> handler);
  void Unsubscribe<TEvent>(Action<TEvent> handler);
  void Publish<TEvent>(TEvent eventData);
}

public class EventAggregator : IEventAggregator
{
  private readonly ConcurrentDictionary<Type, List<Delegate>> _subscribers = new();

  public void Subscribe<TEvent>(Action<TEvent> handler)
  {
    var eventType = typeof(TEvent);
    _subscribers.AddOrUpdate(eventType,
      _ => new List<Delegate> { handler },
      (_, handlers) =>
      {
        handlers.Add(handler);
        return handlers;
      });
  }

  public void Unsubscribe<TEvent>(Action<TEvent> handler)
  {
    var eventType = typeof(TEvent);
    if (_subscribers.TryGetValue(eventType, out var handlers))
    {
      handlers.Remove(handler);
    }
  }

  public void Publish<TEvent>(TEvent eventData)
  {
    var eventType = typeof(TEvent);
    if (_subscribers.TryGetValue(eventType, out var handlers))
    {
      foreach (var handler in handlers)
      {
        (handler as Action<TEvent>)?.Invoke(eventData);
      }
    }
  }
}

Usage Example

public class UserCreatedEvent
{
  public string Username { get; set; }
}

class Program
{
  static void Main()
  {
    IEventAggregator eventAggregator = new EventAggregator();

    // Subscribe
    eventAggregator.Subscribe<UserCreatedEvent>(e =>
      Console.WriteLine($"User created: {e.Username}"));

    // Publish
    eventAggregator.Publish(new UserCreatedEvent { Username = "Damian" });
  }
}

Register with .NET DI Container

You can easily make this Event Aggregator (messaging center) Dependency Injection (DI) friendly in .NET. All you have to do is register it as a singleton service. The following uses, Microsoft.Extensions.DependencyInjection.

Register DI Container

using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Register EventAggregator as a Singleton
builder.Services.AddSingleton<IEventAggregator, EventAggregator>();

var app = builder.Build();

Usage Example

// Event Payload
public class UserCreatedEvent
{
  public string Username { get; set; }
}

// Publishing Event
public class UserService
{
  private readonly IEventAggregator _eventAggregator;

  public UserService(IEventAggregator eventAggregator)
  {
    _eventAggregator = eventAggregator;
  }

  public void CreateUser(string username)
  {
    // Business logic...
    _eventAggregator.Publish(new UserCreatedEvent { Username = username });
  }
}

// Subscribing to Event
public class NotificationService
{
  public NotificationService(IEventAggregator eventAggregator)
  {
    eventAggregator.Subscribe<UserCreatedEvent>(e =>
    {
      Console.WriteLine($"Notification: User '{e.Username}' created.");
    });
  }
}

Enhance with Weak References

Now let’s enhance our Event Aggregator implementation using weak references for handlers. This prevents memory leaks by allowing subscribers to be garbage collected if they go out of scope.

This features:

  • WeakReference for handlers.
  • Cleans up dead references during Publish.
  • Prevents memory leaks when subscribers are no longer needed.

Why Weak References?

  • If you store strong references to handlers, subscribers will never be collected.
  • Using WeakReference ensures that if the subscriber is no longer needed, it can be GC’d.

Implementation

 
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;

public interface IEventAggregator
{
  void Subscribe<TEvent>(Action<TEvent> handler);
  void Unsubscribe<TEvent>(Action<TEvent> handler);
  void Publish<TEvent>(TEvent eventData);
}

public class EventAggregator : IEventAggregator
{
  private readonly ConcurrentDictionary<Type, List<WeakReference>> _subscribers = new();

  public void Subscribe<TEvent>(Action<TEvent> handler)
  {
    var eventType = typeof(TEvent);
    var weakHandler = new WeakReference(handler);

    _subscribers.AddOrUpdate(eventType,
      _ => new List<WeakReference> { weakHandler },
      (_, handlers) =>
      {
        handlers.Add(weakHandler);
        return handlers;
      });
  }

  public void Unsubscribe<TEvent>(Action<TEvent> handler)
  {
    var eventType = typeof(TEvent);
    if (_subscribers.TryGetValue(eventType, out var handlers))
    {
      handlers.RemoveAll(wr => wr.Target is Action<TEvent> h && h == handler);
    }
  }

  public void Publish<TEvent>(TEvent eventData)
  {
    var eventType = typeof(TEvent);
    if (_subscribers.TryGetValue(eventType, out var handlers))
    {
      var deadRefs = new List<WeakReference>();

      foreach (var weakRef in handlers)
      {
        if (weakRef.Target is Action<TEvent> handler)
        {
          handler(eventData);
        }
        else
        {
          deadRefs.Add(weakRef);
        }
      }

      // Clean up dead references
      foreach (var dead in deadRefs)
      {
        handlers.Remove(dead);
      }
    }
  }
}

Usage Example

public class UserCreatedEvent
{
  public string Username { get; set; }
}

class Program
{
  static void Main()
  {
    IEventAggregator eventAggregator = new EventAggregator();

    var subscriber = new Subscriber(eventAggregator);

    eventAggregator.Publish(new UserCreatedEvent { Username = "Damian" });

    // If subscriber goes out of scope and GC runs, handler will be removed automatically
  }
}

public class Subscriber
{
  public Subscriber(IEventAggregator aggregator)
  {
    aggregator.Subscribe<UserCreatedEvent>(OnUserCreated);
  }

  private void OnUserCreated(UserCreatedEvent e)
  {
    Console.WriteLine($"User created: {e.Username}");
  }
}