Observer Pattern with Events and Delegates in C

The Observer pattern is a behavioral design pattern that defines a one-to-many dependency between objects, allowing one object (the subject) to notify multiple dependents (observers) when its state changes. In C#, the language provides first-class support for this pattern natively through delegates and events, eliminating the need to manually implement boilerplate subject and observer interfaces. This guide demonstrates how to build an idiomatic, type-safe implementation of the Observer pattern using C#'s built-in event keyword and EventHandler<T> delegate.

Core Concepts

In traditional object-oriented implementations of the Observer pattern, you define an ISubject interface with Attach(), Detach(), and Notify() methods, alongside an IObserver interface with an Update() method.

C# abstracts these responsibilities using:

  • Delegates: Define the method signature for callbacks (acting as the abstract observer contract).
  • Events: Encapsulate delegates on the publisher (subject), restricting subscribers to only register (+=) or unregister (-=) handlers.
  • EventArgs: Encapsulates the state data passed from the publisher to the subscribers.

Step-by-Step Implementation

1. Define Custom Event Arguments

Create a class that inherits from EventArgs to carry the state change information to subscribers.

using System;

public class OrderStatusChangedEventArgs : EventArgs
{
    public int OrderId { get; }
    public string NewStatus { get; }

    public OrderStatusChangedEventArgs(int orderId, string newStatus)
    {
        OrderId = orderId;
        NewStatus = newStatus;
    }
}

2. Implement the Subject (Publisher)

The subject exposes an event using EventHandler<TEventArgs>. It triggers the event using thread-safe null-conditional invocation (?.Invoke()).

public class Order
{
    public int Id { get; }
    private string _status;

    // The event acts as the subject's notification mechanism
    public event EventHandler<OrderStatusChangedEventArgs>? StatusChanged;

    public Order(int id, string initialStatus)
    {
        Id = id;
        _status = initialStatus;
    }

    public void UpdateStatus(string newStatus)
    {
        if (_status != newStatus)
        {
            _status = newStatus;
            OnStatusChanged(new OrderStatusChangedEventArgs(Id, newStatus));
        }
    }

    // Protected virtual method enables derived classes to handle or override the event
    protected virtual void OnStatusChanged(OrderStatusChangedEventArgs e)
    {
        StatusChanged?.Invoke(this, e);
    }
}

3. Implement the Observers (Subscribers)

Observers define handler methods matching the signature void Handler(object? sender, TEventArgs e) and attach them to the subject's event.

public class EmailNotifier
{
    public void OnOrderStatusChanged(object? sender, OrderStatusChangedEventArgs e)
    {
        Console.WriteLine($"[Email] Order #{e.OrderId} updated: {e.NewStatus}");
    }
}

public class SmsNotifier
{
    public void OnOrderStatusChanged(object? sender, OrderStatusChangedEventArgs e)
    {
        Console.WriteLine($"[SMS] Order #{e.OrderId} updated: {e.NewStatus}");
    }
}

4. Wire the Subject and Observers Together

Register handlers using the += operator and trigger notifications by altering the subject's state.

public class Program
{
    public static void Main()
    {
        var order = new Order(101, "Pending");

        var emailNotifier = new EmailNotifier();
        var smsNotifier = new SmsNotifier();

        // Subscribe to the event
        order.StatusChanged += emailNotifier.OnOrderStatusChanged;
        order.StatusChanged += smsNotifier.OnOrderStatusChanged;

        // Triggers notifications to both observers
        order.UpdateStatus("Shipped");

        // Unsubscribe an observer
        order.StatusChanged -= smsNotifier.OnOrderStatusChanged;

        // Triggers notification only to the email notifier
        order.UpdateStatus("Delivered");
    }
}

Best Practices

  • Prevent Memory Leaks: An event holds a strong reference to its subscriber objects. If a publisher has a longer lifespan than a subscriber, always unregister the handler using -= or implement IDisposable on the subscriber to avoid keeping objects alive in memory.
  • Use Standard Signatures: Stick to EventHandler<TEventArgs> rather than creating custom delegate types. This adheres to .NET framework design guidelines and ensures consistency across libraries.
  • Thread Safety: Invoking events via StatusChanged?.Invoke(this, e) copies the delegate reference before evaluation, ensuring thread safety if a subscriber unregisters on another thread just before invocation.