Back to blog
Backend Systemsbeginner

Observer Pattern — Events and Delegates in C#

Learn the Observer pattern using C# events, delegates, and the modern IObservable<T> interface. Build loosely coupled event-driven systems.

Asma HafeezApril 17, 20264 min read
csharpdesign-patternsobservereventsdotnet
Share:𝕏

Observer Pattern

Observer defines a one-to-many dependency: when one object changes state, all its dependents are notified automatically. In C#, this is built into the language via events and delegates.


The Pattern

Subject (Publisher) → notifies → Observers (Subscribers)

When an order is placed:
  → Send confirmation email        (EmailObserver)
  → Deduct inventory               (InventoryObserver)
  → Update analytics               (AnalyticsObserver)

C# Events (Built-in Observer)

C#
// EventArgs carry the event data
public class OrderPlacedEventArgs : EventArgs
{
    public int    OrderId    { get; init; }
    public int    CustomerId { get; init; }
    public decimal Total     { get; init; }
}

// Publisher
public class OrderService
{
    // Declare the event
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public Order PlaceOrder(CreateOrderRequest request)
    {
        var order = ProcessOrder(request);

        // Fire the event — notify all subscribers
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs
        {
            OrderId    = order.Id,
            CustomerId = order.CustomerId,
            Total      = order.Total
        });

        return order;
    }
}

// Subscribers
var orderService = new OrderService();

// Subscribe to the event
orderService.OrderPlaced += async (sender, e) =>
{
    await emailService.SendConfirmationAsync(e.CustomerId, e.OrderId);
};

orderService.OrderPlaced += (sender, e) =>
{
    inventoryService.DeductStock(e.OrderId);
};

// Unsubscribe
EventHandler<OrderPlacedEventArgs> analyticsHandler = (s, e) => analytics.Track(e);
orderService.OrderPlaced += analyticsHandler;
orderService.OrderPlaced -= analyticsHandler;  // unsubscribe later

Custom Delegate Types

C#
// Define custom delegate type
public delegate void PriceChangedHandler(string productId, decimal oldPrice, decimal newPrice);

public class Product
{
    private decimal _price;
    public event PriceChangedHandler? PriceChanged;

    public decimal Price
    {
        get => _price;
        set
        {
            if (value == _price) return;
            var oldPrice = _price;
            _price = value;
            PriceChanged?.Invoke(Id, oldPrice, value);
        }
    }

    public string Id { get; init; } = string.Empty;
}

var laptop = new Product { Id = "laptop-1", Price = 999m };
laptop.PriceChanged += (id, old, next) =>
    Console.WriteLine($"Product {id} price: {old:C} → {next:C}");

laptop.Price = 899m;  // prints: Product laptop-1 price: $999.00 → $899.00

Interface-Based Observer

For more control over subscription lifecycle:

C#
public interface IOrderObserver
{
    void OnOrderPlaced(Order order);
}

public class OrderEventBus
{
    private readonly List<IOrderObserver> _observers = new();

    public void Subscribe(IOrderObserver observer)   => _observers.Add(observer);
    public void Unsubscribe(IOrderObserver observer) => _observers.Remove(observer);

    public void Publish(Order order)
    {
        foreach (var observer in _observers)
        {
            try { observer.OnOrderPlaced(order); }
            catch (Exception ex)
            {
                // Don't let one failed observer stop others
                Console.Error.WriteLine($"Observer failed: {ex.Message}");
            }
        }
    }
}

public class EmailNotificationObserver : IOrderObserver
{
    public void OnOrderPlaced(Order order)
    {
        Console.WriteLine($"Sending confirmation email for order {order.Id}");
    }
}

public class InventoryObserver : IOrderObserver
{
    public void OnOrderPlaced(Order order)
    {
        Console.WriteLine($"Deducting inventory for order {order.Id}");
    }
}

var bus = new OrderEventBus();
bus.Subscribe(new EmailNotificationObserver());
bus.Subscribe(new InventoryObserver());
bus.Publish(new Order { Id = 1 });

Async Observer with MediatR Notifications

In real .NET apps, use MediatR for decoupled event handling:

C#
// Notification (event)
public record OrderPlacedNotification(int OrderId, int CustomerId) : INotification;

// Publisher
public class OrderService(IMediator mediator)
{
    public async Task<Order> PlaceOrderAsync(CreateOrderRequest req)
    {
        var order = await CreateOrderAsync(req);
        await mediator.Publish(new OrderPlacedNotification(order.Id, order.CustomerId));
        return order;
    }
}

// Multiple handlers — each runs independently
public class SendConfirmationEmailHandler : INotificationHandler<OrderPlacedNotification>
{
    public async Task Handle(OrderPlacedNotification notification, CancellationToken ct)
        => await emailService.SendAsync(notification.CustomerId);
}

public class UpdateInventoryHandler : INotificationHandler<OrderPlacedNotification>
{
    public async Task Handle(OrderPlacedNotification notification, CancellationToken ct)
        => await inventoryService.DeductAsync(notification.OrderId);
}

Key Takeaways

  1. C# events are the built-in Observer pattern — use event EventHandler<T> for standard patterns
  2. Always check for null before invoking: event?.Invoke(...) — no subscribers = null
  3. Unsubscribe when the observer is disposed to prevent memory leaks (especially with long-lived publishers)
  4. Use MediatR notifications for async, decoupled event handling in production ASP.NET Core apps
  5. Catch exceptions in each observer notification — one failed handler shouldn't break the others

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.