A newspaper subscription has the shape of Observer. The publisher keeps a subscriber list and sends each new issue to that list. Subscribers can leave independently, and the publisher does not know how any recipient uses the issue.

Observer defines one publisher with many independently registered subscribers. A state change triggers notification through a stable callback contract, so the publisher does not depend on subscriber types. C# events provide this mechanism directly: += subscribes, -= unsubscribes, and invoking the event notifies the current delegate list.

sequenceDiagram
    participant Order as Order - Subject
    participant Email as EmailNotifier
    participant Sms as SmsNotifier
    participant Analytics as AnalyticsTracker
    participant Warehouse as WarehouseNotifier
    Order->>Email: StatusChanged - Shipped
    Order->>Sms: StatusChanged - Shipped
    Order->>Analytics: StatusChanged - Shipped
    Order->>Warehouse: StatusChanged - Shipped

Problem

OrderService.UpdateStatus() calls every downstream system itself. Notification policy is mixed into order state changes:

public class OrderService
{
    private readonly IEmailService _email;
    private readonly ISmsService _sms;
    private readonly IAnalyticsService _analytics;
    private readonly IWarehouseService _warehouse;
 
    // ⚠️ OrderService knows every subscriber — tight coupling
    public async Task UpdateStatusAsync(Guid orderId, OrderStatus newStatus)
    {
        var order = await _repository.GetAsync(orderId);
        order.Status = newStatus;
        await _repository.UpdateAsync(order);
 
        // ⚠️ Adding a new subscriber (push notification, ERP system) requires editing this method
        await _email.SendStatusUpdateAsync(order.Customer.Email, order.Id, newStatus);
        await _sms.SendStatusUpdateAsync(order.Customer.Phone, order.Id, newStatus);
        await _analytics.TrackStatusChangeAsync(order.Id, newStatus);
        await _warehouse.NotifyStatusChangeAsync(order.Id, newStatus);
    }
}

A push notification requires another OrderService dependency and another call. The publisher has become a registry of notification channels.

Solution

C# events work for synchronous notifications. An explicit observer interface gives server-side code an awaitable contract and clearer failure handling:

// Approach 1: C# event — the idiomatic .NET Observer
public class Order
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; private set; }
    public Customer Customer { get; set; } = null!;
 
    // ✅ event IS the Observer pattern — subscribers register independently
    public event EventHandler<OrderStatusChangedEventArgs>? StatusChanged;
 
    public void UpdateStatus(OrderStatus newStatus)
    {
        var previous = Status;
        Status = newStatus;
        // ✅ Raise event — Order doesn't know who's listening
        StatusChanged?.Invoke(this, new OrderStatusChangedEventArgs(Id, previous, newStatus));
    }
}
 
public record OrderStatusChangedEventArgs(Guid OrderId, OrderStatus Previous, OrderStatus New)
    : EventArgs;
 
// Synchronous UI subscriber
public class StatusBadge
{
    public string Text { get; private set; } = "Pending";
 
    public void Subscribe(Order order) =>
        order.StatusChanged += (_, e) => Text = e.New.ToString();
}
 
// Approach 2: awaitable observers discovered through DI
public interface IOrderStatusObserver
{
    Task OnStatusChangedAsync(Order order, OrderStatus previous, OrderStatus current);
}
 
public class EmailNotifier(IEmailService email) : IOrderStatusObserver
{
    public Task OnStatusChangedAsync(Order order, OrderStatus previous, OrderStatus current) =>
        email.SendStatusUpdateAsync(order.Customer.Email, order.Id, current);
}
 
public class AnalyticsTracker(IAnalyticsService analytics) : IOrderStatusObserver
{
    public Task OnStatusChangedAsync(Order order, OrderStatus previous, OrderStatus current) =>
        analytics.TrackStatusChangeAsync(order.Id, current);
}
 
public class PushNotifier(IPushService push) : IOrderStatusObserver
{
    public Task OnStatusChangedAsync(Order order, OrderStatus previous, OrderStatus current) =>
        push.SendAsync(order.Customer.DeviceToken, $"Order {order.Id}: {current}");
}
 
public class OrderStatusService(IEnumerable<IOrderStatusObserver> observers)
{
    public async Task UpdateStatusAsync(Order order, OrderStatus newStatus)
    {
        var previous = order.Status;
        order.UpdateStatus(newStatus);
        await Task.WhenAll(
            observers.Select(o => o.OnStatusChangedAsync(order, previous, newStatus)));
    }
}
 
// DI registration for explicit observer approach
builder.Services.AddScoped<IOrderStatusObserver, EmailNotifier>();
builder.Services.AddScoped<IOrderStatusObserver, AnalyticsTracker>();
// ✅ Adding push notifications = register new observer, zero code changes
builder.Services.AddScoped<IOrderStatusObserver, PushNotifier>();

A push subscriber is registered alongside the others. The publisher contract does not change.

Events and Observable APIs in .NET

C# event / delegate supplies the language-native subscription list. button.Click += handler registers a callback and -= removes it.

IObservable<T> / IObserver<T> model push streams through OnNext, OnError, and OnCompleted. Reactive Extensions builds composition operators over that protocol.

INotifyPropertyChanged lets binding infrastructure observe ViewModel property changes without the ViewModel knowing which controls display them.

ObservableCollection<T> raises CollectionChanged after mutations. UI collection views subscribe and update their projection.

IChangeToken / ChangeToken.OnChange() exposes one-shot change notifications, including configuration reload signals.

Pitfalls

Unsubscribed event handlers. A long-lived publisher retains its delegate targets. A closed view can stay alive because its handler is still attached to a surviving ViewModel. Unsubscribe with the owning lifetime or use a weak-event mechanism where that ownership cannot be aligned.

One observer breaks the notification loop. A synchronous event invocation stops when a handler throws. Explicit async observers can isolate failures or aggregate them with Task.WhenAll, but the policy must say whether one subscriber may fail the whole operation.

Hidden ordering dependencies. Task.WhenAll starts independent observers together and gives no completion order. When WarehouseNotifier must finish before ShippingNotifier, await observers sequentially in an explicit workflow or chain.

Tradeoffs

ConcernC# eventDI-discovered observers
Async supportAwkward (async void or fire-and-forget)Natural (Task-returning interface)
Error handlingExceptions propagate to publisherCan wrap each observer independently
Subscriber discoveryManual registrationDI container can inject all implementations
Completion orderInvocation-list order for synchronous handlersNone with Task.WhenAll. Use a sequential awaited loop when order matters
Lifetime and removalMust hold the handler delegate and unsubscribeGoverned by DI registration and the containing scope

C# events fit synchronous UI notifications. Server-side async subscribers need a Task-returning interface so completion and failure remain observable. IObservable<T> with Rx becomes worthwhile when the event stream itself needs composition over time.

Questions

References