Observer Pattern in C#: Events and IObservable
Observer pattern in C# / .NET 10: notify subscribers when state changes via event, IObservable, INotifyPropertyChanged, or Channel — pick the right one for the case.
Table of contents
- What problem does the Observer pattern solve in C#?
- How does the C# event keyword implement Observer?
- When does IObservable replace event in modern C#?
- When should you reach for INotifyPropertyChanged?
- How does Observer compare to Mediator and Command?
- When does the Observer pattern misfire?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
The cart's UI shows a "(N items)" badge in the header. Every time the cart changes — add, remove, quantity update — the badge must refresh. The naive way wires the cart class to know about the header view: a tight coupling that breaks unit testing and stops two views from showing the same cart at the same time.
The Observer pattern is the answer. The cart raises a "changed" event. Whoever cares — the badge, the totals row, the analytics module — subscribes. The cart never knows their classes, only that someone is listening. In modern C# this is one of those patterns where the language gives you four different tools for slightly different cases; this article is about picking the right one.
What problem does the Observer pattern solve in C#?
The pattern earns its place when state changes in one place must be reflected in zero or more other places, and the place that changes should not know who else cares. Three concrete shapes:
- UI binding. A view-model property changes; one or more bound views update.
- Domain events. "Order shipped" must wake up a tracking widget, log an audit row, and possibly trigger a webhook.
- Streamed data. Stock ticks, sensor readings, log lines. A producer emits; many consumers subscribe with their own filters and rates.
What is not an Observer problem: "I want to broadcast through a hub" — that is Mediator. "I want to queue actions for replay" — that is Command. Observer is specifically about direct, push-style notification.
How does the C# event keyword implement Observer?
The textbook publisher with a typed event:
public sealed class Cart
{
public event EventHandler<CartChangedEventArgs>? Changed;
private readonly List<LineItem> _items = new();
public void Add(LineItem item)
{
_items.Add(item);
Changed?.Invoke(this, new CartChangedEventArgs(ChangeKind.Added, item));
}
public void Remove(string sku)
{
var item = _items.FirstOrDefault(i => i.Sku == sku);
if (item is null) return;
_items.Remove(item);
Changed?.Invoke(this, new CartChangedEventArgs(ChangeKind.Removed, item));
}
}
public sealed record CartChangedEventArgs(ChangeKind Kind, LineItem Item);
public enum ChangeKind { Added, Removed, QuantityChanged }
Subscribers attach with += and detach with -=:
EventHandler<CartChangedEventArgs> handler = (sender, e) =>
badge.Text = $"({((Cart)sender!).Items.Count} items)";
cart.Changed += handler;
// later
cart.Changed -= handler;
The publisher knows nothing about the badge. Multiple subscribers work the same way. The structural picture:
flowchart LR
Cart -->|raises Changed| H1[Badge handler]
Cart -->|raises Changed| H2[Totals handler]
Cart -->|raises Changed| H3[Analytics handler]
Forgetting -= keeps handler (and whatever it captured) alive
for the lifetime of the cart. Long-running apps with many
subscriptions are exactly where this leak hurts most.
When does IObservable replace event in modern C#?
Reactive Extensions for .NET (Rx.NET)
gives you IObservable<T> — a stream of events you can compose
with LINQ-style operators:
public sealed class Cart
{
private readonly Subject<CartChangedEventArgs> _changes = new();
public IObservable<CartChangedEventArgs> Changes => _changes;
public void Add(LineItem item)
{
// ...
_changes.OnNext(new CartChangedEventArgs(ChangeKind.Added, item));
}
}
// Subscriber: throttle, filter, group
using IDisposable sub = cart.Changes
.Throttle(TimeSpan.FromMilliseconds(100)) // collapse rapid edits
.Where(e => e.Kind != ChangeKind.QuantityChanged)
.Subscribe(e => Console.WriteLine($"{e.Kind}: {e.Item.Sku}"));
Subscribe returns an IDisposable; Dispose() is the
unsubscribe call. The leak surface shrinks because cleanup is a
language-level pattern (using) instead of a discipline.
Three reasons to prefer IObservable<T> over event:
- Composition.
Throttle,Buffer,CombineLatest,DistinctUntilChanged— operators that turn raw notifications into useful behaviour without inventing classes. - Lifetime hygiene.
IDisposableis enforced by the type;usingand DI can manage it. - Backpressure (with
Channel<T>). Where Rx is push-only,System.Threading.Channels.Channel<T>adds bounded queues between producer and consumer.
When should you reach for INotifyPropertyChanged?
INotifyPropertyChanged is the data-binding flavour of Observer.
The publisher is usually a view-model; the subscribers are the
binding engine of WPF, WinUI, or MAUI:
public sealed class CartViewModel : INotifyPropertyChanged
{
private int _itemCount;
public int ItemCount
{
get => _itemCount;
set
{
if (_itemCount == value) return;
_itemCount = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ItemCount)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
The XAML binding <TextBlock Text="{Binding ItemCount}" />
listens for PropertyChanged with propertyName == "ItemCount"
and refreshes the visual. You almost never write a XAML view that
calls Subscribe — the framework does it; you just implement the
interface. Modern alternatives like
CommunityToolkit.Mvvm
generate the boilerplate from [ObservableProperty] attributes.
How does Observer compare to Mediator and Command?
| Pattern | Hub? | Direction | Modern .NET |
|---|---|---|---|
| Observer | None | One publisher → many subscribers, direct | event, IObservable<T>, Channel<T> |
| Mediator | Required | Many ↔ many through hub | MediatR IMediator.Publish |
| Command | Optional dispatcher | One sender → one handler | IRequest<T> + IRequestHandler<T,U> |
The cleanest sentence: Observer is push, direct; Mediator is push through a hub; Command is send-and-receive exactly once. The boundary between Observer and Mediator is discoverability — Mediator subscribers register with a third party; Observer subscribers register with the publisher.
When does the Observer pattern misfire?
Three traps:
- Memory leaks from forgotten
-=. The most common production bug for long-running .NET services. UseIDisposablereturned by Rx, or weak-event helpers, or document an explicit unsubscribe step in the lifecycle. - Order-of-handler dependencies. Two handlers that both edit shared state will collide. Order is the registration order in events but not guaranteed across all flavours. Make handlers independent.
- Reentrancy. A handler raises another change on the publisher
inside the handler. Either disallow reentrancy (
if (_inEvent) return;) or document the recursion contract.
What does a real .NET 10 example look like?
Combining event for simple cases and IObservable<T> (via
Subject<T>) for streamed cases on the same cart:
public enum ChangeKind { Added, Removed, QuantityChanged }
public sealed record CartChange(ChangeKind Kind, LineItem Item, int NewItemCount);
public sealed class Cart
{
private readonly List<LineItem> _items = new();
private readonly Subject<CartChange> _stream = new();
public IReadOnlyList<LineItem> Items => _items;
public IObservable<CartChange> Changes => _stream;
public void Add(LineItem item)
{
_items.Add(item);
_stream.OnNext(new CartChange(ChangeKind.Added, item, _items.Count));
}
public void Remove(string sku)
{
var item = _items.FirstOrDefault(i => i.Sku == sku);
if (item is null) return;
_items.Remove(item);
_stream.OnNext(new CartChange(ChangeKind.Removed, item, _items.Count));
}
}
// Subscriber A — UI badge updates on every change
IDisposable badgeSub = cart.Changes.Subscribe(c =>
Badge.Text = $"({c.NewItemCount} items)");
// Subscriber B — analytics, throttled and filtered
IDisposable analyticsSub = cart.Changes
.Where(c => c.Kind == ChangeKind.Added)
.Throttle(TimeSpan.FromSeconds(1))
.Subscribe(c => analytics.Track("add_to_cart", c.Item.Sku));
// Cleanup
badgeSub.Dispose();
analyticsSub.Dispose();
// Test
[Fact]
public void Adding_item_pushes_change()
{
var cart = new Cart();
var seen = new List<CartChange>();
using var sub = cart.Changes.Subscribe(seen.Add);
cart.Add(new LineItem("BOOK", 9.99m, 2));
Assert.Single(seen);
Assert.Equal(ChangeKind.Added, seen[0].Kind);
}
The cart does not know about the badge or the analytics module.
Each subscriber owns its lifetime via the returned IDisposable.
Adding a third subscriber is one new Subscribe call.
Where should you read next in this series?
- Previous: Memento — snapshotting state for undo and rollback.
- Next: State — when an object's behaviour depends on which state it is in.
- Cross-reference: Mediator — when notifications go through a hub instead of directly to subscribers.
- Cross-reference: Iterator — when consumers pull instead of being pushed to.
- Decision tree: How to choose the right design pattern.
A practical note: the Observer pattern in modern .NET is split
across four tools, and choosing the right one is most of the
work. event for simple in-process, IObservable<T> for
streamed transformations, INotifyPropertyChanged for binding,
Channel<T> for producer-consumer with backpressure. Get the
choice right and the rest of the code falls into place; get it
wrong and you fight the framework.
Frequently asked questions
When should I use event versus IObservable in C#?
event for in-process, synchronous, low-rate notifications where subscribers are short-lived and known to the publisher's lifetime. Use IObservable<T> (Rx.NET) when notifications form a stream you want to filter, throttle, buffer, or combine — Rx gives you LINQ over events. Use Channel<T> when you need backpressure between a producer and one or more consumers.Is INotifyPropertyChanged the Observer pattern?
INotifyPropertyChanged is Observer specialised for property-level change notifications, which is what data-binding frameworks (WPF, WinUI, MAUI) listen to. The publisher is the view-model; the subscriber is the binding system. Same pattern, with a fixed event signature (PropertyChanged) and a string property-name argument.How does Observer differ from Mediator?
What is the right way to unsubscribe from a C# event?
+= with a -= on the same delegate instance. The biggest source of memory leaks in long-running .NET apps is forgotten event subscriptions: the publisher keeps the subscriber alive forever. For Rx.NET, Subscribe() returns an IDisposable — dispose it to unsubscribe. For one-shot subscriptions, WeakReference patterns or weak-event helpers exist but are harder to get right than disciplined -=.