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
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 laterCustom 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.00Interface-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
- C# events are the built-in Observer pattern — use
event EventHandler<T>for standard patterns - Always check for null before invoking:
event?.Invoke(...)— no subscribers =null - Unsubscribe when the observer is disposed to prevent memory leaks (especially with long-lived publishers)
- Use MediatR notifications for async, decoupled event handling in production ASP.NET Core apps
- 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.