C# Publisher Subscriber Pattern: Events in Practice
c# publisher subscriber pattern: Learn how the C# publisher subscriber pattern works with events and delegates, including exception handling, memory leaks, and threadi...
The C# publisher subscriber pattern is most commonly built on events and delegates. An event lets one class announce that something happened without knowing which other classes care. Subscribers attach handlers with +=, and the publisher invokes them when the event fires. This decouples the publisher from the subscribers, but the mechanics of delegate invocation introduce several behaviors that matter in production code.
How Events and Delegates Form the Pattern
A delegate is a type-safe function reference. An event is a delegate field with restricted access: outside code can only add or remove handlers using += and -=. The publisher alone can invoke the event. This restriction is what makes the pattern work. Subscribers cannot clear the handler list or raise the publisher's event on their own.
public class OrderService { public event EventHandler<OrderPlacedEventArgs>? OrderPlaced; public void PlaceOrder(Order order) { // persist the order... OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(order)); } }
The ?.Invoke syntax checks whether any handlers are attached before raising the event. If the event has no subscribers, OrderPlaced is null and the invocation is skipped. This null check is the standard way to raise an event safely.
A Minimal Publisher and Subscriber Pair
A subscriber attaches a handler and does something useful when the event fires.
public class EmailNotifier { public EmailNotifier(OrderService orders) { orders.OrderPlaced += OnOrderPlaced; } private void OnOrderPlaced(object? sender, OrderPlacedEventArgs e) { SendEmail(e.Order.CustomerEmail, "Your order was placed."); } }
The EmailNotifier never needs a reference to OrderService's internals. It only needs the event. The publisher never needs to know that EmailNotifier exists. That separation is the point of the pattern.
What Happens When a Subscriber Throws
When the publisher invokes the event, all subscribers run sequentially in the order they were attached. If one subscriber throws an exception, the invocation stops immediately. Remaining subscribers never run, and the exception propagates to the publisher's caller.
public void PlaceOrder(Order order) { OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(order)); // If a subscriber throws, this line never executes. SaveToAuditLog(order); }
This is a common production surprise. An event is often treated as a notification, but the publisher cannot control what subscribers do. A slow or failing subscriber blocks the publisher. If the publisher must continue regardless of subscriber failures, each subscriber needs its own try/catch, or the publisher must invoke handlers individually.
Memory Leaks from Event Subscriptions
An event subscription creates a strong reference from the publisher to the subscriber. If a subscriber is long-lived and the publisher is short-lived, the publisher stays alive as long as the subscriber holds it. The reverse is the more common problem: a short-lived subscriber attached to a long-lived publisher is never garbage collected.
public class ShortLivedComponent { public ShortLivedComponent(OrderService orders) { orders.OrderPlaced += OnOrderPlaced; } public void Dispose() { // Without this, the publisher keeps a reference to this instance. // orders.OrderPlaced -= OnOrderPlaced; } }
If the subscriber is disposed but does not unsubscribe, the publisher still holds a reference to it. The subscriber's memory cannot be reclaimed. This is the classic event-handler memory leak. The fix is to unsubscribe in Dispose, or to use a weak-event pattern when the subscriber's lifetime cannot be controlled.
Threading and Reentrancy
Events are raised on the calling thread. If PlaceOrder runs on a background thread, all subscribers also run on that thread. A subscriber that touches UI controls must marshal to the UI thread, typically through SynchronizationContext or Dispatcher. The publisher does not provide any automatic marshaling.
The += and -= operations are thread-safe for a single delegate chain, but the invocation itself is not atomic. If one thread raises the event while another unsubscribes, the handler list captured by ?.Invoke may still include the removed handler. In practice, this means a handler can be called after it was unsubscribed. For most applications this is acceptable, but it is a behavior to be aware of when the event is raised frequently from multiple threads.
Alternatives Beyond the Event Keyword
The event keyword is not the only way to implement the publisher subscriber pattern in C#. An event aggregator is a shared mediator that decouples publishers from subscribers entirely. Instead of subscribing directly to a publisher's event, both sides register with a central aggregator.
IObservable<T> and IObserver<T> provide a push-based model with built-in completion semantics. They are a better fit when subscribers need to know when a stream of notifications ends, or when you want to compose and filter notifications with LINQ-style operators.
System.Threading.Channels is useful when the publisher produces data faster than subscribers can consume it. A channel buffers messages and lets subscribers read at their own pace, which is something a plain event cannot do.
The event keyword remains the simplest and most direct implementation. Use it when the publisher and subscribers share a clear lifetime and the notification is a simple "something happened" signal. Use an aggregator when publishers and subscribers should not know about each other at all. Use channels when backpressure or buffering is required.
When the Pattern Becomes a Liability
The publisher subscriber pattern hides which components depend on which. In a large codebase, an event with many subscribers makes it difficult to trace the flow of a request. Debugging becomes harder because the call stack jumps through delegate invocations.
The pattern is also a poor fit when the publisher needs a return value from subscribers, or when subscribers must run in a specific order. Events do not provide either. If you need ordered processing or results, a direct method call or a pipeline of explicit handlers is clearer.
A reasonable rule is to use events for notifications that have no return value and no ordering requirement, and to keep the number of subscribers small enough that the dependency graph remains visible.