GoF Behavioral Pattern

Observer Pattern

When something changes, tell everyone who cares — automatically. That's Observer in a nutshell.

29 Q&As 5 Bug Studies 8 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR

Observer in one line: "Hey, something changed! Everyone who signed up to hear about it — here's your update." That's it. One object broadcasts a change, and every object that said "I care about this" gets notified automatically. Nobody else is bothered.

What: Imagine you follow a news channel. When they publish something new, you get a notification — you didn't have to keep checking. That's the Observer pattern. One object (called the SubjectThe "broadcaster" — the object that holds data and sends out updates when something changes. Think of a YouTube channel, a stock price feed, or a weather station. In code, this is often called a Subject, Publisher, or Observable. It keeps a list of everyone who subscribed and notifies them all when something happens., or "publisher") keeps a list of interested parties (called ObserversThe "listeners" — objects that said "let me know when something changes." Each observer decides on its own what to do with the update: maybe display it on screen, log it, send an email, or just ignore it. The Subject doesn't care what observers do — it just delivers the news., or "subscribers"). When the Subject's data changes, it walks through that list and tells each one: "here's what's new." The beauty? The Subject doesn't know or care who's listening. Add a hundred new listeners tomorrow — the Subject's code stays exactly the same.

When: Use it whenever a change in one place needs to trigger reactions in other places — without those places knowing about each other. Real-world examples: a stock price updates and five different screens refresh, a user clicks "Buy" and the inventory system, email service, and analytics tracker all respond, a sensor reading changes and multiple dashboards update.

In C# / .NET: The framework gives you three built-in ways to do this. event / delegateC#'s native Observer mechanism. You declare an event on your class and other objects subscribe with +=. When you "raise" the event, every subscriber gets called automatically. This is the simplest and most common approach — you'll see it everywhere in .NET codebases. — the simplest and most common way (you'll use this 90% of the time). IObservable<T> / IObserver<T>Built-in .NET interfaces for a more structured Observer. IObservable<T> is the Subject side (the thing you subscribe to). IObserver<T> is the listener side, with three callbacks: "here's new data" (OnNext), "something went wrong" (OnError), and "we're done, no more updates" (OnCompleted). This is the foundation of Rx.NET for reactive programming. — built-in interfaces for when you need a stream of data with start/error/done signals. And Channel<T>A high-performance async pipe. Think of it as a thread-safe mailbox: one side writes messages in, the other side reads them out. Great for background processing and producer/consumer scenarios where you need to handle updates asynchronously. — an async-friendly pipe for high-performance scenarios.

Quick Code:

Observer-at-a-glance.cs
// .NET's built-in IObserver<T> / IObservable<T> interfaces
// (both are in System namespace — no packages needed)

// The Subject: produces stock price updates
public sealed class StockTicker : IObservable<decimal>
{
    private readonly List<IObserver<decimal>> _observers = new();

    public IDisposable Subscribe(IObserver<decimal> observer)
    {
        _observers.Add(observer);
        return new Unsubscriber(_observers, observer);   // caller holds this to unsubscribe
    }

    public void UpdatePrice(decimal newPrice)
    {
        foreach (var observer in _observers.ToList())    // ToList = safe iteration
            observer.OnNext(newPrice);
    }

    private sealed class Unsubscriber(List<IObserver<decimal>> observers, IObserver<decimal> observer)
        : IDisposable
    {
        public void Dispose() => observers.Remove(observer);
    }
}

// An Observer: reacts to price changes
public sealed class PriceAlertObserver(string stockSymbol, decimal threshold)
    : IObserver<decimal>
{
    public void OnNext(decimal price)
    {
        if (price > threshold)
            Console.WriteLine($"ALERT: {stockSymbol} crossed {threshold:C} — now {price:C}!");
    }
    public void OnError(Exception error) => Console.WriteLine($"Feed error: {error.Message}");
    public void OnCompleted() => Console.WriteLine("Market closed.");
}

// Usage
var ticker = new StockTicker();

using var sub1 = ticker.Subscribe(new PriceAlertObserver("MSFT", 400m));
using var sub2 = ticker.Subscribe(new PriceAlertObserver("MSFT", 420m));

ticker.UpdatePrice(395m);   // no alerts
ticker.UpdatePrice(410m);   // sub1 fires
ticker.UpdatePrice(425m);   // both fire
// sub1 and sub2 auto-unsubscribe when their using blocks end
Section 2

Prerequisites

Before diving in:
Interfaces & Polymorphism — The Subject keeps a list of listeners, but it doesn't know the exact type of each listener — it only knows they all follow the same contract (interface). If the idea of "programming to an interface" feels fuzzy, review that first. Delegates & Events — C# has Observer built into the language through the event keywordIn C#, 'event' is a modifier that lets other objects subscribe (+= to listen) and unsubscribe (-= to stop listening), but prevents them from overwriting or triggering the event directly. It's like giving people a "subscribe" button without giving them the "send notification" button.. If you understand how += adds a listener and -= removes one, you're good. Generics — Most Observer types in .NET use <T> so they can work with any kind of data. If you can read List<string> and understand the <T> means "this works with any type," you're set. IDisposable & the using pattern — When you subscribe to something, you need a way to unsubscribe later (otherwise you get memory leaks). In .NET, subscribing gives you a small "receipt" object — when you're done, you dispose it and the subscription ends cleanly. That's the IDisposableA standard .NET interface that says "I have something to clean up." For Observer, disposing the subscription receipt removes you from the listener list. The 'using' keyword automates this — when the block ends, cleanup happens automatically. pattern. Basic SOLID (especially OCP & DIP) — Observer is a real-world example of two SOLID principles: you can add new listeners without changing the broadcaster (Open/Closed), and the broadcaster depends on an abstraction, not on specific listener types (Dependency Inversion). Knowing these makes the "why" click instantly. Thread Safety Basics — In real apps, notifications often happen from background work (timers, network calls). If two things try to modify the listener list at the same time, things break. Knowing what a race conditionWhen two threads try to do something at the same time and the result depends on which one gets there first. Example: Thread A is walking through the listener list to notify everyone, while Thread B is adding a new listener to that same list. The list changes mid-walk — crash. is and why lock exists will help you with the concurrency sections later.
Section 3

Real-World Analogies

YouTube Subscriptions (Primary Analogy)

You subscribe to a YouTube channel. The channel is the Subject — the thing that has updates to share. You are the Observer — someone who said "notify me." When the creator uploads a new video, YouTube sends a notification to every subscriber. You react however you want — watch it, ignore it, save it for later. The channel doesn't know or care what you do. It just says "new video!" to everyone on the list.

YouTube WorldWhat it meansIn code
YouTube ChannelThe broadcaster — holds data, sends updatesSubject / IObservable<T>
Subscriber (you)A listener — receives updatesObserver / IObserver<T>
Hit Subscribe buttonSign up for notifications.Subscribe(observer)
Hit UnsubscribeStop getting notificationssubscription.Dispose()
New video uploadedSomething changed!Notify()
Notification bell rings"Here's new data for you"OnNext(value)
Channel deleted"We're done, no more updates"OnCompleted()
Upload failed / error"Something went wrong"OnError(exception)
The key insight: The channel has NO IDEA what you do when you get a notification. Maybe you watch immediately, maybe you save it for later, maybe you ignore it completely. The channel just broadcasts. This is what makes Observer powerful — the broadcaster and the listeners don't need to know about each other. Add 10 million subscribers tomorrow: the channel's code doesn't change by a single character.

Newsletter / Mailing List

You sign up for a newsletter with your email. Every time the publisher writes a new issue, it lands in your inbox automatically — you didn't have to check. You can unsubscribe whenever you want. The publisher doesn't care if 10 people or 10,000 are on the list — the writing process is the same. This is the push modelThe broadcaster sends the data directly to you when something new happens — you don't have to go fetch it yourself. Like how a newsletter arrives in your inbox vs. you having to visit a website to check for new posts. of Observer: the data comes to you, you don't go looking for it.


Subject: Newsletter publisher

Observer: Each email subscriber

State change: New issue published

Notification: Email delivered

Weather Station

A weather station measures temperature, humidity, and pressure. Multiple displays (current conditions, forecast, heat index) all observe the same station. When the sensor reads new data, all displays update automatically — each display only cares about the data it needs. This is the classic example from the GoF Behavioral Patterns chapter!


Subject: WeatherStation (sensor)

Observers: CurrentConditionsDisplay, ForecastDisplay, HeatIndexDisplay

Notification: New sensor reading

Auction House

An auctioneer announces each new bid to the room. Every bidder (observer) hears the announcement (notification) and decides independently whether to raise their paddle (react). The auctioneer doesn't know — or care — who's in the room. New bidders walk in (subscribe) and walk out (unsubscribe) without the auctioneer changing their script.


Subject: Auctioneer / bid tracking system

Observers: Bidders in the room

State change: Highest bid increases

Notification: "We have $500, do I hear $550?"

Section 4

Core Pattern & UML

<<interface>> ISubject + Attach(observer) + Detach(observer) + Notify() + State: T <<interface>> IObserver<T> + OnNext(value: T) + OnError(error) + OnCompleted() ConcreteSubject - _observers: List<T> - _state: T + Attach(o) / Detach(o) + Notify() ConcreteObserver - _localState + OnNext(value: T) + OnError(error) implements implements notifies * Subject holds IObserver<T> list — never knows concrete types
Key insight — look at the arrows: The Subject only knows about the interface (the contract), not about any specific listener. It doesn't say "notify PriceDisplay and Logger" — it says "notify everyone on my list." This means you can add completely new listener types months from now and the Subject's code never changesBecause the Subject's list is typed as a list of the interface (IObserver<T>), not a list of specific classes. Any class that implements the interface can join the list. This is the Open/Closed Principle (OCP) and Dependency Inversion Principle (DIP) in action.. That's the whole point.

The Four Roles

  • ISubject (the contract for broadcasters): Says "any broadcaster must support: add a listener, remove a listener, notify all listeners." In .NET, this is IObservable<T> with its single Subscribe() method.
  • ConcreteSubject (an actual broadcaster): A real class that holds data and keeps a list of listeners. When data changes, it walks the list and tells everyone. Example: StockTicker.
  • IObserver (the contract for listeners): Says "any listener must handle: new data arrived, an error happened, we're done." In .NET: IObserver<T> with OnNext, OnError, OnCompleted.
  • ConcreteObserver (an actual listener): A real class that decides what to do when it hears the update. Example: PriceAlertObserver that checks if a stock crossed a threshold.

Push vs Pull: Two Ways to Deliver Data

  • Push (the modern way): The broadcaster packages the new data and sends it directly to each listener — "Here, the price is now $415." The listener gets everything it needs in that one call. This is what IObservable<T> and .NET events do.
  • Pull (the old way): The broadcaster just taps the listener on the shoulder and says "something changed." The listener then has to go back to the broadcaster and ask "okay, what changed?" More flexible, but now the listener needs to know the broadcaster's API.
  • The original GoF book described pullThe 1994 GoF book used Update(Subject subject) — the listener received a reference to the broadcaster and had to call GetState() itself. Modern .NET strongly prefers push because it's simpler and the data flow is obvious., but modern .NET strongly prefers push — it's simpler and clearer.
Section 5

Code Implementations

Now that you understand the idea, let's see it in code. Here are four ways to implement Observer in C# — from the textbook classic to modern async streaming. Each tab shows a complete, runnable example (C# 12 / .NET 8).

Manual List<IObserver> implementation — mirrors the GoF diagram exactly. Good for learning the mechanics.

ClassicObserver.cs
// file-scoped namespace — C# 10+
namespace Observer.Classic;

// ── Interfaces (the GoF contract) ──────────────────────────────────────────

public interface IStockObserver
{
    void Update(string symbol, decimal price);
}

public interface IStockSubject
{
    void Attach(IStockObserver observer);
    void Detach(IStockObserver observer);
    void Notify();
}

// ── Concrete Subject ───────────────────────────────────────────────────────

public sealed class StockMarket : IStockSubject
{
    private readonly List<IStockObserver> _observers = new();
    private decimal _price;
    private string _symbol;

    public StockMarket(string symbol) => _symbol = symbol;

    // State property — calling setter triggers notification
    public decimal Price
    {
        get => _price;
        set
        {
            _price = value;
            Notify();   // automatic notification on state change
        }
    }

    public void Attach(IStockObserver observer)
    {
        if (!_observers.Contains(observer))
            _observers.Add(observer);
    }

    public void Detach(IStockObserver observer) => _observers.Remove(observer);

    public void Notify()
    {
        // ToList() snapshot prevents issues if an observer detaches itself
        // during notification (a real production concern)
        foreach (var observer in _observers.ToList())
            observer.Update(_symbol, _price);
    }
}

// ── Concrete Observers ─────────────────────────────────────────────────────

// Primary constructor (C# 12) — clean, no boilerplate
public sealed class PriceDisplay(string name) : IStockObserver
{
    public void Update(string symbol, decimal price) =>
        Console.WriteLine($"[{name}] {symbol}: {price:C}");
}

public sealed class TradingBot(decimal buyThreshold, decimal sellThreshold) : IStockObserver
{
    public void Update(string symbol, decimal price)
    {
        if (price <= buyThreshold)
            Console.WriteLine($"[Bot] BUY {symbol} at {price:C}");
        else if (price >= sellThreshold)
            Console.WriteLine($"[Bot] SELL {symbol} at {price:C}");
        else
            Console.WriteLine($"[Bot] HOLD {symbol} at {price:C}");
    }
}

public sealed class AuditLogger : IStockObserver
{
    private readonly List<string> _log = new();

    public void Update(string symbol, decimal price)
    {
        var entry = $"{DateTime.UtcNow:HH:mm:ss} | {symbol} | {price:C}";
        _log.Add(entry);
        Console.WriteLine($"[Audit] Logged: {entry}");
    }

    public IReadOnlyList<string> GetLog() => _log.AsReadOnly();
}

// ── Program ────────────────────────────────────────────────────────────────

var msft = new StockMarket("MSFT");

var display = new PriceDisplay("Bloomberg Terminal");
var bot = new TradingBot(buyThreshold: 380m, sellThreshold: 420m);
var audit = new AuditLogger();

msft.Attach(display);
msft.Attach(bot);
msft.Attach(audit);

msft.Price = 375m;   // all 3 observers notified
msft.Price = 395m;   // all 3 notified
msft.Price = 425m;   // all 3 notified — bot sells

msft.Detach(display); // display leaves

msft.Price = 410m;   // only bot + audit receive this one

C#'s event keywordUnder the hood, an event is a multicast delegate field with restricted access. The compiler generates add/remove accessors. Subscribers use +=, the publisher invokes the delegate. This IS the Observer pattern — just built into the language with syntax sugar. EventHandler<TEventArgs> is the standard pattern: sender is the Subject, EventArgs carries the notification data. is Observer built into the language. This is the most common .NET approach.

EventObserver.cs
namespace Observer.Events;

// ── EventArgs DTO — carries notification data ──────────────────────────────

// record = immutable DTO, perfect for event args (C# 9+)
public sealed record StockChangedEventArgs(
    string Symbol,
    decimal OldPrice,
    decimal NewPrice,
    DateTimeOffset Timestamp) : EventArgs;

// ── Publisher (Subject) ────────────────────────────────────────────────────

public sealed class StockTicker
{
    private decimal _price;

    // The event IS the Observer registration mechanism
    // EventHandler<T> = void(object? sender, T e) delegate signature
    public event EventHandler<StockChangedEventArgs>? StockChanged;

    public string Symbol { get; }
    public decimal Price
    {
        get => _price;
        set
        {
            var old = _price;
            _price = value;
            OnStockChanged(old, value);
        }
    }

    public StockTicker(string symbol, decimal initialPrice)
    {
        Symbol = symbol;
        _price = initialPrice;
    }

    private void OnStockChanged(decimal oldPrice, decimal newPrice)
    {
        // '?.Invoke' is null-safe: only calls if there are subscribers
        StockChanged?.Invoke(this, new StockChangedEventArgs(
            Symbol, oldPrice, newPrice, DateTimeOffset.UtcNow));
    }
}

// ── Subscribers (Observers) — just methods, no interface needed! ───────────

public sealed class Dashboard
{
    public void OnStockChanged(object? sender, StockChangedEventArgs e)
    {
        var arrow = e.NewPrice > e.OldPrice ? "↑" : "↓";
        Console.WriteLine($"[Dashboard] {e.Symbol} {arrow} {e.OldPrice:C} → {e.NewPrice:C}");
    }
}

public sealed class RiskManager
{
    private const decimal MaxDropPercent = 0.05m; // 5% drop triggers alert

    public void OnStockChanged(object? sender, StockChangedEventArgs e)
    {
        if (e.OldPrice > 0)
        {
            var dropPercent = (e.OldPrice - e.NewPrice) / e.OldPrice;
            if (dropPercent >= MaxDropPercent)
                Console.WriteLine($"[RISK] ALERT: {e.Symbol} dropped {dropPercent:P1}!");
        }
    }
}

// ── Wire-up ────────────────────────────────────────────────────────────────

var ticker = new StockTicker("AAPL", 180m);
var dashboard = new Dashboard();
var risk = new RiskManager();

// Subscribe — += adds to the multicast delegate chain
ticker.StockChanged += dashboard.OnStockChanged;
ticker.StockChanged += risk.OnStockChanged;

// Lambda observers — no class needed for simple reactions
ticker.StockChanged += (_, e) =>
    Console.WriteLine($"[Log] Price updated at {e.Timestamp:HH:mm:ss}");

ticker.Price = 185m;    // all 3 fire
ticker.Price = 175m;    // risk manager fires the 5% alert too

// Unsubscribe — -= removes from the multicast chain
ticker.StockChanged -= dashboard.OnStockChanged;

ticker.Price = 170m;    // risk + lambda only

IObservable<T>System.IObservable<T> is the Subject in .NET's reactive pattern. It has one method: IDisposable Subscribe(IObserver<T> observer). The returned IDisposable is the unsubscription token. IObserver<T> has three methods: OnNext (normal data), OnError (error in stream), OnCompleted (stream ended). This pair is the foundation of Rx.NET. — .NET's built-in reactive interfaces. No Rx.NET package required for basic usage.

ReactiveObserver.cs
namespace Observer.Reactive;

// ── Data type ──────────────────────────────────────────────────────────────

public sealed record TemperatureReading(string Sensor, double Celsius, DateTimeOffset At);

// ── Subject implements IObservable<T> ─────────────────────────────────────

public sealed class TemperatureSensor : IObservable<TemperatureReading>
{
    // Thread-safe snapshot for concurrent subscribe/unsubscribe
    private readonly object _lock = new();
    private readonly List<IObserver<TemperatureReading>> _observers = new();

    public string Name { get; }
    public TemperatureSensor(string name) => Name = name;

    public IDisposable Subscribe(IObserver<TemperatureReading> observer)
    {
        lock (_lock)
        {
            if (!_observers.Contains(observer))
                _observers.Add(observer);
        }
        return new Subscription(this, observer);
    }

    private void Remove(IObserver<TemperatureReading> observer)
    {
        lock (_lock) { _observers.Remove(observer); }
    }

    public void Publish(double celsius)
    {
        var reading = new TemperatureReading(Name, celsius, DateTimeOffset.UtcNow);
        List<IObserver<TemperatureReading>> snapshot;
        lock (_lock) { snapshot = _observers.ToList(); }

        foreach (var observer in snapshot)
            observer.OnNext(reading);
    }

    public void Fail(string errorMessage)
    {
        List<IObserver<TemperatureReading>> snapshot;
        lock (_lock) { snapshot = _observers.ToList(); }
        var ex = new InvalidOperationException(errorMessage);
        foreach (var observer in snapshot)
            observer.OnError(ex);
    }

    public void Shutdown()
    {
        List<IObserver<TemperatureReading>> snapshot;
        lock (_lock) { snapshot = _observers.ToList(); _observers.Clear(); }
        foreach (var observer in snapshot)
            observer.OnCompleted();
    }

    // Nested subscription token — clean unsubscription via IDisposable
    private sealed class Subscription(TemperatureSensor sensor, IObserver<TemperatureReading> observer)
        : IDisposable
    {
        private bool _disposed;
        public void Dispose()
        {
            if (!_disposed) { sensor.Remove(observer); _disposed = true; }
        }
    }
}

// ── Observers implement IObserver<T> ──────────────────────────────────────

public sealed class ThermostatController(double setPoint) : IObserver<TemperatureReading>
{
    public void OnNext(TemperatureReading r)
    {
        if (r.Celsius > setPoint + 2)
            Console.WriteLine($"[Thermostat] Too hot ({r.Celsius:F1}°C) — cooling ON");
        else if (r.Celsius < setPoint - 2)
            Console.WriteLine($"[Thermostat] Too cold ({r.Celsius:F1}°C) — heating ON");
        else
            Console.WriteLine($"[Thermostat] OK ({r.Celsius:F1}°C)");
    }
    public void OnError(Exception e) => Console.WriteLine($"[Thermostat] Sensor error: {e.Message}");
    public void OnCompleted() => Console.WriteLine("[Thermostat] Sensor offline.");
}

// ── Usage ──────────────────────────────────────────────────────────────────

var sensor = new TemperatureSensor("LivingRoom");
var thermostat = new ThermostatController(setPoint: 21.0);

using var sub = sensor.Subscribe(thermostat);   // IDisposable = auto-unsubscribe
sensor.Subscribe(new ThermostatController(19.0));  // second observer

sensor.Publish(18.0);   // too cold — both thermostats react
sensor.Publish(21.5);   // OK for thermostat-1, too hot for thermostat-2
sensor.Fail("Battery low");     // OnError on both
sensor.Shutdown();              // OnCompleted on both

Modern async Observer using Channel<T>System.Threading.Channels.Channel<T> — a high-performance, async-first bounded or unbounded queue. Introduced in .NET Core 3.0. Think of it as a thread-safe async pipe: one or more producers write to ChannelWriter<T>, one or more consumers read from ChannelReader<T> via await foreach. Supports backpressure (bounded channels block/drop when full). + IAsyncEnumerable<T> — the modern choice for async event streams in .NET 8.

AsyncObserver.cs
using System.Threading.Channels;
namespace Observer.Async;

public sealed record OrderEvent(string OrderId, string Status, DateTimeOffset At);

// ── Async Subject — broadcasts to multiple channel consumers ───────────────

public sealed class OrderEventBus : IAsyncDisposable
{
    // Bounded channel: if consumer falls behind, writer will await or drop
    private readonly List<Channel<OrderEvent>> _channels = new();
    private readonly object _lock = new();

    public ChannelReader<OrderEvent> Subscribe()
    {
        // Bounded(100): at most 100 buffered events per consumer
        var channel = Channel.CreateBounded<OrderEvent>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.DropOldest,  // drop old events if consumer slow
                SingleWriter = false,
                SingleReader = true
            });

        lock (_lock) { _channels.Add(channel); }
        return channel.Reader;
    }

    public async Task PublishAsync(OrderEvent evt, CancellationToken ct = default)
    {
        List<Channel<OrderEvent>> snapshot;
        lock (_lock) { snapshot = _channels.ToList(); }

        // Broadcast to all consumers concurrently
        var tasks = snapshot.Select(ch =>
            ch.Writer.WriteAsync(evt, ct).AsTask());
        await Task.WhenAll(tasks);
    }

    public async ValueTask DisposeAsync()
    {
        List<Channel<OrderEvent>> snapshot;
        lock (_lock) { snapshot = _channels.ToList(); _channels.Clear(); }
        foreach (var ch in snapshot)
            ch.Writer.Complete();   // signals OnCompleted to all readers
    }
}

// ── Async Observers — consume via await foreach ────────────────────────────

public static class OrderProcessors
{
    public static async Task RunEmailNotifier(
        ChannelReader<OrderEvent> reader, CancellationToken ct)
    {
        await foreach (var evt in reader.ReadAllAsync(ct))
        {
            Console.WriteLine($"[Email] Order {evt.OrderId} status: {evt.Status}");
            await Task.Delay(10, ct);   // simulate async email send
        }
    }

    public static async Task RunAnalytics(
        ChannelReader<OrderEvent> reader, CancellationToken ct)
    {
        await foreach (var evt in reader.ReadAllAsync(ct))
            Console.WriteLine($"[Analytics] Tracking {evt.Status} for {evt.OrderId}");
    }
}

// ── Usage ──────────────────────────────────────────────────────────────────

using var cts = new CancellationTokenSource();
await using var bus = new OrderEventBus();

var emailReader = bus.Subscribe();
var analyticsReader = bus.Subscribe();

// Consumers run concurrently
var emailTask = OrderProcessors.RunEmailNotifier(emailReader, cts.Token);
var analyticsTask = OrderProcessors.RunAnalytics(analyticsReader, cts.Token);

// Publish events
await bus.PublishAsync(new OrderEvent("ORD-001", "Placed", DateTimeOffset.UtcNow));
await bus.PublishAsync(new OrderEvent("ORD-001", "Shipped", DateTimeOffset.UtcNow));
await bus.PublishAsync(new OrderEvent("ORD-001", "Delivered", DateTimeOffset.UtcNow));

await bus.DisposeAsync();   // completes all channels
await Task.WhenAll(emailTask, analyticsTask);
Section 6

Jr vs Sr Implementation

The Task

Build a stock price tracker where multiple displays update whenever the price changes. Different display types (console, mobile, alert) should all work without the tracker knowing their concrete types.

How a Junior Thinks

"I'll just keep a list of displays and loop through them when the price changes. Simple!"
JuniorObserver.cs
public class PriceTracker
{
    private List<PriceDisplay> displays = new List<PriceDisplay>();
    public decimal CurrentPrice { get; private set; }

    public void AddDisplay(PriceDisplay d) => displays.Add(d);

    public void SetPrice(decimal price)
    {
        CurrentPrice = price;
        foreach (var d in displays)
            d.Refresh(price);
    }
}

public class PriceDisplay
{
    public string Name { get; set; }
    public PriceDisplay(string name) => Name = name;
    public void Refresh(decimal price) => Console.WriteLine($"{Name}: {price:C}");
}

Problems

Tight coupling to concrete types

List<PriceDisplay> means only PriceDisplay observers work. Adding MobileDisplay requires changing PriceTracker — violating the Open/Closed Principle.

No way to unsubscribe — memory leak

No RemoveDisplay() method. Every display added stays referenced forever. In long-running apps, this is a slow OOM crash.

Unsafe iteration — concurrent modification danger

If an observer unsubscribes during Refresh(), the foreach throws InvalidOperationException. No snapshot, no thread safety.

No exception isolation

If d2.Refresh() throws, d3 and d4 never get notified. One bad observer silently breaks the entire chain.

How a Senior Thinks

"I need an interface for observers, IDisposable subscriptions, thread-safe iteration, and exception isolation. Let me build it right from the start."
IStockObserver.cs
namespace StockTracker;

// ✅ Thin interface — only what observers need
public interface IStockObserver
{
    void OnPriceChanged(string symbol, decimal newPrice, decimal oldPrice);
}

// ✅ Immutable record for event data
public sealed record StockSnapshot(
    string Symbol,
    decimal Price,
    decimal Change,
    DateTimeOffset Timestamp)
{
    public decimal ChangePercent => Price > 0 ? Change / (Price - Change) * 100 : 0;
}
StockTicker.cs
namespace StockTracker;

public sealed class StockTicker
{
    private readonly object _lock = new();
    private readonly List<IStockObserver> _observers = new();
    private readonly ILogger<StockTicker> _logger;
    private decimal _price;
    public string Symbol { get; }

    public StockTicker(string symbol, ILogger<StockTicker> logger)
    {
        Symbol = symbol;
        _logger = logger;
    }

    public IDisposable Subscribe(IStockObserver observer)
    {
        ArgumentNullException.ThrowIfNull(observer);
        lock (_lock)
        {
            if (!_observers.Contains(observer))
                _observers.Add(observer);
        }
        return new Subscription(this, observer);
    }

    private void Unsubscribe(IStockObserver observer)
    {
        lock (_lock) { _observers.Remove(observer); }
    }

    public decimal Price
    {
        get => _price;
        set
        {
            var old = _price;
            if (old == value) return;
            _price = value;
            NotifyAll(old, value);
        }
    }

    private void NotifyAll(decimal oldPrice, decimal newPrice)
    {
        List<IStockObserver> snapshot;
        lock (_lock) { snapshot = _observers.ToList(); }

        foreach (var observer in snapshot)
        {
            try
            {
                observer.OnPriceChanged(Symbol, newPrice, oldPrice);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Observer {Type} threw during notification",
                    observer.GetType().Name);
            }
        }
    }

    private sealed class Subscription(StockTicker ticker, IStockObserver observer)
        : IDisposable
    {
        private int _disposed;
        public void Dispose()
        {
            if (Interlocked.Exchange(ref _disposed, 1) == 0)
                ticker.Unsubscribe(observer);
        }
    }
}
Program.cs
using Microsoft.Extensions.Logging;
using StockTracker;

public sealed class ConsoleDisplay(string label) : IStockObserver
{
    public void OnPriceChanged(string symbol, decimal newPrice, decimal oldPrice)
    {
        var dir = newPrice > oldPrice ? "▲" : "▼";
        Console.WriteLine($"[{label}] {symbol} {dir} {newPrice:C} (was {oldPrice:C})");
    }
}

public sealed class AlertingService(decimal alertThreshold) : IStockObserver
{
    public void OnPriceChanged(string symbol, decimal newPrice, decimal oldPrice)
    {
        if (newPrice >= alertThreshold)
            Console.WriteLine($"[ALERT] {symbol} hit threshold: {newPrice:C}!");
    }
}

using var logFactory = LoggerFactory.Create(b => b.AddConsole());
var logger = logFactory.CreateLogger<StockTicker>();

var msft = new StockTicker("MSFT", logger);

using var sub1 = msft.Subscribe(new ConsoleDisplay("Bloomberg"));
using var sub2 = msft.Subscribe(new AlertingService(420m));

msft.Price = 400m;
msft.Price = 415m;
msft.Price = 425m;  // alert fires
// sub1, sub2 disposed here — observers auto-removed
StockTickerTests.cs
public sealed class RecordingObserver : IStockObserver
{
    private readonly List<(string, decimal, decimal)> _calls = new();
    public IReadOnlyList<(string, decimal, decimal)> Calls => _calls.AsReadOnly();

    public void OnPriceChanged(string symbol, decimal newPrice, decimal oldPrice) =>
        _calls.Add((symbol, newPrice, oldPrice));
}

[Fact]
public void Subscribe_ObserverReceivesNotification()
{
    var ticker = new StockTicker("TEST", NullLogger<StockTicker>.Instance);
    var observer = new RecordingObserver();

    using var _ = ticker.Subscribe(observer);
    ticker.Price = 100m;

    Assert.Single(observer.Calls);
    Assert.Equal(100m, observer.Calls[0].Item2);
}

[Fact]
public void Dispose_StopsNotifications()
{
    var ticker = new StockTicker("TEST", NullLogger<StockTicker>.Instance);
    var observer = new RecordingObserver();

    var sub = ticker.Subscribe(observer);
    ticker.Price = 100m;
    sub.Dispose();
    ticker.Price = 200m;

    Assert.Single(observer.Calls);  // only first notification
}

Design Decisions

Interface-based decoupling

IStockObserver means the Subject never knows concrete types. Any class implementing the interface can subscribe — ConsoleDisplay, MobileApp, AlertingService. Adding new observer types requires zero changes to StockTicker.

IDisposable subscription tokens

Subscribe() returns an IDisposable. When disposed, the observer is automatically removed from the list. No explicit Unsubscribe() call needed — just using var sub = ticker.Subscribe(observer); and cleanup is guaranteed.

Thread-safe snapshot iteration

lock around subscribe/unsubscribe + _observers.ToList() snapshot before iterating. Lock is held only long enough to copy the list — never during observer callbacks (which could deadlock).

Exception isolation per observer

Each observer call is wrapped in try/catch. One bad observer can't break the notification chain. Failures are logged but the loop continues — all other observers still receive their updates.

Section 7

Evolution of Observer in .NET

Observer has been part of .NET since the very beginning — but the tools for building it have gotten better with every version. Here's how it evolved, so you know which approach belongs to which era.

.NET 1.0–1.1 (2002) — Delegates & Events: The OG Observer

The multicast delegateA delegate that holds references to multiple methods. When invoked, it calls ALL attached methods in order. This is the mechanical heart of .NET events — event += handler adds to the invocation list, event -= removes from it. The delegate handles multicasting automatically; no loop required on your end. was .NET 1.0's built-in Observer. No interfaces, no generics (not invented yet) — just raw delegates with custom EventArgs.

dotnet1-era.cs
// .NET 1.0 style -- no generics, custom delegate signatures
public delegate void PriceChangedEventHandler(object sender, decimal newPrice);

public class StockTicker
{
    public event PriceChangedEventHandler PriceChanged;  // Observer list

    private decimal _price;
    public decimal Price
    {
        get => _price;
        set { _price = value; PriceChanged?.Invoke(this, value); }
    }
}

var ticker = new StockTicker();
ticker.PriceChanged += new PriceChangedEventHandler(OnPriceChanged);

void OnPriceChanged(object sender, decimal price) =>
    Console.WriteLine($"Price: {price:C}");

Works perfectly — and you still see this in legacy codebases. Main limitation: no generic safety, custom delegate per event type.

.NET 2.0 (2005) — Generic EventHandler<T> + Anonymous Methods

Generics simplified everything. EventHandler<TEventArgs>A built-in generic delegate: void(object? sender, TEventArgs e). Standardized the event pattern across all of .NET. Instead of every team defining their own PriceChangedEventHandler, everyone uses EventHandler<PriceChangedEventArgs>. Convention: sender = the publishing object, EventArgs subclass carries the data. became the universal event signature. Anonymous methods eliminated the need for named observer methods.

dotnet2-era.cs
// .NET 2.0: generic EventHandler<T> -- one delegate to rule them all
public class PriceChangedEventArgs : EventArgs
{
    public decimal OldPrice { get; set; }
    public decimal NewPrice { get; set; }
}

public class StockTicker
{
    public event EventHandler<PriceChangedEventArgs>? PriceChanged;

    protected virtual void OnPriceChanged(decimal old, decimal @new) =>
        PriceChanged?.Invoke(this, new PriceChangedEventArgs { OldPrice = old, NewPrice = @new });
}

// Anonymous method -- no named method required
ticker.PriceChanged += delegate(object sender, PriceChangedEventArgs e)
{
    Console.WriteLine($"Changed: {e.OldPrice:C} -> {e.NewPrice:C}");
};

.NET 3.5–4.0 (2008–2010) — Lambdas, LINQ, and IObservable<T>

Lambda expressions made subscription one-liners. .NET 4.0 introduced IObservable<T> / IObserver<T>Added to System namespace in .NET 4.0. IObservable<T> = push-based data source. IObserver<T> has three methods: OnNext (data), OnError (failure), OnCompleted (stream ends). This models a complete lifecycle — events only model OnNext. Rx.NET released simultaneously, turning these into a full reactive programming library. as the foundation for Reactive Extensions (Rx.NET).

dotnet35-4-era.cs
// .NET 3.5: Lambda subscriptions -- one-liners
ticker.PriceChanged += (sender, e) =>
    Console.WriteLine($"Price: {e.NewPrice:C}");

// .NET 4.0: IObservable<T> + Rx.NET (System.Reactive NuGet)
var priceStream = Observable
    .FromEventPattern<PriceChangedEventArgs>(ticker, nameof(ticker.PriceChanged))
    .Select(e => e.EventArgs.NewPrice)
    .Where(price => price > 400m)        // filter: only high prices
    .Throttle(TimeSpan.FromSeconds(1));  // debounce: no spam

priceStream.Subscribe(
    onNext: price => Console.WriteLine($"High price: {price:C}"),
    onError: ex => Console.WriteLine($"Error: {ex.Message}"),
    onCompleted: () => Console.WriteLine("Stream ended"));

.NET 6–8 (2021–2023) — Async Streams, Channels, System.Reactive 6.0

Modern .NET completes the picture with async-native Observer. IAsyncEnumerable<T>C# 8 / .NET Core 3.0. Represents an async stream — a sequence of values produced asynchronously. Consumers use await foreach. Naturally lazy and integrates with CancellationToken. For Observer: the observer iterates, the subject yields. Ideal for I/O-bound event streams like database change feeds or Kafka consumers. and Channel<T> handle the async world; System.Reactive 6.0 adds full AOT support for .NET 8.

dotnet6-8-era.cs
// .NET 8: IAsyncEnumerable<T> as async observer stream
public static async IAsyncEnumerable<decimal> GetPriceStream(
    string symbol, [EnumeratorCancellation] CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        yield return await FetchLatestPriceAsync(symbol, ct);
        await Task.Delay(1000, ct);
    }
}

await foreach (var price in GetPriceStream("MSFT", cts.Token))
    Console.WriteLine($"MSFT: {price:C}");

// .NET 8: System.Reactive 6.0 with AOT support
var obs = Observable.Create<decimal>(async (observer, ct) =>
{
    await foreach (var price in GetPriceStream("MSFT", ct))
        observer.OnNext(price);
    observer.OnCompleted();
});

obs
    .Buffer(TimeSpan.FromSeconds(5))
    .Select(batch => batch.Average())
    .DistinctUntilChanged()
    .Subscribe(avg => Console.WriteLine($"5s avg: {avg:C}"));
Section 8

Observer in the .NET Framework

Observer is everywhere in .NET — you use it daily without thinking about it. Before building your own Observer from scratch, check if the framework already does what you need. Here are the most important examples.

Observer Mechanisms in .NET EVENT-BASED — C# language-level Observer INotifyPropertyChanged FileSystemWatcher IHostApplicationLifetime event / delegate REACTIVE / STREAM — push-based data sequences IObservable<T> IObserver<T> IChangeToken MEDIATOR — decoupled pub/sub via mediator MediatR INotification 🔔 Notification USAGE Event-Based Reactive Mediator

INotifyPropertyChanged (WPF / MAUI)

Ever wonder how UI screens update automatically when data changes? In WPF, WinUI 3, and MAUI, the answer is data bindingData binding connects what's on screen to the data behind it. When the data changes, the screen updates automatically — no manual "refresh" needed. Under the hood, it's Observer: your data object fires a "something changed" event, and the UI framework listens for it and redraws the right control. — and it's Observer under the hood. Your data object says "my price changed," and the UI framework hears that and updates the text on screen.

ViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;

public partial class StockViewModel : ObservableObject
{
    // [ObservableProperty] generates INPC property + notification
    [ObservableProperty]
    private decimal _price;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(PriceFormatted))]
    private string _symbol = "";

    public string PriceFormatted => $"{Symbol}: {Price:C}";
}
// XAML: <TextBlock Text="{Binding PriceFormatted}"/>
// The binding IS the observer -- subscribes to PropertyChanged

IChangeToken (Configuration Reload)

IChangeTokenMicrosoft.Extensions.Primitives.IChangeToken — a one-shot Observer token. HasChanged tells if the change already happened; RegisterChangeCallback subscribes to future changes. Used by IConfiguration (appsettings.json reload), IFileProvider (file changes), and routing. Single-use: after firing, get a new IChangeToken for the next change. is .NET's lightweight Observer for config changes. IOptionsMonitor<T> wraps it — your service gets notified automatically when appsettings.json changes with zero boilerplate.

ConfigObserver.cs
public sealed class PricingService
{
    private readonly IOptionsMonitor<PricingOptions> _options;

    public PricingService(IOptionsMonitor<PricingOptions> options)
    {
        _options = options;
        // Subscribe to config changes -- no IChangeToken manual work
        options.OnChange(newOpts =>
            Console.WriteLine($"Config reloaded: margin now {newOpts.Margin:P}"));
    }

    public decimal Calculate(decimal cost) =>
        cost * (1 + _options.CurrentValue.Margin);
}

FileSystemWatcher

System.IO.FileSystemWatcher wraps OS file system events as .NET events — pure Observer. Used in dev tools, hot-reload, and file processing pipelines. Subscribe to Changed, Created, Deleted, and Error events.

FileWatcher.cs
using var watcher = new FileSystemWatcher(@"C:\Logs")
{
    Filter = "*.log",
    NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName,
    EnableRaisingEvents = true
};

watcher.Changed += (_, e) => Console.WriteLine($"Changed: {e.FullPath}");
watcher.Created += (_, e) => Console.WriteLine($"New: {e.Name}");
watcher.Deleted += (_, e) => Console.WriteLine($"Deleted: {e.Name}");
watcher.Error  += (_, e) => Console.WriteLine($"Error: {e.GetException().Message}");

Console.ReadLine(); // keep alive

IHostApplicationLifetime

IHostApplicationLifetimeExposes three CancellationToken properties: ApplicationStarted, ApplicationStopping, ApplicationStopped. Register callbacks on any of them to observe host lifecycle events. Internally these are CancellationTokenSource objects — .Register() IS subscribing; the cancellation IS the notification. Observer via cancellation tokens. lets you observe app lifecycle — startup, graceful shutdown, stopped. Register handlers via CancellationToken.Register().

LifetimeObserver.cs
public sealed class AppLifetimeLogger(
    IHostApplicationLifetime lifetime,
    ILogger<AppLifetimeLogger> logger) : IHostedService
{
    public Task StartAsync(CancellationToken ct)
    {
        lifetime.ApplicationStarted.Register(() =>
            logger.LogInformation("App started"));
        lifetime.ApplicationStopping.Register(() =>
            logger.LogWarning("Shutdown signal received"));
        lifetime.ApplicationStopped.Register(() =>
            logger.LogInformation("App stopped"));
        return Task.CompletedTask;
    }
    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

MediatR Notifications

MediatRPopular .NET in-process messaging library. INotification + INotificationHandler<T> is textbook Observer — publish once, all registered handlers receive it. The Subject is IPublisher (the Mediator), Observers are INotificationHandler<T> implementations. Registration via DI — no explicit Subscribe/Unsubscribe needed. notifications are Observer at the application service layer: publish once, many handlers react. DI handles the observer list.

MediatRNotification.cs
public sealed record OrderPlacedNotification(
    string OrderId, string CustomerId, decimal Total) : INotification;

public sealed class SendConfirmationEmailHandler
    : INotificationHandler<OrderPlacedNotification>
{
    public Task Handle(OrderPlacedNotification n, CancellationToken ct)
    {
        Console.WriteLine($"[Email] Confirmation for {n.OrderId}");
        return Task.CompletedTask;
    }
}

public sealed class UpdateInventoryHandler
    : INotificationHandler<OrderPlacedNotification>
{
    public Task Handle(OrderPlacedNotification n, CancellationToken ct)
    {
        Console.WriteLine($"[Inventory] Reserved for {n.OrderId}");
        return Task.CompletedTask;
    }
}

// Publish = notify all handlers
await mediator.Publish(new OrderPlacedNotification("ORD-123", "CUST-001", 99.99m));

SignalR (Distributed Observer)

ASP.NET Core SignalRSignalR extends Observer across the network — the server (Subject) pushes messages to connected browser/mobile clients (Observers) via WebSocket, SSE, or Long Polling. Clients subscribe by joining Hub Groups. Takes Observer cross-process: Subject doesn't care if the observer is in the same process or in a browser tab thousands of miles away. extends Observer to browser/mobile clients. The server pushes updates; connected clients are the observers.

StockHub.cs
public sealed class StockHub : Hub
{
    public async Task Subscribe(string symbol) =>
        await Groups.AddToGroupAsync(Context.ConnectionId, symbol);

    public async Task Unsubscribe(string symbol) =>
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, symbol);
}

public sealed class PriceBroadcaster(IHubContext<StockHub> hub) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var price = GetCurrentPrice("MSFT");
            await hub.Clients.Group("MSFT")
                .SendAsync("PriceUpdate", price, ct);
            await Task.Delay(1000, ct);
        }
    }
}
Section 9

When To Use / When Not To

Use When

Things happen in your system — an order is placed, a user signs up, a stock price changes — and multiple parts of the app need to react independently
Your UI needs to update automatically when data changes in the background — WPF/MAUI data binding is Observer built into the framework
Real-time data feeds — stock prices, sensor readings, live dashboards — where multiple consumers process the same data stream independently
You want to log, track metrics, or send analytics without cluttering business logic — an audit observer listens quietly in the background
Plugin / extension systems — external code hooks into your events without you needing to know about the plugins at compile time

Don't Use When

Only ONE thing ever needs to know about a change — a simple callback or Action<T> is cleaner
Execution order matters — if Observer B must run after Observer A, use Chain of ResponsibilityPasses a request through a chain of handlers in order. Unlike Observer (all get notified at once), CoR processes sequentially. Use CoR when order matters; use Observer when all listeners are independent. instead
You need a response back from the "listener" — Observer is fire-and-forget. Use Strategy or a direct method call instead
Cross-process / distributed events — classic Observer is in-memory only. Use a message broker (RabbitMQ, Kafka, Azure Service Bus) for cross-service communication
Thousands of high-frequency observers — at extreme scale, in-process Observer becomes a bottleneck. Consider topic-based pub/subSystems like Kafka or Azure Service Bus where consumers subscribe to specific topics rather than one Subject. Distributes load and scales consumers independently. or batching

Decision Framework

Something changes & others need to know? NO No Observer needed YES How many listeners? ONE Simple callback / delegate MANY Cross-process? YES Message broker (RabbitMQ, Kafka) NO Need reactive stream (filter / map / combine)? YES IObservable<T> + Rx NO C# event / delegate ✓
Section 10

Comparisons

Observer looks similar to a few other patterns. Here's the difference in plain terms — so you always pick the right one.

Observer vs Mediator

Observer
  • Subject directly notifies its own list of observers
  • Subject knows it has listeners — it manages the list
  • Observers are loosely coupled via an interface
  • One-to-many: one subject, many listeners
  • Example: stock ticker notifying dashboards
VS
MediatorMediator centralizes communication — components don't talk directly, they route through the hub. MediatR is the canonical .NET implementation. Better than Observer when routing logic is complex or you need complete sender/receiver decoupling where even the publisher is unaware of what's downstream.
  • Everything routes through a central hub
  • Publisher doesn't know what's downstream at all
  • Components are fully decoupled — no direct references
  • Many-to-many: any component can talk to any other via hub
  • Example: MediatR routing commands/events in ASP.NET

Observer (in-process) vs Pub/Sub (distributed)

Observer (in-process)
  • Same process, typically same thread
  • Subject holds direct references to observers
  • Synchronous by default
  • No infrastructure needed — just code
  • Observer list is explicit and inspectable
VS
Pub/Sub (distributed)Publisher/Subscriber via message brokers (RabbitMQ, Kafka, Azure Service Bus). Publishers don't know subscribers exist. Messages go to a broker topic; subscribers consume independently. Fully decoupled: publisher and subscriber can be different services, different languages, even different time zones — subscriber can read messages hours after publishing.
  • Cross-process, cross-service, even cross-datacenter
  • Message broker sits in between (no direct refs)
  • Asynchronous, with persistence and retry
  • Requires broker infrastructure (RabbitMQ, Kafka, etc.)
  • Publishers completely unaware of subscribers

Events / Delegates vs IObservable<T>

Events / Delegates
  • Language-native, zero dependencies
  • One notification model: fire and forget
  • No built-in error or completion signals
  • Hard to compose (no filter/map/merge)
  • Best for: simple UI events, lifecycle hooks
VS
IObservable<T> / Rx.NET
  • Richer contract: OnNext + OnError + OnCompleted
  • Composable: Where, Select, Throttle, Buffer, Merge
  • Models stream lifecycle, not just one-off events
  • Requires System.Reactive NuGet package
  • Best for: data streams, reactive pipelines, CEP
Quick rule: Observer = in-process, subject manages list. Mediator = central hub, nobody knows who's listening. Pub/Sub = cross-service, broker handles everything. Events = simple fire-and-forget. Rx = composable streams over time.
Section 11

SOLID Mapping

PrincipleRelationExplanation
SRPSingle Responsibility Principle — A class should have only one reason to change. Each class should do one thing and do it well. Showcases Subject has one job: manage state and notify observers. Each Observer has one job: react in its specific way. StockTicker doesn't send emails; EmailNotifier doesn't track prices.
OCPOpen/Closed Principle — Software entities should be open for extension but closed for modification. You should be able to add new behavior without changing existing code. Core Principle Add SlackNotifier years later? Implement the interface, subscribe. Zero changes to the Subject — open for extension, closed for modification.
LSPLiskov Substitution Principle — Subtypes must be substitutable for their base types. If you swap a class for its subclass, the program should still work correctly. Showcases Any IObserver<T> substitutes for any other. EmailNotifier, SlackNotifier, AuditLogger are all just IObserver. Subject calls OnNext() identically on all.
ISPInterface Segregation Principle — No client should be forced to depend on methods it does not use. Prefer many small interfaces over one large one. Showcases IObserver<T> is minimal — three methods. For even finer granularity, split into per-event interfaces (IOrderPlacedObserver, IOrderShippedObserver) so no observer implements unused methods.
DIPDependency Inversion Principle — High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces). Core Principle Subject depends on IObserver<T> abstraction, never on concrete classes. Neither Subject nor Observer knows the other as a concrete type. This is the structural point of Observer.
Section 12

Bug Case Studies

The Incident

WPF dashboard app. The team built a stock-price dashboard. Every time a user opened a new tab, the app created a fresh DashboardViewModel. That viewmodel immediately subscribed to a long-lived StockService singleton so it could show live prices.

Everything looked fine during development. One window, one tab, no problems. But in production, traders would open and close dozens of tabs throughout the day. After about four hours of use, the app started lagging. After six hours, it crashed with an OutOfMemoryException.

The confusing part: there was no obvious memory hog. No giant arrays, no image caches, no database result sets sitting in memory. The business logic looked clean. The leak was invisible in normal code review because the problem wasn't what the code did — it was what the code didn't do.

When someone finally ran a memory profiler, it told a clear story: hundreds of DashboardViewModel objects were alive in memory, all held by one thing — the StockService's event delegate list. Every closed tab left behind a zombie viewmodel that could never be garbage-collected, because the service still held a reference to it through the event subscription.

Time to Diagnose

4 hours — memory profiler showed GC roots leading back to the event delegate list. No obvious leak in business logic.

StockService (Singleton — lives forever) PriceChanged event (delegate list inside) DashboardVM #1 (closed) Should be GC'd but CAN'T DashboardVM #2 (closed) Should be GC'd but CAN'T DashboardVM #N (closed) Memory grows until OOM owns holds ref
BuggyViewModel.cs
public class DashboardViewModel
{
    public DashboardViewModel(StockService service)
    {
        // ❌ Subscribes but NEVER unsubscribes
        service.PriceChanged += OnPriceChanged;
        // ❌ StockService holds a reference to 'this'
        // ❌ 'this' can NEVER be GC'd while service is alive
    }

    private void OnPriceChanged(object? s, decimal price) =>
        CurrentPrice = price;

    public decimal CurrentPrice { get; private set; }
    // ❌ No Dispose, no -= handler: memory leak
}

Walking through the buggy code: The constructor subscribes to PriceChanged with +=. This tells C# to store a reference to this viewmodel inside the service's delegate list. That's the trap. The service is a singleton — it lives for the entire application lifetime. So the delegate list lives forever too. And every viewmodel that ever subscribed is stuck in that list, even after the user closed the tab. There's no Dispose(), no -=, no cleanup at all. The viewmodel just hopes the garbage collector will clean it up — but the GC can't, because the service is still pointing at it.

FixedViewModel.cs
public sealed class DashboardViewModel : IDisposable
{
    private readonly StockService _service;
    private bool _disposed;

    public DashboardViewModel(StockService service)
    {
        _service = service;
        _service.PriceChanged += OnPriceChanged; // ✅ subscribe
    }

    private void OnPriceChanged(object? s, decimal price) =>
        CurrentPrice = price;

    public decimal CurrentPrice { get; private set; }

    public void Dispose()
    {
        if (!_disposed)
        {
            _service.PriceChanged -= OnPriceChanged; // ✅ unsubscribe
            _disposed = true;
        }
    }
}
// ✅ In WPF: call Dispose() in Window.Closed handler

Why the fix works: The viewmodel now implements IDisposable, which gives it a cleanup hook. When the user closes the tab, the WPF window calls Dispose(), which runs -= OnPriceChanged. That removes the viewmodel from the service's delegate list. Now nothing references the viewmodel, so the garbage collector can reclaim its memory. The _disposed flag prevents double-unsubscription — calling Dispose twice is safe and does nothing the second time.

Lesson Learned

Every += needs a matching -=. The event handler keeps the subscriber aliveWhen you do service.PriceChanged += viewModel.OnPriceChanged, the delegate stores a reference to viewModel. Service is alive, delegate is alive, viewModel is alive — even if all other references to it are gone. The GC can't collect it. This is the classic Observer memory leak. Fix: implement IDisposable and unsubscribe on Dispose. as long as the Subject exists. Implement IDisposable on all subscribers.

How to Spot This in Your Code

Search your codebase for += on any event. For each one, ask: "Where is the matching -=?" If you can't find one, you likely have a memory leak. Pay extra attention to short-lived objects (ViewModels, dialog windows, page components) subscribing to long-lived services (singletons, static events, application-lifetime objects). A memory profiler like dotMemory or Visual Studio's Diagnostic Tools will show you "retained objects" held by delegate chains — that's your smoking gun.

The Incident

Temperature converter service. A team built a unit-conversion tool that synchronized Celsius and Fahrenheit displays in real time. They had two "sensors" (subjects) — one for Celsius, one for Fahrenheit — and a converter object that observed both.

Here's where it went wrong: when the Celsius sensor changed, the converter updated the Fahrenheit sensor to match. But the Fahrenheit sensor then notified all its observers — including the same converter. The converter saw a Fahrenheit change and tried to update the Celsius sensor. Which notified the converter. Which updated Fahrenheit. Which notified the converter. And so on, forever.

The app crashed with a StackOverflowException within milliseconds. The stack trace was hundreds of frames long, all alternating between the same two methods. The tricky part was that each individual piece of code looked perfectly reasonable in isolation — the circular dependency only became visible when you traced the notification chain across multiple files.

This is one of the nastiest Observer bugs because it's easy to create accidentally whenever observers modify subjects. Two objects that seem unrelated can form a hidden loop through a chain of three or four intermediate observers.

Time to Diagnose

2 hours — stack trace showed a loop without a clear entry point. The circular dependency was spread across multiple files.

Celsius Sensor SetValue(25) TempConverter OnNext() Fahrenheit Sensor SetValue(77) 1. notify 2. update 3. F notifies converter, which updates C, which notifies converter... StackOverflowException Crashes in milliseconds
BuggyLoop.cs
public class TempConverter : IObserver<double>
{
    private readonly TemperatureSensor _celsius;
    private readonly TemperatureSensor _fahrenheit;

    public TempConverter(TemperatureSensor c, TemperatureSensor f)
    {
        _celsius = c; _fahrenheit = f;
        c.Subscribe(this);  // ❌ C notifies this
        f.Subscribe(this);  // ❌ which updates F, which notifies this...
    }

    public void OnNext(double value)
    {
        _fahrenheit.SetValue(value * 9 / 5 + 32); // ❌ triggers F notification
        _celsius.SetValue((_fahrenheit.Value - 32) * 5 / 9); // ❌ triggers C
        // ❌ Stack overflow in 2 calls
    }
}

Walking through the buggy code: The converter subscribes to both sensors. When Celsius changes, OnNext fires. Inside OnNext, it calls _fahrenheit.SetValue() — which triggers Fahrenheit's notification loop, which calls OnNext on this same converter again, which calls _celsius.SetValue(), which triggers Celsius's notification loop, which calls OnNext again... and the stack grows deeper with every round-trip until the runtime kills the process.

FixedLoop.cs
public class TempConverter : IObserver<double>
{
    private bool _updating;  // ✅ re-entrancy guard
    private readonly TemperatureSensor _fahrenheit;

    public void OnNext(double celsius)
    {
        if (_updating) return;  // ✅ break the cycle
        _updating = true;
        try
        {
            _fahrenheit.SetValue(celsius * 9 / 5 + 32);
            // ✅ F updates, notifies this observer,
            // but _updating is true -- returns immediately
        }
        finally
        {
            _updating = false;  // ✅ always reset
        }
    }
}

Why the fix works: The _updating boolean acts like a "Do Not Disturb" sign. When OnNext starts processing, it sets the flag to true. If the converter is called again (because the Fahrenheit sensor notified it back), it sees the flag and immediately returns — breaking the cycle. The finally block ensures the flag is always reset, even if an exception occurs, so the converter is ready for the next legitimate notification.

Lesson Learned

If an observer modifies any Subject, you risk circular notification. Use a re-entrancy guardA boolean flag that prevents an observer from being re-invoked while already processing a notification. Set to true before state changes, reset to false in a finally block. For thread-safe re-entrancy, use Interlocked.CompareExchange or ThreadLocal<bool> instead of a plain bool. flag or redesign the observation topology to eliminate cycles entirely.

How to Spot This in Your Code

Draw your observer graph on paper: boxes for subjects, arrows for subscriptions. If you can trace a loop from any box back to itself, you have a cycle. The warning signs in code: an observer's OnNext or Update method calls SetValue(), RaiseEvent(), or any method that triggers another notification. If you see this pattern, add a re-entrancy guard immediately — or better yet, redesign so observers are read-only consumers that never modify subjects.

The Incident

Real-time stock ticker. The system had a simple setup: a background timer fired every 100 milliseconds, fetched the latest stock price, and notified all subscribed observers. On the UI side, users could close chart panels, which unsubscribed the corresponding observer. Both things happened constantly throughout the trading day.

For weeks, everything worked fine. Then one morning, the app started crashing in production with InvalidOperationException: Collection was modified. The crash was completely random — sometimes after 5 minutes, sometimes after 2 hours, sometimes not at all. QA couldn't reproduce it.

The root cause was a classic race condition. The background timer thread was looping through the observer list with foreach. At the exact same moment, the UI thread was removing an observer from that same list because a user closed a panel. Two threads touching the same List<T> at the same time — one reading, one modifying — and the foreach iterator detected the structural change and threw.

What made this so hard to catch is that it only happened when the timer thread was mid-loop at the exact millisecond the UI thread removed an observer. Under light usage, that collision was rare. Under heavy production load with dozens of panels opening and closing, it became inevitable.

Time to Diagnose

6 hours — impossible to reproduce locally because it required exact timing between two threads. Only appeared under production load.

Timer Thread foreach (obs in _observers) UI Thread _observers.Remove(obs) List<IObserver> SHARED — No lock! reading modifying 💥 CRASH Collection was modified
BuggyThreadSafety.cs
public class StockTicker
{
    // ❌ Not thread-safe -- plain List
    private readonly List<IObserver<decimal>> _observers = new();

    // Called from Timer thread (background)
    public void NotifyAll(decimal price)
    {
        // ❌ Another thread calls Unsubscribe() here: BOOM
        // ❌ InvalidOperationException: Collection was modified
        foreach (var obs in _observers)
            obs.OnNext(price);
    }

    // Called from UI thread
    public void Unsubscribe(IObserver<decimal> obs) =>
        _observers.Remove(obs);  // ❌ concurrent modification!
}

Walking through the buggy code: The NotifyAll method runs on a background timer thread and starts iterating through _observers with foreach. Meanwhile, the UI thread calls Unsubscribe which calls _observers.Remove() on the very same list. C#'s List<T> is not designed for concurrent access — when the foreach iterator detects that the list structure changed mid-loop, it throws immediately. There's no lock, no snapshot, no protection at all.

FixedThreadSafety.cs
public class StockTicker
{
    private readonly object _lock = new();
    private readonly List<IObserver<decimal>> _observers = new();

    public void NotifyAll(decimal price)
    {
        List<IObserver<decimal>> snapshot;
        // ✅ Lock only to take a snapshot -- release before notifications
        lock (_lock) { snapshot = _observers.ToList(); }

        // ✅ Iterate snapshot -- modifications to _observers are safe
        foreach (var obs in snapshot)
            obs.OnNext(price);
    }

    public void Unsubscribe(IObserver<decimal> obs)
    {
        lock (_lock) { _observers.Remove(obs); }  // ✅ thread-safe
    }
}

Why the fix works: The key insight is the "snapshot" approach. Before iterating, we briefly grab a lock and copy the entire observer list into a new list (.ToList()). Then we immediately release the lock and iterate the copy. If the UI thread removes an observer from the original list while we're iterating the copy, it doesn't matter — we're reading from two different lists now. The lock ensures the copy itself is atomic (no half-modified list), but we don't hold the lock during notification, which prevents deadlocks if an observer tries to subscribe or unsubscribe from within OnNext.

Lesson Learned

In multi-threaded code, always take a snapshot of the observer list_observers.ToList() creates an independent copy at that moment. Modifications to _observers after the snapshot don't affect the iteration. Lock only during snapshot creation, not during the notification loop. Never hold a lock while calling observer methods — they might call back into your Subject, causing a deadlock. before iterating. Lock only long enough to copy the list, then release immediately.

How to Spot This in Your Code

Look for any foreach loop over a collection where another method modifies the same collection — especially if those methods can run on different threads. Red flags: a NotifyAll method that iterates _observers combined with Subscribe/Unsubscribe methods that add/remove from the same list without a lock. Even in single-threaded code, an observer that unsubscribes itself during OnNext triggers the same crash. The snapshot pattern (.ToList() before foreach) fixes both cases.

The Incident

Trading platform. The trading system used a "pull" model for Observer notifications. When the stock market subject changed its price, it sent a simple "hey, something changed" signal to all observers — but without including the actual data. Each observer then called back to the subject to read the current price.

This worked perfectly in testing because prices changed slowly. But in live markets, prices can change hundreds of times per second. Here's what happened: the subject set the price to $185.50 and notified all observers. Observer A started processing. Before A could read the price, the subject received a new feed update and changed the price to $186.20. When A finally called _subject.CurrentPrice, it got $186.20 — not the $185.50 that triggered its notification.

The result was subtle but devastating: automated trading algorithms made decisions based on the wrong price. A "buy at $185.50" decision was actually executing at $186.20. Small discrepancies added up to thousands of dollars in losses before anyone noticed.

The debugging was especially painful because the logs showed the final correct state, not the state at notification time. Everything looked fine in logs because they captured "current price" which was always the latest value. Adding timestamps to every read finally revealed the timing gap.

Time to Diagnose

8 hours — the data looked correct in logs because the logs captured the final state, not the state at notification time. Required adding timestamps to every read.

Time Price = $185.50 Notify sent! Observer wakes "Something changed" Price = $186.20 Changed AGAIN! Observer reads Gets $186.20 Expected $185.50! Danger window: state can change here
BuggyStaleState.cs
// ❌ Pull model -- observer reads state AFTER notification
public class PriceTracker : IObserver
{
    private readonly StockMarket _subject;

    public void OnChanged()  // ❌ no data passed -- must pull
    {
        // ❌ By the time this executes, price may have changed AGAIN
        var price = _subject.CurrentPrice;  // ❌ stale!
        ProcessPrice(price);  // ❌ decision based on wrong data
    }
}

Walking through the buggy code: The OnChanged() method receives no data — it's just a "heads up" signal. So the observer has to reach back to the subject and ask "what's the current price?" using _subject.CurrentPrice. The problem is that between the moment the notification was sent and the moment the observer reads, the subject's price may have changed one, two, or ten times. The observer gets whatever value happens to be there now, not the value that triggered the notification. This is called a "time-of-check vs time-of-use" race condition.

FixedStaleState.cs
// ✅ Push model -- Subject sends data WITH the notification
public class StockMarket
{
    public void SetPrice(decimal price)
    {
        _price = price;
        NotifyAll(price);  // ✅ capture value NOW and push it
    }

    private void NotifyAll(decimal price)
    {
        List<IObserver> snapshot;
        lock (_lock) { snapshot = _observers.ToList(); }
        foreach (var obs in snapshot)
            obs.OnNext(price);  // ✅ exact value that triggered notification
    }
}

public class PriceTracker : IObserver
{
    public void OnNext(decimal price) => ProcessPrice(price);  // ✅ no stale reads
}

Why the fix works: Instead of saying "something changed, go check," the subject now says "the price is $185.50" by passing the value directly in the notification. The observer receives the exact price that triggered the event — even if the subject has since moved to $186.20 or $190.00. The data is captured at the moment of change and delivered as a package. No callback, no race condition, no stale reads.

Lesson Learned

Prefer the push modelSubject packages all relevant data into the notification and sends it directly in OnNext(T value). Observers never need to call back on the Subject. Eliminates stale-state bugs because the observer always has the exact data that triggered the notification — not whatever state the Subject happens to be in when the observer reads it.. Pass the changed data directly in the notification (OnNext(newPrice)). Pulling data back introduces a time-of-check vs time-of-use race condition.

How to Spot This in Your Code

Look for observer methods that receive no data (like OnChanged() with no parameters) and then call back to the subject to read state (like _subject.GetCurrentValue()). That "call back to read" pattern is the pull model, and it's risky whenever the subject can change faster than observers can react. The fix is almost always to include the relevant data in the notification itself, so observers never need to reach back.

The Incident

Order processing pipeline. The e-commerce system processed every new order through three observers: one updated inventory, one sent a confirmation email, and one wrote an audit log entry for compliance. All three subscribed to the OrderPlaced event.

One day, the email server went down. The email observer (Observer #2 in the subscription order) threw an HttpRequestException when it couldn't connect to the SMTP server. Because there was no exception handling in the notification loop, that exception flew up the call stack immediately. The foreach loop stopped dead. Observer #3 — the audit log — never ran.

Nobody noticed for three days. The email failures were visible (customers complained about missing confirmations), so the team fixed the SMTP server. But they didn't realize the audit log had also been skipping entries. It wasn't until a compliance audit that someone discovered 847 orders had no audit trail. Regulators were not amused.

The insidious part: the exception wasn't "swallowed" in the traditional sense. It did propagate — but it propagated from the email observer, so the error message was about SMTP, not about audit logging. The team investigated SMTP and declared it fixed. The real damage — silently skipped downstream observers — was completely invisible because there was no error about them.

Time to Diagnose

12 hours — the missing audit entries weren't noticed until a compliance check. No error logs because the exception was caught by the framework before reaching our logging.

OrderService NotifyAll() 1. Inventory Runs OK 2. Email THROWS! HttpRequestException Loop STOPS here 3. Audit Log NEVER RUNS 847 orders with no audit trail
BuggyException.cs
public void NotifyAll(decimal price)
{
    foreach (var obs in _observers.ToList())
    {
        // ❌ No exception handling -- if obs[1] throws,
        // ❌ obs[2], obs[3] are NEVER notified
        obs.OnNext(price);
    }
}

public class BrokenEmailSender : IObserver<decimal>
{
    public void OnNext(decimal price)
    {
        throw new HttpRequestException("SMTP unavailable");
        // ❌ All subsequent observers are silently skipped
    }
}

Walking through the buggy code: The NotifyAll loop calls each observer one by one. When it reaches BrokenEmailSender, the OnNext method throws an HttpRequestException. Because there's no try/catch inside the loop, the exception immediately exits the foreach. Any observers that come after the email sender in the list never get called. The loop doesn't resume, the remaining observers don't get their notification, and there's no record that they were skipped.

FixedException.cs
private readonly ILogger _logger;

public void NotifyAll(decimal price)
{
    List<Exception>? errors = null;

    foreach (var obs in _observers.ToList())
    {
        try
        {
            obs.OnNext(price);  // ✅ each observer isolated
        }
        catch (Exception ex)
        {
            // ✅ Log but continue -- next observer still notified
            _logger.LogError(ex,
                "Observer {Type} failed. Price: {Price}",
                obs.GetType().Name, price);

            (errors ??= new()).Add(ex);
        }
    }

    // ✅ Re-throw if caller needs to know about failures
    if (errors is { Count: > 0 })
        throw new AggregateException("One or more observers failed", errors);
}

Why the fix works: Each observer call is wrapped in its own try/catch. If the email observer throws, the exception is caught, logged with full context (which observer failed, what data was being sent), and the loop continues to the next observer. The audit log observer runs no matter what happened to the email observer. At the end, if any observers failed, all their exceptions are collected into an AggregateException and re-thrown — so the caller knows failures occurred. Every observer is treated as independent: one failure never kills another.

Lesson Learned

Observers are independent — one failure shouldn't cascade. Wrap each call in try/catch, log (don't swallow silently!), and continue. Collect all failures and throw AggregateExceptionSystem.AggregateException wraps multiple exceptions into one. Ideal for Observer notification loops where multiple observers might fail independently. It preserves all failure details while letting the notification loop complete. Callers inspect AggregateException.InnerExceptions to see exactly which observers failed and why. at the end if the caller needs to know.

How to Spot This in Your Code

Find every loop that calls observer methods (any foreach over a subscriber list that calls OnNext, Update, Handle, etc.). If the loop body has no try/catch, one failing observer will kill the rest. This is especially dangerous when observers do I/O (HTTP calls, database writes, file operations) because I/O can fail unpredictably. The rule is simple: every notification loop needs per-observer exception isolation.

Section 13

Pitfalls & Anti-Patterns

Mistake: Subscribing to an event with += but never calling -=. The publisher's delegate list holds a strong reference to the subscriber, preventing garbage collection even after the subscriber is "gone."

Why This Happens: It feels natural to subscribe in a constructor — "I need this data, let me sign up." But developers often forget the second half: cleaning up when done. In most code, you create an object and trust the garbage collector to clean it up. But event subscriptions are different — they create a hidden reference from the publisher to the subscriber. If the publisher lives longer than the subscriber (which is very common with singletons, static services, or application-level objects), the subscriber can never be garbage-collected. It's like signing up for a mailing list and never unsubscribing — the mail keeps coming, and your mailbox fills up forever.

Why Bad: Long-lived publishers (singletons, static services) keep every short-lived subscriber (ViewModels, dialogs) alive forever. Memory grows until OOM crash. Classic in WPF/MAUI apps.

BAD — never unsubscribes StockService lives forever Panel 1 (dead) Panel 2 (dead) Panel 3 (dead) 🚫 Can't GC! Strong refs keep dead objects alive Memory grows until OOM crash GOOD — Dispose() unsubscribes StockService Panel (alive) Dispose() calls -= Every += has a matching -= Dead panels get GC'd normally
BadUnsubscribe.cs — ❌
public class PricePanel
{
    public PricePanel(StockService service)
    {
        service.PriceChanged += OnPrice; // subscribes...
        // No Dispose, no cleanup, no -= anywhere
        // This panel can NEVER be garbage-collected
    }
    private void OnPrice(object? s, decimal p) => Update(p);
}
GoodUnsubscribe.cs — ✅
public class PricePanel : IDisposable
{
    private readonly StockService _service;
    public PricePanel(StockService service)
    {
        _service = service;
        _service.PriceChanged += OnPrice;
    }
    private void OnPrice(object? s, decimal p) => Update(p);
    public void Dispose() => _service.PriceChanged -= OnPrice;
    // Now the caller just calls panel.Dispose() when done
}

The bad version creates a permanent reference from the service to the panel. The good version implements IDisposable so the panel can cleanly unsubscribe when it's no longer needed. The key insight: every += must have a corresponding -= somewhere in the lifecycle.

Mistake: An observer's Update() calls Unsubscribe() while the subject is iterating its observer list. The foreach throws InvalidOperationException: Collection was modified.

Why This Happens: It seems perfectly logical — an observer receives a notification and decides "I'm done, I don't need more updates." So it calls Unsubscribe() right there inside the handler. The problem is timing: the subject is currently looping through its observer list to send notifications, and removing an item from the list while the loop is running breaks the iteration. Developers don't think about this because the subscription and notification code are usually in different files, so the conflict isn't visible.

Why Bad: Intermittent crash that only happens when an observer decides to self-unsubscribe during a notification. Hard to reproduce in testing.

BAD — iterating live list [ A, B, C ] foreach → B calls Unsubscribe() removes itself mid-loop Collection modified! CRASH GOOD — iterate a snapshot _observers (live) .ToList() snapshot copy foreach iterates the copy B unsubscribes from original Copy is unaffected — no crash
BadIteration.cs — ❌
// Subject notification loop
foreach (var obs in _observers)     // iterating the LIVE list
    obs.OnNext(value);              // observer might call Unsubscribe()!

// Observer handler
public void OnNext(decimal price)
{
    if (price > _threshold)
        _subject.Unsubscribe(this); // BOOM: modifies list during foreach
}
GoodIteration.cs — ✅
// Subject notification loop — iterate a SNAPSHOT
foreach (var obs in _observers.ToList())  // .ToList() = independent copy
    obs.OnNext(value);                    // safe: modifying _observers
                                          // doesn't affect the copy

The fix is just six characters: .ToList(). This creates a snapshot — a separate copy of the list at that moment. The original list can be modified freely while we iterate the copy. It costs a small allocation, but it eliminates an entire class of crashes.

Mistake: Subject fires events from a background thread (timer, SignalR). Observer tries to update a UI element directly.

Why This Happens: When you write an observer handler, you're thinking about what to do with the data, not which thread it arrives on. The handler says "update the label text" which seems harmless. But UI frameworks (WPF, MAUI, WinForms) require that all UI updates happen on a specific "main" thread. If your subject fires from a background timer or a SignalR callback, the handler runs on that background thread — and trying to touch a UI element from the wrong thread causes an immediate crash.

Why Bad: WPF/MAUI throw InvalidOperationException: "The calling thread cannot access this object because a different thread owns it." Crashes your app immediately.

BAD — background thread hits UI BG Thread Label.Text = "Different thread owns this object" InvalidOperationException! GOOD — marshal to UI thread BG Thread Dispatcher BeginInvoke Label.Text = UI thread
BadThread.cs — ❌
// Observer handler — runs on whatever thread the subject fires from
public void OnPriceChanged(object? s, decimal price)
{
    // If subject fires from a background thread:
    PriceLabel.Text = $"${price}"; // CRASH: wrong thread!
}
GoodThread.cs — ✅
// Option 1: Marshal to UI thread manually
public void OnPriceChanged(object? s, decimal price)
{
    Dispatcher.BeginInvoke(() =>
        PriceLabel.Text = $"${price}");  // runs on UI thread
}

// Option 2: Let Rx handle it transparently
priceStream
    .ObserveOn(SynchronizationContext.Current!) // switch to UI thread
    .Subscribe(price => PriceLabel.Text = $"${price}");

The bad version assumes the handler runs on the UI thread. The good version explicitly marshals to the UI thread using Dispatcher.BeginInvoke() (WPF) or lets Rx handle it with ObserveOn. The Rx approach is cleaner because the thread switch is declared once in the pipeline, not repeated in every handler.

Mistake: One observer makes a database call or HTTP request inside OnNext(). The synchronous notification loop blocks every observer after it.

Why This Happens: Developers think of observer handlers like any other method — "I need to save this data, so I'll write to the database right here." What they forget is that the notification loop is synchronous. The subject calls observer 1, waits for it to finish, then calls observer 2, and so on. If observer 1 makes a 2-second database call, every other observer sits idle for those 2 seconds. In a UI app, the entire screen freezes. In a server app, the thread is blocked for no good reason.

Why Bad: Your 10ms event becomes a 2-second freeze. All downstream observers are delayed. In UI apps, the screen locks up.

BAD — heavy work inline Notify Observer 1 HTTP + DB = 2.5s Obs 2 Obs 3 waiting... 2.5 seconds blocked Everyone waits for the slow observer GOOD — offload to queue Notify Observer 1 queue.Write() Obs 2 Obs 3 microseconds — all observers done instantly Heavy work happens in background worker No one blocks anyone
BadHeavyWork.cs — ❌
public void OnNext(OrderPlacedEvent evt)
{
    // Blocks the entire notification chain for 2+ seconds
    _httpClient.PostAsync(webhookUrl, content).Result; // .Result = BLOCK
    _database.SaveAuditLog(evt);                       // another 500ms
    // Every observer after this one waits 2.5 seconds
}
GoodHeavyWork.cs — ✅
private readonly Channel<OrderPlacedEvent> _queue =
    Channel.CreateBounded<OrderPlacedEvent>(1000);

public void OnNext(OrderPlacedEvent evt)
{
    _queue.Writer.TryWrite(evt); // instant — just drops it in a queue
    // Notification loop continues immediately to the next observer
}

// Background worker processes the queue at its own pace
private async Task ProcessAsync(CancellationToken ct)
{
    await foreach (var evt in _queue.Reader.ReadAllAsync(ct))
    {
        await _httpClient.PostAsync(webhookUrl, content);
        await _database.SaveAuditLogAsync(evt);
    }
}

The bad version does heavy I/O work inline, blocking the entire notification chain. The good version drops the event into a Channel<T> (which is nearly instant) and processes it asynchronously in a background worker. The notification loop finishes in microseconds instead of seconds, and other observers aren't held hostage by slow I/O.

Mistake: Observer A modifies Subject B, which notifies Observer C, which modifies Subject A again. Infinite loop or stack overflow.

Why This Happens: Each observer handler looks innocent on its own. Observer A says "when X changes, update Y." Observer C says "when Y changes, update X." Neither developer knows about the other's handler because they're in different files, possibly written by different team members. The circular dependency is invisible until runtime, when it manifests as a StackOverflowException or an infinite hang.

Why Bad: Nightmarish to debug — the stack trace shows a loop with no clear entry point. The circular dependency is often spread across multiple files.

BAD — infinite ping-pong Celsius Fahrenheit updates updates back StackOverflowException GOOD — re-entrancy guard Celsius Fahrenheit updates tries to update _updating flag = true? Return early!
BadCircular.cs — ❌
// File A: when celsius changes, update fahrenheit
celsius.Changed += (s, c) =>
    fahrenheit.Value = c * 9.0 / 5 + 32;  // triggers fahrenheit.Changed

// File B: when fahrenheit changes, update celsius
fahrenheit.Changed += (s, f) =>
    celsius.Value = (f - 32) * 5.0 / 9;   // triggers celsius.Changed
// Result: infinite loop → StackOverflowException
GoodCircular.cs — ✅
private bool _updating;

celsius.Changed += (s, c) =>
{
    if (_updating) return;   // already inside an update — stop the cycle
    _updating = true;
    try { fahrenheit.Value = c * 9.0 / 5 + 32; }
    finally { _updating = false; }
};

The bad version creates a ping-pong loop between two event handlers. The good version uses a re-entrancy guard: a simple boolean that says "I'm already processing an update, don't start another one." When the Fahrenheit handler tries to call back into the Celsius handler, the flag is already set, so it returns immediately — breaking the loop.

Mistake: Reaching for events/Observer when there's exactly one listener. Adding subscribe/unsubscribe ceremony for a single consumer.

Why This Happens: Developers learn about Observer and events, and then start using them everywhere because it feels "clean" or "decoupled." But Observer is designed for one-to-many broadcasting. When there's exactly one consumer, an event is ceremony without benefit. You're adding subscription management, potential memory leak surface area, and harder-to-trace data flow — all for a scenario where a simple callback delegate would do the job perfectly.

Why Bad: Premature Observer adds complexity: potential memory leaks, harder to trace data flow, subscribe/unsubscribe boilerplate. All for zero benefit when there's only one consumer.

BAD — event for 1 listener FileProcessor event ProgressChanged += 1 listener subscribe + track + unsubscribe ...all for ONE consumer GOOD — simple callback FileProcessor Process(onProgress) call pct => bar.Value No subscribe, no unsubscribe No leak risk, no ceremony
BadOveruse.cs — ❌
public class FileProcessor
{
    public event EventHandler<int>? ProgressChanged; // event for ONE consumer?
    public void Process()
    {
        for (int i = 0; i <= 100; i += 10)
            ProgressChanged?.Invoke(this, i);
    }
}
// Caller must subscribe, track, unsubscribe... for ONE listener
GoodSimpleCallback.cs — ✅
public class FileProcessor
{
    public void Process(Action<int>? onProgress = null)
    {
        for (int i = 0; i <= 100; i += 10)
            onProgress?.Invoke(i);  // simple callback — no subscription needed
    }
}
// Caller: processor.Process(pct => progressBar.Value = pct);

The bad version uses a full event for something that only has one listener. The good version accepts a callback delegate — no subscription ceremony, no unsubscribe to forget, no memory leak possible. Use Observer when you need multiple, dynamic subscribers. Use callbacks when you know there's exactly one consumer.

Mistake: Checking if (EventHandler != null) then invoking on the next line. Between those two lines, another thread unsubscribes.

Why This Happens: It looks safe: "I checked if the event has subscribers, and it does, so I'll invoke it." The problem is that between the null check and the invoke, another thread can unsubscribe — setting the delegate back to null. By the time .Invoke() runs, the handler is null, and you get a NullReferenceException. This is a race condition, and it's devilishly hard to reproduce because it requires exact timing between two threads.

Why Bad: NullReferenceException or missed events. Classic race condition that's impossible to reproduce reliably in tests.

BAD — gap between check and invoke Thread A: if (evt != null) .Invoke() BOOM! Thread B: evt -= handler Thread B sneaks in between! evt is null when Invoke runs NullReferenceException! GOOD — atomic ?.Invoke() Thread A: evt?.Invoke(this, price) Thread B: can't interfere — it's one operation ?. reads + null-checks + invokes atomically — no gap to exploit
BadNullCheck.cs — ❌
public event EventHandler<decimal>? PriceChanged;

private void OnPriceChanged(decimal price)
{
    if (PriceChanged != null)        // Thread B unsubscribes HERE
        PriceChanged.Invoke(this, price); // NullReferenceException!
}
GoodNullSafe.cs — ✅
public event EventHandler<decimal>? PriceChanged;

private void OnPriceChanged(decimal price)
{
    PriceChanged?.Invoke(this, price); // ?.Invoke is atomic — thread-safe
    // OR: var handler = PriceChanged; handler?.Invoke(this, price);
}

The bad version has a gap between the null check and the invoke where another thread can sneak in. The good version uses the null-conditional operator ?. which is atomic — it reads the delegate, checks for null, and invokes in a single operation that can't be interrupted. Alternatively, copy the delegate to a local variable first: var handler = PriceChanged; — the local copy is safe even if the original changes.

Mistake: Making the observer list public: public List<IObserver> Observers { get; }. Anyone can bypass your subscribe/unsubscribe logic.

Why This Happens: A developer thinks "I'll expose the observer list so callers can add themselves directly — it's simpler than writing Subscribe/Unsubscribe methods." The problem is that exposing the raw list lets anyone do anything: clear all subscribers, add duplicates, remove someone else's subscription, or iterate in ways that aren't thread-safe. You lose all control over the subscription lifecycle.

Why Bad: Callers can .Clear() the list, add duplicates, or remove others' subscriptions. Bypasses validation, locking, and logging. Subtle bugs that are nearly impossible to trace.

BAD — public list exposed Broadcaster public List<IObserver> wide open! Anyone can: .Clear() .Add(dup) .Remove(other) No control over who does what GOOD — private list, public API Broadcaster private List<IObserver> locked down Subscribe() Dispose() Only 2 operations — full control
BadExposed.cs — ❌
public class EventBroadcaster
{
    // Anyone can: .Clear(), .Add(duplicate), .Remove(someone else's)
    public List<IObserver<string>> Observers { get; } = new();

    public void Notify(string msg)
    {
        foreach (var obs in Observers) // no snapshot — crash risk too
            obs.OnNext(msg);
    }
}
GoodEncapsulated.cs — ✅
public class EventBroadcaster
{
    private readonly List<IObserver<string>> _observers = new();

    public IDisposable Subscribe(IObserver<string> observer)
    {
        _observers.Add(observer);
        return new Subscription(() => _observers.Remove(observer));
    }
    // List is PRIVATE — callers can only Subscribe/Dispose
    // You control validation, locking, logging, duplicate prevention
}

The bad version hands out the raw list, giving callers unrestricted access. The good version keeps the list private and exposes only a Subscribe method that returns an IDisposable token. Callers can subscribe and dispose — nothing else. You maintain full control over validation, thread safety, and lifecycle management.

Section 14

Testing Strategies

Good news: Observer code is straightforward to test. The goal is simple — make sure the right listeners get called with the right data, and that unsubscribing actually works. Here are four approaches that cover every testing scenario you'll run into.

Strategy 1: Mock Observers (Moq)

Use MoqMoq is the most popular mocking library for .NET. It creates dynamic proxy implementations of interfaces at test time. Mock<IObserver>() generates a fake implementation where you can setup return values (Setup/Returns) and verify calls happened (Verify/VerifyAll). Install: dotnet add package Moq to create a mock observer, subscribe it, trigger the subject, then verify the mock was called with the correct arguments.

MockObserverTests.cs
using Moq;
using Xunit;

public class StockPriceSubjectTests
{
    [Fact]
    public void PriceChange_NotifiesAllObservers_WithCorrectPrice()
    {
        // Arrange
        var subject = new StockPriceSubject("AAPL");
        var mockObserver1 = new Mock<IStockObserver>();
        var mockObserver2 = new Mock<IStockObserver>();

        subject.Subscribe(mockObserver1.Object);
        subject.Subscribe(mockObserver2.Object);

        // Act
        subject.UpdatePrice(150.75m);

        // Assert — both observers called with correct price
        mockObserver1.Verify(o => o.OnPriceChanged("AAPL", 150.75m), Times.Once);
        mockObserver2.Verify(o => o.OnPriceChanged("AAPL", 150.75m), Times.Once);
    }

    [Fact]
    public void PriceUnchanged_DoesNotNotifyObservers()
    {
        var subject = new StockPriceSubject("AAPL");
        subject.UpdatePrice(150.00m); // initial price

        var mockObserver = new Mock<IStockObserver>();
        subject.Subscribe(mockObserver.Object);

        subject.UpdatePrice(150.00m); // same price — no change

        mockObserver.Verify(o => o.OnPriceChanged(It.IsAny<string>(), It.IsAny<decimal>()),
            Times.Never);
    }

    [Fact]
    public void MultipleUpdates_NotifiesInOrder()
    {
        var subject = new StockPriceSubject("MSFT");
        var callOrder = new List<decimal>();
        var mockObserver = new Mock<IStockObserver>();

        mockObserver.Setup(o => o.OnPriceChanged(It.IsAny<string>(), It.IsAny<decimal>()))
            .Callback<string, decimal>((_, price) => callOrder.Add(price));

        subject.Subscribe(mockObserver.Object);
        subject.UpdatePrice(100m);
        subject.UpdatePrice(105m);
        subject.UpdatePrice(103m);

        Assert.Equal(new[] { 100m, 105m, 103m }, callOrder);
    }
}

A FakeObserverA test double that records all calls made to it. Unlike mocks (which are configured with expectations), fakes have real implementations that capture data for later assertion. Fakes are simpler to write than mocks for complex scenarios — especially when you need to inspect what was passed, not just whether it was called. (test double) is a simple hand-rolled class that records calls. Great when Mock<T> setup gets complex or you want readable assertion code.

FakeObserverTests.cs
// The fake — a real implementation that records calls
public class FakeStockObserver : IStockObserver
{
    public List<(string Ticker, decimal Price)> ReceivedUpdates { get; } = new();
    public int CallCount => ReceivedUpdates.Count;
    public (string Ticker, decimal Price) LastUpdate => ReceivedUpdates.Last();

    public void OnPriceChanged(string ticker, decimal price)
        => ReceivedUpdates.Add((ticker, price));
}

// Tests using the fake — clean, readable assertions
public class StockPriceSubjectFakeTests
{
    [Fact]
    public void Subscribe_ThenUpdate_ObserverReceivesAllUpdates()
    {
        var subject = new StockPriceSubject("TSLA");
        var fake = new FakeStockObserver();

        subject.Subscribe(fake);
        subject.UpdatePrice(900m);
        subject.UpdatePrice(950m);

        Assert.Equal(2, fake.CallCount);
        Assert.Equal(("TSLA", 950m), fake.LastUpdate);
    }

    [Fact]
    public void MultipleObservers_EachReceiveIndependentCopies()
    {
        var subject = new StockPriceSubject("AMZN");
        var fake1 = new FakeStockObserver();
        var fake2 = new FakeStockObserver();

        subject.Subscribe(fake1);
        subject.Subscribe(fake2);
        subject.UpdatePrice(3400m);

        // Each gets its own call — they don't share state
        Assert.Single(fake1.ReceivedUpdates);
        Assert.Single(fake2.ReceivedUpdates);
        Assert.Equal(fake1.LastUpdate, fake2.LastUpdate);
    }

    [Fact]
    public void LateSubscriber_DoesNotReceivePreviousEvents()
    {
        var subject = new StockPriceSubject("GOOGL");
        var earlyFake = new FakeStockObserver();
        var lateFake = new FakeStockObserver();

        subject.Subscribe(earlyFake);
        subject.UpdatePrice(2800m); // lateFake not yet subscribed

        subject.Subscribe(lateFake);
        subject.UpdatePrice(2850m); // both should receive this

        Assert.Equal(2, earlyFake.CallCount);
        Assert.Equal(1, lateFake.CallCount); // only got the second update
    }
}

Testing async observers requires synchronization — you need to wait for the async handler to complete before asserting. TaskCompletionSource<T>A manual "promise" in .NET. You create a TaskCompletionSource, hand out its Task to the test (which awaits it), and then call SetResult() from within the async observer handler to signal completion. This lets your test await the observer completing without polling or arbitrary delays. is the cleanest solution.

AsyncObserverTests.cs
public class AsyncObserverTests
{
    [Fact]
    public async Task AsyncObserver_CompletesHandlerBeforeTimeout()
    {
        var subject = new AsyncEventSubject();
        var tcs = new TaskCompletionSource<string>();

        // Subscribe an async observer that signals completion
        await subject.SubscribeAsync(async (payload) =>
        {
            await Task.Delay(10); // simulate async work
            tcs.SetResult(payload.Data);
        });

        // Act
        await subject.PublishAsync(new EventPayload("order-completed"));

        // Wait for async observer — with timeout for safety
        var result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));

        Assert.Equal("order-completed", result);
    }

    [Fact]
    public async Task MultipleAsyncObservers_AllCompleteInParallel()
    {
        var subject = new AsyncEventSubject();
        var results = new ConcurrentBag<string>();
        var tcs = new TaskCompletionSource();
        int expected = 3;

        for (int i = 0; i < expected; i++)
        {
            var id = i.ToString();
            await subject.SubscribeAsync(async (payload) =>
            {
                await Task.Delay(50); // all 3 run in parallel
                results.Add(id);
                if (results.Count == expected) tcs.SetResult();
            });
        }

        await subject.PublishAsync(new EventPayload("test"));
        await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));

        Assert.Equal(expected, results.Count);
    }

    [Fact]
    public async Task AsyncObserver_ExceptionInOne_DoesNotAffectOthers()
    {
        var subject = new ResilientAsyncSubject();
        var goodObserverCalled = false;

        await subject.SubscribeAsync(_ => throw new InvalidOperationException("oops"));
        await subject.SubscribeAsync(_ => { goodObserverCalled = true; return Task.CompletedTask; });

        // Should not throw — subject catches and logs individual failures
        await subject.PublishAsync(new EventPayload("test"));

        Assert.True(goodObserverCalled);
    }
}

Verifying that unsubscribe works is critical for proving your memory leak fix actually works. Test that after disposing the subscription, no further callbacks arrive.

UnsubscribeTests.cs
public class UnsubscribeTests
{
    [Fact]
    public void Unsubscribe_StopsAllFurtherNotifications()
    {
        var subject = new StockPriceSubject("NVDA");
        var fake = new FakeStockObserver();

        subject.Subscribe(fake);
        subject.UpdatePrice(500m); // received

        subject.Unsubscribe(fake);
        subject.UpdatePrice(550m); // should NOT be received
        subject.UpdatePrice(600m); // should NOT be received

        Assert.Equal(1, fake.CallCount);
        Assert.Equal(500m, fake.LastUpdate.Price);
    }

    [Fact]
    public void IDisposable_Unsubscribes_OnDispose()
    {
        var subject = new StockPriceSubject("META");
        var fake = new FakeStockObserver();

        using (var subscription = subject.Subscribe(fake))
        {
            subject.UpdatePrice(300m); // received inside using
        }
        // subscription.Dispose() called automatically

        subject.UpdatePrice(350m); // should NOT be received
        Assert.Equal(1, fake.CallCount);
    }

    [Fact]
    public void DoubleUnsubscribe_DoesNotThrow()
    {
        var subject = new StockPriceSubject("AMD");
        var fake = new FakeStockObserver();

        subject.Subscribe(fake);
        subject.Unsubscribe(fake);

        // Second unsubscribe should be a no-op, not an exception
        var ex = Record.Exception(() => subject.Unsubscribe(fake));
        Assert.Null(ex);
    }

    [Fact]
    public void RemainingObservers_StillReceiveAfterOneUnsubscribes()
    {
        var subject = new StockPriceSubject("ORCL");
        var staying = new FakeStockObserver();
        var leaving = new FakeStockObserver();

        subject.Subscribe(staying);
        subject.Subscribe(leaving);
        subject.UpdatePrice(100m); // both receive

        subject.Unsubscribe(leaving);
        subject.UpdatePrice(110m); // only staying receives

        Assert.Equal(2, staying.CallCount);
        Assert.Equal(1, leaving.CallCount); // stopped after unsubscribe
    }
}
Section 15

Performance Considerations

For most apps, Observer is plenty fast — you won't need to think about performance at all. But if your system fires thousands of events per second (stock feeds, IoT sensors, game loops), the details start to matter. Here's what you need to know.

Notification Overhead

The notification overhead is dominated by iteration cost (O(n) subscribers) and virtual dispatch (interface call). For typical applications (1-100 observers), this is negligible — nanosecond range. Memory impact comes from the observer list itself and the delegates stored in event fields.

PerformanceBenchmark.cs
// BenchmarkDotNet results (rough order of magnitude)
// Environment: .NET 8, x64, Intel Core i7

// List<IObserver>.ForEach — 10 observers
// Notify() call: ~150ns total, ~15ns per observer

// event (MulticastDelegate) — 10 subscribers
// Event?.Invoke(): ~80ns total, ~8ns per subscriber

// ImmutableList — 10 observers (ToBuilder + loop)
// Notify(): ~300ns (ImmutableList allocation overhead)

// ─── Optimization: pool the notification loop ───
private static readonly ObjectPool<List<IObserver>> _snapshotPool =
    ObjectPool.Create<List<IObserver>>();

public void NotifyOptimized()
{
    var snapshot = _snapshotPool.Get();
    snapshot.AddRange(_observers);
    try
    {
        foreach (var obs in snapshot) obs.Update(State);
    }
    finally
    {
        snapshot.Clear();
        _snapshotPool.Return(snapshot);
    }
}
// Eliminates GC pressure from repeated ToList() allocations
ScenarioTime ComplexityMemoryGC Pressure
Notify N observers (sync)O(N)O(N) for listNone (if no snapshot)
SubscribeO(1) amortized (List)Ref + delegateLow
Unsubscribe (List)O(N) scanFreed after GCLow
Snapshot (ToList)O(N) copyN * pointer sizePer-notify allocation
ImmutableList swapO(log N) structuralO(log N) nodesPer-subscribe
event (MulticastDelegate)O(N)Delegate chainOn subscribe/unsubscribe

Three main mechanisms in .NET for Observer-style notifications — each with different trade-offs:

ObserverMechanisms.cs
// ─── 1. C# Events (MulticastDelegate) ───
// Pros: zero allocation on invoke, simple syntax, compiler-enforced encapsulation
// Cons: no backpressure, sync only (event keyword), hard to compose
public class EventSubject
{
    public event Action<int>? ValueChanged;
    protected void Notify(int value) => ValueChanged?.Invoke(value); // zero alloc
}

// ─── 2. IObservable<T> / Rx.NET ───
// Pros: composable operators (Throttle, Distinct, Buffer), LINQ-like, handles async
// Cons: learning curve, subscription management, Rx dependency, overhead per operator
public class RxSubject
{
    private readonly Subject<int> _subject = new();
    public IObservable<int> Values => _subject.AsObservable();
    public void Notify(int value) => _subject.OnNext(value);
}

// Usage: composable — filter + throttle + project
_rxSubject.Values
    .Where(v => v > 0)
    .Throttle(TimeSpan.FromMilliseconds(100))
    .DistinctUntilChanged()
    .Subscribe(v => Console.WriteLine(v));

// ─── 3. System.Threading.Channels ───
// Pros: true async, backpressure, multiple producers/consumers, high throughput
// Cons: queue-based (not instant), requires async consumers, slightly complex
public class ChannelSubject
{
    private readonly Channel<int> _channel = Channel.CreateUnbounded<int>();
    public ChannelReader<int> Reader => _channel.Reader;
    public void Notify(int value) => _channel.Writer.TryWrite(value);
}

// Consumer reads asynchronously — perfect for high-throughput
await foreach (var value in channelSubject.Reader.ReadAllAsync())
MechanismThroughputAsync?Backpressure?Best For
C# eventsHighestNo (sync)NoSimple UI, 1-10 observers
IObservable<T>System.IObservable<T> is a built-in .NET interface (mscorlib). Rx.NET (System.Reactive NuGet) provides the full implementation with 100+ operators. The core interface has just one method: Subscribe(IObserver<T>). IObserver<T> has OnNext, OnError, OnCompleted — a complete async sequence model.MediumYes (via schedulers)Yes (Throttle/Buffer)Complex event streams, LINQ over events
Channel<T>Very HighYes (native)Yes (bounded channel)High-throughput, producer-consumer
List<IObserver>HighConfigurableNoCustom Observer pattern, full control

Observer count has a linear impact on notification time. Here's what "scale" actually means in practice:

ScaleAnalysis.cs
// 10 observers: ~150ns notification — absolutely fine for any frequency
// 1,000 observers: ~15µs notification — fine for up to ~65K events/sec
// 100,000 observers: ~1.5ms notification — breaks real-time requirements

// ─── Solution for large-scale: segmented notification ───
public class ScalableSubject
{
    // Partition observers by interest/group
    private readonly Dictionary<string, List<IObserver>> _partitions = new();

    public void Subscribe(string topic, IObserver observer)
    {
        if (!_partitions.TryGetValue(topic, out var list))
            _partitions[topic] = list = new();
        list.Add(observer);
    }

    // Only notify interested observers — O(interested) not O(all)
    public void Notify(string topic, object state)
    {
        if (_partitions.TryGetValue(topic, out var observers))
            foreach (var o in observers) o.Update(state);
    }
}

// ─── For 100K+ observers: fan-out with parallelism ───
public async Task NotifyParallelAsync(object state)
{
    // Partition into chunks, process each chunk on thread pool
    var chunks = _observers.Chunk(1000);
    await Task.WhenAll(chunks.Select(chunk =>
        Task.Run(() => { foreach (var o in chunk) o.Update(state); })));
}

Weak referencesWeakReference<T> holds a reference that does NOT prevent garbage collection. If the target object is GC'd, TryGetTarget() returns false. Weak references "solve" the memory leak problem automatically — dead subscribers are collected. But they add overhead (null checks on every notify), make debugging harder, and require careful notification loop design. solve the memory leak without requiring explicit unsubscribe — but they're a trade-off, not a free lunch.

WeakObserver.cs
public class WeakObserverSubject
{
    private readonly List<WeakReference<IObserver>> _observers = new();

    public void Subscribe(IObserver observer)
        => _observers.Add(new WeakReference<IObserver>(observer));

    public void Notify(object state)
    {
        var dead = new List<WeakReference<IObserver>>();

        foreach (var weakRef in _observers)
        {
            if (weakRef.TryGetTarget(out var observer))
                observer.Update(state); // still alive — notify
            else
                dead.Add(weakRef); // collected — mark for cleanup
        }

        // Clean up dead references
        foreach (var d in dead) _observers.Remove(d);
    }
}

// Trade-offs:
// PRO: subscribers auto-GC'd when not referenced elsewhere
// CON: notification loop has null-check overhead (~2x slower)
// CON: can't guarantee when (or if) GC collects — notification timing is undefined
// CON: subscriber held alive by other refs? Still gets notified after "logically" gone
// VERDICT: Explicit IDisposable + -= is almost always the right choice
Section 16

How to Explain in an Interview

The 2-Minute Pitch

Opening: "Observer solves the notification problem — how do you tell multiple objects that something changed without the publisher needing to know who's listening? Think of a stock ticker: the price changes, and every screen, alert, and log entry needs to know. Without Observer, you'd hardcode screen.Update(); alertSystem.Check(); auditLog.Write() — tight coupling that breaks every time you add a new consumer."

Core: "The Subject (publisher) maintains a list of observers and calls Notify() when state changes. Observers implement a single-method interface — OnNext(T) or Update(). They subscribe at runtime and unsubscribe when done. The subject never knows the concrete observer types — only the interface."

Example: "In .NET, C# event keyword is syntactic sugar for Observer — the MulticastDelegateThe backing type for C# events. A linked list of method pointers (delegates). When you invoke an event, it calls each delegate in the chain sequentially. += adds to the chain, -= removes. IS the observer list. INotifyPropertyChanged in MVVM, IObservable<T> in Rx.NET, IProgress<T> for progress — all Observer. For production, I use C# events for simple cases and IObservable<T> when I need Throttle, Buffer, or DistinctUntilChanged."

Trade-offs: "Benefits are loose coupling and open/closed compliance. Risks are memory leaks from unsubscribed handlers (fix with IDisposable), unexpected notification order, and debugging difficulty — an event triggers 5 observers, one throws, which one? I mitigate with structured logging in a try-catch around each observer call."

When NOT: "Don't use when there's exactly one consumer — just inject a callback. Don't use for request-response flows needing a return value. Don't use when the publisher must know the consumer succeeded — events are fire-and-forget."

Close: "I think of Observer as the dependency inversionDIP says high-level modules should not depend on low-level modules — both should depend on abstractions. Observer implements this perfectly: the publisher depends on IObserver (abstraction), not on concrete subscribers. of notifications — the publisher depends on an abstraction, and the concrete subscribers plug in at runtime without the publisher ever importing them."

Section 17

Interview Q&As

29 questions that interviewers actually ask — starting from the basics and building up to advanced scenarios. Each one has a "Think First" prompt (try answering before peeking!) and a "Great Answer Bonus" to help you stand out.

Basic Foundations (Q1—Q8)

Think First What happens without Observer when a state change must notify 5 different objects?

Observer solves the "notification problem" — when something changes, how do you tell everyone who cares without hardcoding who those "someones" are? Think of it like a YouTube channel. When a creator posts a new video, they don't personally call every viewer. Instead, viewers subscribe to the channel, and YouTube automatically notifies everyone on the list. The creator doesn't know (or care) who's subscribed — they just publish, and the platform handles delivery.

Without Observer, the code that changes data would have to manually call screen.Update(), logger.Log(), emailer.Send() — and every time you add a new consumer, you'd edit that code again. That's like the creator maintaining a personal phone list and calling each fan individually. Observer flips this: interested parties subscribe themselves, and the broadcaster just says "something changed" to everyone on the list. It only knows the interface, never the specific subscriber types. Adding a new consumer is just adding a new subscriber — zero changes to the publisher.

Great Answer Bonus "Observer also enables runtime flexibility — subscribers attach and detach dynamically without redeploying the subject. In .NET, this maps directly to event += and -= at runtime, making plugin architectures trivial."
Think First If your observer only needs one field from a 20-field object, which model wastes less bandwidth?

Imagine a restaurant kitchen. In the push model, the chef puts the finished plate on the counter and calls out "Table 4, your steak is ready!" — the food comes to you with all the details. In the pull model, the chef just rings a bell and says "something's ready" — and the waiter has to walk over, look at the counter, and figure out what it is and who it's for.

Push: Subject sends data directly — observer.Update(newPrice). Simpler for observers but they receive everything whether they want it or not. This is the default in most modern implementations because it eliminates timing bugs (the observer always gets the exact data that triggered the notification).

Pull: Subject sends a bare notification (observer.Update(this)), then the observer calls back to fetch only what it needs: var price = subject.GetPrice(). More flexible when the payload is large, but creates a back-reference and risks stale reads. In .NET: C# events are push (EventArgs carry the data). INotifyPropertyChanged is pull-ish — it sends the property name, and the observer calls the getter to read the value.

Great Answer Bonus "In practice, .NET's IObservable<T> uses push with typed payloads. If the payload is large, consider sending a lightweight notification (pull) with a correlation ID so observers can fetch only what they need from a cache or store."
Think First What would happen if external code could invoke your event directly instead of just subscribing?

C# events ARE Observer — language-level syntactic sugar. The event keyword wraps a MulticastDelegateThe backing type for C# events. A linked list of method pointers. += adds to the chain, -= removes. The event keyword restricts external access so only the declaring class can invoke it — preventing callers from firing the event themselves or overwriting the entire delegate. (the observer list) and restricts external callers to only += and -=. += = Subscribe, -= = Unsubscribe, ?.Invoke() = Notify. The compiler enforces that outside code cannot overwrite or invoke the event directly.

Great Answer Bonus "The event keyword also prevents external code from reading the invocation list or assigning = null. Without it, any caller could wipe all subscribers with a single = null assignment — a subtle bug that's hard to trace in production."
Think First In WPF, what happens to the UI when you change a ViewModel property without raising PropertyChanged?

INotifyPropertyChanged is a built-in .NET interface used in MVVM. It exposes one event: PropertyChangedEventHandler? PropertyChanged. When a ViewModel property changes, it raises this event with the property name string. WPF/MAUI data binding subscribes to PropertyChanged and updates the bound UI element — that binding IS the Observer. ViewModel = Subject, binding engine = Observer, UI element = display updated by the observer.

Great Answer Bonus "In .NET 8+ with source generators, CommunityToolkit.Mvvm's [ObservableProperty] attribute auto-generates the PropertyChanged boilerplate — eliminating the magic string and the manual setter pattern entirely."
Think First If observer #2 out of 5 throws, what happens to observers #3, #4, and #5?

By default, an exception from one observer propagates up and stops all subsequent observers — they never run. This is almost always wrong. Fix: wrap each observer call individually:

ResilientNotify.cs
public void Notify(T state)
{
    foreach (var observer in _observers.ToList())
    {
        try { observer.Update(state); }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Observer {Type} threw",
                observer.GetType().Name);
            // Continue — one bad observer won't stop the others
        }
    }
}
Great Answer Bonus "In production, I'd pair the try-catch with a circuit breaker per observer — if the same observer throws 3 times in a row, temporarily unsubscribe it and alert ops. This prevents a single broken observer from degrading notification throughput."
Think First Can Observer work across two different microservices running on different machines?

Think of it this way: Observer is like shouting across a room — you can see everyone you're talking to, and they hear you instantly. Pub/Sub is like posting a letter through the post office — you drop it off, the postal system figures out who gets it, and the recipients might be in a completely different city.

Observer: The Subject holds direct references to observers. They live in the same process, same memory space. The subject calls observer methods directly — like a function call. Tightly coupled physically (same process), but loosely coupled in terms of types (only knows the interface).

Pub/SubPublish/Subscribe uses a message broker (Azure Service Bus, Kafka, RabbitMQ) as an intermediary. Publishers and subscribers don't know each other at all — they both know only the broker and the message contract. Works across processes, services, and data centers. Provides durability (messages persist if subscriber is down) and fan-out (one publish → many subscribers across services).: Publishers and subscribers communicate through a message broker (like Azure Service Bus or Kafka). No direct references. The publisher doesn't know who's listening. The broker handles delivery, retries, and persistence. Works across processes, services, machines, and even data centers. Observer = in-process, fast, simple. Pub/Sub = distributed, durable, scalable.

Great Answer Bonus "The hybrid approach is powerful: use in-process Observer (events or MediatR) within a service boundary, then publish domain events to a broker for cross-service Pub/Sub. This gives you simplicity locally and durability across services."
Think First In Rx.NET, what does the Select() operator act as — an observer, an observable, or both?

Yes — this is a chain of observers / observer pipeline. A filtering subject subscribes to a raw data source, processes events, and re-publishes to its own subscribers. Rx.NET operators work this way — each Where/Select/Throttle operator is both an IObserver<T> (subscribing upstream) and an IObservable<T> (publishing downstream). The risk: circular chains (A observes B observes A). Always design observer graphs as DAGs — no cycles.

Great Answer Bonus "This is exactly how middleware pipelines work in ASP.NET Core — each middleware observes the incoming request and acts as a source for the next middleware. Recognizing this chain-of-observers pattern helps you design composable processing pipelines."
Think First You have exactly one consumer that needs data from a producer. Is Observer the right pattern?
  • Only one consumer: Just inject a callback delegate — events add unnecessary ceremony.
  • Request-response flow: If you need a return value from the observer, use direct method calls.
  • Sequential dependency between observers: If Observer B needs Observer A's output, use a pipeline not parallel notification.
  • Publisher must confirm consumer success: Events are fire-and-forget. If confirmation is required, use awaitable method calls with result types.
  • Debugging priority: Observer makes call stacks harder to trace. In systems where debuggability > flexibility, direct calls win.
Great Answer Bonus "A useful heuristic: if you can name every consumer at compile time and there will never be more, Observer adds indirection without value. Direct method injection gives you compile-time safety, better stack traces, and easier debugging."

Intermediate Applied (Q9—Q18)

Think First What can go wrong when subscribe and notify happen simultaneously on different threads?

The core problem is simple: if one thread is looping through the observer list to send notifications, and another thread adds or removes an observer from the same list at the same time, things break. You might get a crash, a skipped observer, or a notification sent to an observer that just unsubscribed. The solution is the "copy-on-write" pattern — instead of modifying the existing list, every subscription change creates a brand new list and swaps the reference atomically.

_observers ref (single pointer) Old List [A, B, C] Notify() reads this safely New List [A, B, C, D] Subscribe() creates this was now points to Notify thread Iterates old list undisturbed Subscribe thread Swaps ref under lock
ThreadSafeSubject.cs
public class ThreadSafeSubject<T>
{
    private readonly object _lock = new();
    private List<IObserver<T>> _observers = new();

    public void Subscribe(IObserver<T> observer)
    {
        lock (_lock) // copy-on-write: atomic list replacement
            _observers = [.._observers, observer];
    }

    public void Unsubscribe(IObserver<T> observer)
    {
        lock (_lock)
        {
            var copy = new List<IObserver<T>>(_observers);
            copy.Remove(observer);
            _observers = copy;
        }
    }

    public void Notify(T value)
    {
        List<IObserver<T>> snapshot;
        lock (_lock) { snapshot = _observers; } // atomic read — no copy needed
        // Iterate outside lock — observers can safely subscribe/unsubscribe during this
        foreach (var o in snapshot) o.OnNext(value);
    }
}

The copy-on-writeNever modify the existing collection in place. Create a new copy with the change, then atomically swap the reference under a lock. Readers (Notify) always see a consistent snapshot. Writers (Subscribe/Unsubscribe) briefly lock only to swap the reference. Maximizes read concurrency — notifications never hold the lock. pattern ensures Notify() iterates a stable snapshot without holding the lock, preventing deadlocks if observers subscribe during notification.

Great Answer Bonus "For hot paths, use ImmutableArray<T> instead of List<T> for the observer collection. Immutable collections are inherently thread-safe for reads, and Interlocked.CompareExchange can atomically swap the reference — no lock needed on the read path."
Think First A WPF window subscribes to a singleton service's event but never unsubscribes. What happens when the window closes?

The gold standard is IDisposable subscription tokens — Subscribe() returns an IDisposable, and the subscriber calls Dispose() in its own cleanup:

DisposableSubscription.cs
public IDisposable Subscribe(Action<T> handler)
{
    _handlers.Add(handler);
    return new Subscription(() => _handlers.Remove(handler));
}

private sealed class Subscription(Action cleanup) : IDisposable
{
    private int _disposed;
    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) == 0)
            cleanup();
    }
}

// Subscriber owns its subscriptions — disposes all at once
public class Dashboard : IDisposable
{
    private readonly CompositeDisposable _subs = new();

    public Dashboard(IStockTicker ticker, INewsFeed news)
    {
        _subs.Add(ticker.Subscribe(OnPriceChanged));
        _subs.Add(news.Subscribe(OnNewsArrived));
    }

    public void Dispose() => _subs.Dispose();
}
Great Answer Bonus "I'd add WeakReference-based observer lists as a safety net for UI scenarios. WPF's WeakEventManager does exactly this — it holds weak references so closed windows get collected even if they forget to unsubscribe. Defense in depth: IDisposable for explicit cleanup, weak references as a GC fallback."
Think First Can you pass a C# event as a method parameter? What about IObservable<T>?
  • Composability: IObservable chains with LINQ-like operators (Where, Select, Buffer, Throttle). Events cannot be composed without Rx.
  • Error propagation: OnError(Exception) carries exceptions through the pipeline. Events either crash or swallow.
  • Completion: OnCompleted() signals "no more values." Events have no end signal.
  • Lifetime management: Subscribe() returns IDisposable — built-in cleanup. Events need manual -=.
  • First-class value: IObservable<T> can be stored in fields, passed as parameters, returned from methods. Events cannot.
Great Answer Bonus "The key insight is that IObservable<T> is a first-class value while events are a language feature bolted onto classes. You can build an entire stream-processing pipeline as a method chain — something impossible with events without wrapping them in Observable.FromEventPattern first."
Think First You need to debounce a search box input and merge it with a filter dropdown change. Can plain events do this cleanly?

Reach for Rx when you need: operator composition (throttle rapid input, buffer batches, merge streams), time-based operations (Debounce, Timeout, Sample), cross-thread scheduling (ObserveOn(scheduler)), or complex coordination (Zip two streams, CombineLatest). Plain events win for: simple one-to-many notification with no composition, existing WPF infrastructure, or when the Rx NuGet dependency isn't justified. Rule of thumb: if you've written more than one filter or one TimeSpan-based condition on events, reach for Rx.

Great Answer Bonus "My decision tree: plain events for simple fire-and-forget notification. Rx for anything involving time, combination, or transformation. The crossover point is when you write your first Timer or Task.Delay alongside an event handler — that's your signal to switch to Rx."
Think First If you have 10 event types and 50 observers, but each observer only cares about 2 types, how do you avoid 80% wasted notifications?

Three approaches: filter in the subject (store predicates), filter in the observer (OnNext checks and discards), or topic-based routing (separate list per event type):

TypedEventBus.cs
public class TypedEventBus
{
    private readonly Dictionary<Type, List<Delegate>> _handlers = new();

    public IDisposable Subscribe<TEvent>(Action<TEvent> handler)
    {
        var key = typeof(TEvent);
        if (!_handlers.TryGetValue(key, out var list))
            _handlers[key] = list = new();
        list.Add(handler);
        return new Subscription(() => list.Remove(handler));
    }

    public void Publish<TEvent>(TEvent evt)
    {
        if (_handlers.TryGetValue(typeof(TEvent), out var list))
            foreach (var h in list.ToList())
                ((Action<TEvent>)h)(evt); // only handlers for TEvent
    }
}
Great Answer Bonus "The TypedEventBus approach maps cleanly to MediatR's INotificationHandler<T> — each handler only receives its declared type. At scale, consider a trie-based topic matcher for hierarchical filtering: 'orders.created.us-east' matches subscribers to 'orders.created.*'."
Think First Standard C# events return void. What happens when your observer needs to do async I/O like writing to a database?
AsyncObserver.cs
public interface IAsyncObserver<T>
{
    Task OnNextAsync(T value, CancellationToken ct = default);
}

public class AsyncSubject<T>
{
    private readonly List<IAsyncObserver<T>> _observers = new();

    // Sequential: each observer waits for previous to complete
    public async Task NotifySequentialAsync(T value, CancellationToken ct = default)
    {
        foreach (var o in _observers.ToList())
            await o.OnNextAsync(value, ct);
    }

    // Parallel: all observers run concurrently — use when independent
    public async Task NotifyParallelAsync(T value, CancellationToken ct = default)
    {
        await Task.WhenAll(_observers.ToList()
            .Select(o => o.OnNextAsync(value, ct)));
    }
}

Task.WhenAllAwaits all tasks to complete and returns when ALL finish (or throws AggregateException if any fail). Use for parallel independent observers. Use sequential await when order matters or when one observer's output affects the next. is preferred for independent observers. Sequential is needed when one observer must complete before the next runs (e.g., a validation pipeline).

Great Answer Bonus "Watch out for async void event handlers — they swallow exceptions silently. The IAsyncObserver<T> pattern makes async explicit and awaitable. For fire-and-forget scenarios, Channel<T> with a background consumer is safer than async void because exceptions surface in the consumer loop."
Think First How does ASP.NET Core's DI container know to inject ALL registered IOrderEventHandler implementations, not just one?
DIObserver.cs
// Register all observers — new handler = new class + one line here
builder.Services.AddScoped<IOrderEventHandler, AuditLogHandler>();
builder.Services.AddScoped<IOrderEventHandler, EmailNotificationHandler>();
builder.Services.AddScoped<IOrderEventHandler, InventoryUpdateHandler>();

// Dispatcher injects all registered handlers automatically
public class OrderEventDispatcher(IEnumerable<IOrderEventHandler> handlers)
{
    public async Task DispatchAsync(OrderPlacedEvent evt, CancellationToken ct)
    {
        foreach (var handler in handlers)
            await handler.HandleAsync(evt, ct);
    }
}
// No factory, no switch — DI IS the observer registry
Great Answer Bonus "Injecting IEnumerable<IHandler> is the DI-native Observer pattern. The container IS the subject's subscriber list. Combined with keyed services in .NET 8, you can route events to specific handler subsets by key — filtered Observer through pure DI configuration."
Think First Observer: A broadcasts to many listeners. Mediator: everyone talks through a central hub. When does one-to-many broadcasting become many-to-many coordination?

Think of Observer like a radio station: one broadcaster sends a signal, and anyone with a radio picks it up. The station doesn't coordinate between listeners. Now think of Mediator like an air traffic controller: every plane talks only to the controller, and the controller coordinates who goes where. Planes never talk directly to each other.

Observer (one-to-many) Subject Obs A Obs B Obs C Mediator (hub-and-spoke) Mediator A B C D

Observer: Subject knows it has observers. One-directional notification. Best for event broadcasting, property change, "something happened — here's the data." The subject talks, observers listen.

Mediator: No object knows about others — all talk through the mediator. The mediator knows everyone and coordinates interactions. Best for complex object interactions, form coordination, or N-by-M connections where everything talks to everything. MediatR in .NET is the classic implementation.

The decision rule: If A is telling B "something changed" — use Observer. If A is asking "who handles this command?" — use Mediator. If your observer graph starts looking like a web instead of a tree, it's time to upgrade to a Mediator.

Great Answer Bonus "In practice, I start with Observer and graduate to Mediator when observers start publishing their own events that other observers consume — creating implicit coupling. If your observer graph looks like a web rather than a tree, Mediator centralizes that complexity."
Think First Two observers must run in order: validation before persistence. Does standard Observer guarantee this?

Standard Observer notifies in subscription order — but this is an implementation detail you should never rely on. If ordering matters, model it explicitly:

PriorityObserver.cs
private readonly List<(IObserver<T> Observer, int Priority)> _observers = new();

public void Subscribe(IObserver<T> observer, int priority = 0)
{
    _observers.Add((observer, priority));
    _observers.Sort((a, b) => b.Priority.CompareTo(a.Priority)); // high first
}

public void Notify(T value)
{
    foreach (var (obs, _) in _observers.ToList())
        obs.OnNext(value);
}
Great Answer Bonus "Priority-based ordering is a code smell — it means observers have implicit dependencies. If ordering truly matters, consider a pipeline pattern (Chain of Responsibility) instead, where each step explicitly passes to the next. Reserve Observer for truly independent, order-agnostic reactions."
Think First After calling Dispose() on a subscription, how can you prove the observer object was actually released by the GC?

Use WeakReference in your test to verify the subscriber is garbage-collected after unsubscribing:

MemoryLeakTest.cs
[Fact]
public void Subscriber_CollectedAfterUnsubscribe()
{
    var subject = new StockPriceSubject("AAPL");
    WeakReference weakRef = null!;

    var sub = CreateSubscription(subject, out weakRef);
    sub.Dispose(); // unsubscribe

    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    Assert.False(weakRef.IsAlive, "Memory leak: subscriber still alive after Dispose()");
}

private IDisposable CreateSubscription(StockPriceSubject subject, out WeakReference weakRef)
{
    var observer = new FakeStockObserver();
    weakRef = new WeakReference(observer);
    return subject.Subscribe(observer);
    // observer goes out of scope here — only WeakReference and subject hold refs
}
Great Answer Bonus "In CI pipelines, I pair WeakReference tests with profiler snapshots. The WeakReference test catches the obvious case (forgot to remove from list), but profiler snapshots catch subtle leaks like closure captures, static event handlers, and delegate chains that keep objects alive through unexpected reference paths."

Advanced Expert Level (Q19—Q29)

Think First What happens when a stock ticker fires 50K events/sec and each observer takes 1ms?

Backpressure is what happens when a producer creates data faster than consumers can process it. Imagine a fire hose connected to a drinking fountain — the fountain can't handle the flow. Without a strategy, you get either crashed consumers (out of memory from queuing too many events) or lost data (events dropped on the floor). The key is choosing the right strategy for your domain.

Producer 50K events/sec Throttle Wait for quiet period Sample Take latest every N ms Buffer Batch into windows BoundedChannel Drop oldest when full Best for: search box input Best for: UI dashboard refresh Best for: analytics bulk insert Best for: async hand-off to workers Consumer 1K events/sec
Backpressure.cs
// Option 1: BoundedChannel — blocks/drops when full
var channel = Channel.CreateBounded<StockPrice>(new BoundedChannelOptions(1000)
{
    FullMode = BoundedChannelFullMode.DropOldest
});

// Option 2: Rx Throttle — only emit after N ms quiet period
ticker.Prices
    .Throttle(TimeSpan.FromMilliseconds(100))
    .Subscribe(p => UpdateUI(p));

// Option 3: Rx Sample — emit latest every N ms regardless
ticker.Prices
    .Sample(TimeSpan.FromMilliseconds(500))
    .Subscribe(p => UpdateDashboard(p));

// Option 4: Rx Buffer — batch N events into windows
ticker.Prices
    .Buffer(TimeSpan.FromSeconds(1))
    .Subscribe(batch => ProcessBatch(batch));

Right strategy by domain: UI updates → Sample/Throttle (show latest). Audit → BoundedChannel (no data loss, backpressure to producer). Analytics → Buffer (batch efficiency).

Great Answer Bonus "I'd use Rx BufferBuffer(TimeSpan) or Buffer(int count) collects events into batches and emits them as IList<T>. Perfect for analytics pipelines where you want to bulk-insert to a database rather than insert row-by-row on every event. Combine with Buffer(count: 100, skip: 100) for non-overlapping windows. to batch high-velocity events combined with a BoundedChannel for the async hand-off to background workers. The Rx pipeline handles time-based coordination; the channel handles async flow control."
Think First Standard Observer loses events if no one is listening. How does Event Sourcing solve this?

Think of standard Observer like a live radio broadcast — if you weren't tuned in, you missed it forever. Event Sourcing is like recording every broadcast. Anyone who joins late can replay the recordings to catch up, then listen live from that point forward.

Event Store (Subject) Persisted, immutable, replayable evt 0 evt 1 evt 2 evt 3 evt 4 Projection A Live at evt 4 Projection B (new) Replaying from evt 0 live replay Late joiners can catch up!

Event Sourcing stores every state change as an immutable event. Projections (read models) are observers of the event stream — they subscribe and build their own views by processing events sequentially. The event store is the Subject; projections are Observers. The key difference from basic Observer: ES events are persisted and replayable. Standard Observer notifications are ephemeral — missed events are gone. With ES, any projection can be rebuilt from scratch by replaying from event 0, making Observer's "fire-and-forget" limitation irrelevant.

Great Answer Bonus "Combine ES with Observer: the event store provides durability and replay, while in-process Observer handles real-time projections. New projections catch up by replaying the store, then switch to live Observer notifications — both historical completeness and real-time responsiveness."
Think First In-process Observer uses direct method calls. What replaces method calls when subject and observer are in different services on different machines?

When Observer needs to cross process boundaries — when the subject and observers live in different services on different machines — direct method calls won't work. You need a message broker sitting in the middle to carry notifications between services. This is Pub/Sub: the distributed version of Observer.

Order Service publishes events Message Broker Kafka / Azure Service Bus durability + fan-out + retry at-least-once delivery Inventory Service Email Service Analytics Service publish consume Each consumer must handle duplicate messages (idempotency)

The subject publishes to a topic (Azure Service Bus, Kafka, RabbitMQ). Subscriber services consume from that topic — each service is an independent Observer. The broker provides durability (messages survive crashes), fan-out (one publish reaches many consumers), ordering, and at-least-once delivery. In .NET, MassTransit or NServiceBus abstract the broker: consumers implement IConsumer<TMessage> — the Observer interface mapped to a message type.

The critical addition over in-process Observer: idempotency. Network failures can cause the broker to deliver the same message twice. Your consumers must handle duplicates safely — typically by checking a "processed events" log before acting. Without idempotency, a redelivered "OrderPlaced" event could charge a customer twice.

Great Answer Bonus "Add the Outbox pattern: write the event to an outbox table in the same transaction as the state change. A background process publishes from the outbox. This guarantees at-least-once delivery without distributed transactions."
Think First What happens when a push-based Observable produces events faster than the subscriber can process them?

Classic Observer is push-only — the subject pushes data at whatever rate it wants, and the consumer has to keep up or drown. Reactive Streams fixes this by adding a conversation between producer and consumer. The consumer says "I'm ready for 5 more items," and the producer only sends 5. This is called async backpressure.

Classic Observer (push-only) Producer Consumer push push push! Consumer overwhelmed Reactive Streams (backpressure) Producer Consumer send 5 items request(5) Consumer controls the pace

In .NET, IAsyncEnumerable<T> with await foreach implements this naturally — each await MoveNextAsync() IS the subscriber requesting one item. The producer can't outrun the consumer because it must await each request. System.Threading.Channels provides the same pattern with BoundedChannel + ReadAllAsync().

Great Answer Bonus "In .NET 8 the convergence is Channel + IAsyncEnumerable: BoundedChannel handles the async hand-off with built-in backpressure, and ReadAllAsync() returns IAsyncEnumerable so consumers iterate at their own pace."
Think First A new dashboard connects after the system has been running for an hour. How does it show the current state without waiting for the next event?
ReplaySubject.cs
// Rx ReplaySubject — buffers past values for late subscribers
var replay = new ReplaySubject<int>(bufferSize: 3); // keep last 3

replay.OnNext(1);
replay.OnNext(2);
replay.OnNext(3);
replay.OnNext(4); // 1 evicted — buffer: [2, 3, 4]

// Late subscriber immediately receives [2, 3, 4] then live events
replay.Subscribe(v => Console.WriteLine(v)); // prints: 2, 3, 4

replay.OnNext(5); // both subscribers receive 5

// BehaviorSubject — keeps only the LATEST value
var behavior = new BehaviorSubject<int>(initialValue: 0);
// New subscriber always gets the current value immediately on subscribe
Great Answer Bonus "In distributed systems, replay maps to Kafka consumer groups with offset tracking. Locally, BehaviorSubject (latest value) is for state, ReplaySubject (N values) is for history. Choose based on whether the subscriber needs current state or historical sequence."
Think First In CQRS, the write side changes state and the read side serves queries. What connects them?

CQRS splits your system into a Write side (handles commands, changes state) and a Read side (serves queries, displays data). The Observer pattern is the bridge between them — when the write side changes something, domain events notify the read side to update its projections.

Command PlaceOrder Aggregate Raises events Event Dispatcher (Subject) Read Projection A Read Projection B Email Handler Audit Handler WRITE SIDE READ SIDE + SIDE EFFECTS

The pipeline flows like this: Command arrives, the Aggregate processes it and raises DomainEvents, the Dispatcher (which is the Subject) delivers those events to every registered handler (each handler is an Observer). Projections update read models; side-effect handlers send emails, write audit logs, etc. Adding a new reaction to any event = one new handler class + one DI registration line. Zero changes to the aggregate or command handler.

Great Answer Bonus "Because Observer decouples write from read, you can add eventual consistency strategies per projection. A critical projection (account balance) might use synchronous Observer. A non-critical one (analytics dashboard) might use async Observer with a queue — same pattern, different timing guarantees."
Think First An event fires but one observer never receives it — list the possible causes.

When an event fires but one observer never receives it, the problem could be anywhere in the notification chain. Think of it like debugging a package that never arrived — was it never sent? Was it sent to the wrong address? Did it get intercepted along the way? Here's a systematic checklist to narrow it down:

1. Subscribed? Log Subscribe() 2. Event fired? Log top of Notify() 3. Unsub too early? Log Unsubscribe() 4. Prior threw? try/catch per obs 5. Wrong thread? Check thread ID 6. Filter dropped? Log at each Where() Rx: .Do() tap operator Log between every operator

Walk through each checkpoint in order: (1) Was the observer subscribed? — Add logging inside Subscribe(). (2) Did the event fire at all? — Log at the top of Notify(). (3) Was it unsubscribed too early? — Log inside Unsubscribe() with a stack trace to see who triggered it. (4) Did a prior observer throw and stop the chain? — Add per-observer try/catch to isolate failures. (5) Thread affinity? — Is the notification on a background thread while the observer needs the UI thread? (6) Filter dropped it? — Log at each Where() boundary in Rx pipelines.

In Rx pipelines, the .Do(x => logger.Log("Value: {V}", x)) operator is invaluable — it lets you "tap" into the stream at any point to see what values are flowing through, without changing the pipeline behavior. Place it between operators to find exactly where events get filtered or dropped.

Great Answer Bonus "Wrap every observer call with structured logging that captures observer type, payload, and timestamp. With Serilog or OpenTelemetry, reconstruct the full notification graph from logs and find exactly where the event was dropped, filtered, or swallowed."
Think First Each middleware in ASP.NET Core can inspect, modify, or short-circuit a request. How does the framework let you observe every request without modifying middleware?

DiagnosticListener is .NET's built-in Observer infrastructure. The framework publishes diagnostic events; your code subscribes. OpenTelemetry, Application Insights, and custom APM tools all hook in this way — zero changes to application code:

DiagnosticObserver.cs
// Register as IObserver<DiagnosticListener> in DI
builder.Services.AddSingleton<IObserver<DiagnosticListener>, HttpRequestObserver>();

public class HttpRequestObserver : IObserver<DiagnosticListener>
{
    public void OnNext(DiagnosticListener listener)
    {
        if (listener.Name == "Microsoft.AspNetCore")
            listener.Subscribe(new HttpEventObserver());
    }
    public void OnError(Exception error) { }
    public void OnCompleted() { }
}

public class HttpEventObserver : IObserver<KeyValuePair<string, object?>>
{
    public void OnNext(KeyValuePair<string, object?> evt)
    {
        if (evt.Key == "Microsoft.AspNetCore.Hosting.HttpRequestIn.Start")
        {
            var httpContext = (HttpContext)evt.Value!;
            // Observe every incoming request — logging, metrics, tracing
        }
    }
    public void OnError(Exception error) { }
    public void OnCompleted() { }
}
Great Answer Bonus "DiagnosticSource uses IsEnabled() guards so the payload object isn't even allocated unless someone is subscribed. This makes Observer nearly zero-cost when no observers are attached — critical for framework-level instrumentation that ships in every .NET app."
Think First You have a subject with 4 observers. Do you test the subject and each observer separately, or the whole notification chain together?

Both — but the split matters. Unit tests verify each observer in isolation: given this input event, does the observer produce the correct side effect? Mock the subject; call OnNext() directly. Integration tests verify the wiring: does the subject actually reach all observers, in the right order, with the right data?

Key testing strategies: (1) Unit-test observers independently by calling their handler method directly. (2) Integration-test the dispatcher/subject with real DI wiring. (3) For Rx pipelines, use TestScheduler to control virtual time — never use Task.Delay in tests. (4) Use WeakReference tests to verify no memory leaks after unsubscription.

Great Answer Bonus "I structure observer tests in three layers: unit tests per handler (fast, isolated), integration tests for the dispatch wiring (medium, uses real DI), and contract tests that verify the event schema hasn't changed — preventing silent observer breakage when the subject's event type evolves."
Think First An Order aggregate marks itself as "placed." Three other parts of the system need to react: inventory, email, analytics. Where does the coupling live?

Domain events ARE Observer applied to DDD. The aggregate (Subject) raises events; domain event handlers (Observers) react. The aggregate never knows who's listening — it just records what happened.

The dispatch timing matters: pre-commit handlers (same transaction) are for consistency-critical reactions like inventory reservation. Post-commit handlers (after SaveChanges) are for side effects like email and analytics. This two-phase approach lets you guarantee correctness for critical operations while keeping non-critical work decoupled.

Order.Place() Raise(OrderPlaced) PRE-COMMIT (same TX) Inventory Validation SaveChanges() POST-COMMIT (side effects) Email Analytics Audit Rollback if handler fails Failures don't roll back order
DomainEvents.cs
public abstract class Entity
{
    private readonly List<IDomainEvent> _events = new();
    public IReadOnlyList<IDomainEvent> DomainEvents => _events;
    protected void Raise(IDomainEvent evt) => _events.Add(evt);
    public void ClearEvents() => _events.Clear();
}

public class Order : Entity
{
    public void Place()
    {
        Status = OrderStatus.Placed;
        Raise(new OrderPlacedEvent(Id, Total, DateTime.UtcNow));
    }
}

// EF Core dispatches after SaveChanges
public class DomainEventDispatcher(IServiceProvider sp)
{
    public async Task DispatchAsync(IEnumerable<IDomainEvent> events, CancellationToken ct)
    {
        foreach (var evt in events)
        {
            var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(evt.GetType());
            foreach (dynamic handler in sp.GetServices(handlerType))
                await handler.HandleAsync((dynamic)evt, ct);
        }
    }
}
Great Answer Bonus "I split domain events into two phases: synchronous in-process handlers dispatched before SaveChanges for invariant enforcement, and async integration events published to a broker after SaveChanges for cross-boundary side effects. The Outbox pattern guarantees the integration events are eventually published."
Think First You have 500 observers on a subject that fires 10,000 events/sec. What's the total method call overhead?

Observer becomes a bottleneck when the math works against you. 500 observers multiplied by 10,000 events per second equals 5 million method calls per second. Each call allocates EventArgs, traverses the list, and waits for the observer to finish. At some point, the overhead overwhelms your system.

< 100 evt/sec Standard Observer < 20 observers No optimization needed 100 - 10K evt/sec Add batching Channel + Buffer 100x fewer calls > 10K evt/sec Per-consumer queues Disruptor / Channels Object pooling required Three bottlenecks: fan-out explosion, synchronous blocking, GC pressure from allocations

The three scenarios: Fan-out explosion (N observers x M events/sec = N*M calls), synchronous blocking (one slow observer blocks the chain), and GC pressure (EventArgs allocation at high frequency). Use the decision framework above to pick the right strategy for your throughput level.

HighPerfObserver.cs
// Fix 1: Object pooling — zero allocation at high frequency
var pool = ObjectPool.Create<PriceEvent>();
var evt = pool.Get();
evt.Init("AAPL", 185.50m);
subject.Notify(evt);
pool.Return(evt); // recycle

// Fix 2: Channel + batching — reduce observer call count
await foreach (var batch in channel.Reader.ReadAllAsync().Buffer(100))
    foreach (var observer in observers)
        observer.OnBatch(batch); // 100x fewer calls
Great Answer Bonus "I benchmark with BenchmarkDotNet: measure Notify() latency at P50, P95, and P99 as observer count grows. My rule: if P99 exceeds your SLA, move to async fan-out with per-observer Channel<T> queues. Each observer gets its own bounded channel — the subject's Notify() just writes to N channels and returns in microseconds."
Section 18

Practice Exercises

Four exercises, four levels. Start at Easy to warm up your Observer muscles. Hit Expert to walk out ready to architect a real-time data platform.

Exercise 1: Temperature Sensor with 3 Display Observers Easy

Build a TemperatureSensor (the Subject) and three observers: CurrentTempDisplay (shows current temperature), HighLowTracker (tracks min/max), and AlertObserver (triggers if temperature exceeds a threshold). Implement the classic GoF Observer interfaces. Use IDisposable for unsubscription.

  • Define ITemperatureObserver with void OnTemperatureChanged(float celsius)
  • TemperatureSensor.SetTemperature(float) should only notify if the value actually changed (delta > 0.1°)
  • HighLowTracker keeps two fields: float Min and float Max
  • AlertObserver takes a threshold in its constructor — alerts once per crossing (use a flag)
  • Return a Subscription tokenA lightweight IDisposable that removes the observer from the subject's list when Dispose() is called. Pattern: Subscribe() creates a Subscription(cleanup: () => _observers.Remove(this)), returns it. The caller stores it and disposes when done. from Subscribe() that removes the observer when disposed
TemperatureSensor.cs
public interface ITemperatureObserver
{
    void OnTemperatureChanged(float celsius);
}

public class TemperatureSensor
{
    private readonly List<ITemperatureObserver> _observers = new();
    private float _temperature;

    public IDisposable Subscribe(ITemperatureObserver observer)
    {
        _observers.Add(observer);
        return new Subscription(() => _observers.Remove(observer));
    }

    public void SetTemperature(float celsius)
    {
        if (Math.Abs(celsius - _temperature) < 0.1f) return; // no-change guard
        _temperature = celsius;
        foreach (var obs in _observers.ToList()) obs.OnTemperatureChanged(celsius);
    }

    private sealed class Subscription(Action cleanup) : IDisposable
    {
        private int _disposed;
        public void Dispose()
        {
            if (Interlocked.Exchange(ref _disposed, 1) == 0) cleanup();
        }
    }
}

public class CurrentTempDisplay : ITemperatureObserver
{
    public float Current { get; private set; }
    public void OnTemperatureChanged(float celsius) => Current = celsius;
}

public class HighLowTracker : ITemperatureObserver
{
    public float Min { get; private set; } = float.MaxValue;
    public float Max { get; private set; } = float.MinValue;

    public void OnTemperatureChanged(float celsius)
    {
        if (celsius < Min) Min = celsius;
        if (celsius > Max) Max = celsius;
    }
}

public class AlertObserver(float threshold) : ITemperatureObserver
{
    public bool IsAlerting { get; private set; }
    public event Action<float>? AlertTriggered;
    public event Action? AlertCleared;

    public void OnTemperatureChanged(float celsius)
    {
        if (celsius > threshold && !IsAlerting)
        {
            IsAlerting = true;
            AlertTriggered?.Invoke(celsius);
        }
        else if (celsius <= threshold && IsAlerting)
        {
            IsAlerting = false;
            AlertCleared?.Invoke();
        }
    }
}
Exercise 2: Stock Portfolio Tracker with Filtered Notifications Medium

Build a StockMarket subject that fires price updates for multiple tickers. Observers should only receive updates for the stocks they care about. Implement: PortfolioObserver (tracks holdings with P&L calculation), PriceAlertObserver (fires when a stock crosses a target price), and MovingAverageObserver (tracks 5-period moving average per ticker). Use a typed event bus approach where subscription includes a ticker filter.

  • Store observers as Dictionary<string, List<IStockObserver>> keyed by ticker
  • Subscribe(string ticker, IStockObserver observer) adds to the right list
  • MovingAverageObserver keeps a Queue<decimal> of size N per ticker
  • P&L = (currentPrice - purchasePrice) * shares — update on every tick for held stocks
  • Use record PriceUpdate(string Ticker, decimal Price, DateTimeOffset At) as the event payload
StockMarket.cs
public record PriceUpdate(string Ticker, decimal Price, DateTimeOffset At);

public interface IStockObserver { void OnPriceUpdate(PriceUpdate update); }

public class StockMarket
{
    private readonly Dictionary<string, List<IStockObserver>> _subs = new();

    public IDisposable Subscribe(string ticker, IStockObserver observer)
    {
        if (!_subs.TryGetValue(ticker, out var list))
            _subs[ticker] = list = new();
        list.Add(observer);
        return new Subscription(() =>
        {
            if (_subs.TryGetValue(ticker, out var l)) l.Remove(observer);
        });
    }

    public void UpdatePrice(string ticker, decimal price)
    {
        var update = new PriceUpdate(ticker, price, DateTimeOffset.UtcNow);
        if (_subs.TryGetValue(ticker, out var list))
            foreach (var obs in list.ToList()) obs.OnPriceUpdate(update);
    }

    private sealed class Subscription(Action cleanup) : IDisposable
    {
        private int _disposed;
        public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) cleanup(); }
    }
}

public class PortfolioObserver(Dictionary<string, (decimal PurchasePrice, int Shares)> holdings)
    : IStockObserver
{
    private readonly Dictionary<string, decimal> _lastPrices = new();
    public decimal TotalPnL { get; private set; }

    public void OnPriceUpdate(PriceUpdate update)
    {
        if (!holdings.ContainsKey(update.Ticker)) return;
        _lastPrices[update.Ticker] = update.Price;
        TotalPnL = holdings.Sum(h =>
        {
            var current = _lastPrices.GetValueOrDefault(h.Key, h.Value.PurchasePrice);
            return (current - h.Value.PurchasePrice) * h.Value.Shares;
        });
    }
}

public class MovingAverageObserver(int period = 5) : IStockObserver
{
    private readonly Dictionary<string, Queue<decimal>> _windows = new();
    public Dictionary<string, decimal> Averages { get; } = new();

    public void OnPriceUpdate(PriceUpdate update)
    {
        if (!_windows.TryGetValue(update.Ticker, out var q))
            _windows[update.Ticker] = q = new Queue<decimal>();
        q.Enqueue(update.Price);
        if (q.Count > period) q.Dequeue();
        Averages[update.Ticker] = q.Average();
    }
}
Exercise 3: Chat Room with Async Message Delivery and Retry Hard

Build a ChatRoom subject with async observers. Each observer represents a chat client. Requirements: (1) messages deliver to all connected clients asynchronously in parallel; (2) if a client fails to receive, retry up to 3 times with exponential backoff; (3) after 3 failures, the client is auto-unsubscribed and marked offline; (4) delivery is fire-and-forgetFire-and-forget means starting async work without awaiting it in the calling method. Use _ = DoAsync() or Task.Run(). The caller returns immediately. Risks: exceptions are unobserved (use try-catch inside). In Observer, fire-and-forget prevents slow observers from blocking subsequent ones. from the room's perspective — posting a message returns immediately.

  • Observer interface: Task ReceiveAsync(ChatMessage message, CancellationToken ct)
  • Use Task.Run inside PostMessage so the room returns immediately
  • Retry loop: for (int attempt = 0; attempt < 3; attempt++) with await Task.Delay(100 * Math.Pow(2, attempt))
  • Track failures per client in a ConcurrentDictionary<IClient, int>
  • Auto-unsubscribe: after 3 failures, remove from _clients and raise ClientDisconnected
ChatRoom.cs
public record ChatMessage(string From, string Text, DateTimeOffset At);

public interface IChatClient
{
    string Name { get; }
    Task ReceiveAsync(ChatMessage message, CancellationToken ct);
}

public class ChatRoom(ILogger<ChatRoom> logger)
{
    private readonly ConcurrentDictionary<IChatClient, int> _clients = new();
    public event Action<IChatClient>? ClientDisconnected;

    public IDisposable Join(IChatClient client)
    {
        _clients.TryAdd(client, 0);
        return new Subscription(() =>
        {
            _clients.TryRemove(client, out _);
            logger.LogInformation("{Name} left the room", client.Name);
        });
    }

    // Fire-and-forget — PostMessage returns immediately
    public void PostMessage(ChatMessage message)
        => _ = DeliverToAllAsync(message);

    private async Task DeliverToAllAsync(ChatMessage message)
    {
        var deliveryTasks = _clients.Keys.Select(c => DeliverWithRetryAsync(c, message));
        await Task.WhenAll(deliveryTasks);
    }

    private async Task DeliverWithRetryAsync(IChatClient client, ChatMessage message)
    {
        const int maxRetries = 3;
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

        for (int attempt = 0; attempt < maxRetries; attempt++)
        {
            try
            {
                await client.ReceiveAsync(message, cts.Token);
                _clients[client] = 0; // reset failure count on success
                return;
            }
            catch (Exception ex) when (attempt < maxRetries - 1)
            {
                var delay = TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt));
                logger.LogWarning(ex, "{Name}: delivery failed, retry {N} in {Delay}ms",
                    client.Name, attempt + 1, delay.TotalMilliseconds);
                await Task.Delay(delay, cts.Token);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "{Name}: max retries exceeded — disconnecting", client.Name);
                _clients.TryRemove(client, out _);
                ClientDisconnected?.Invoke(client);
            }
        }
    }

    private sealed class Subscription(Action cleanup) : IDisposable
    {
        private int _disposed;
        public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) cleanup(); }
    }
}
Exercise 4: Event Sourcing with Replay Capability Expert

Build an EventStore<T> that persists all events and supports replay. Requirements: (1) all published events are stored in an append-only log; (2) late subscribers can request replay from event N; (3) a Projection<TState> class builds state by folding over events; (4) snapshots optimize replay for long streams (rebuild from nearest snapshot + events after it); (5) concurrent publishers and consumers are safe.

  • Store events as ImmutableList<StoredEvent<T>> with sequence number and timestamp
  • Subscribe(from: 0) replays all stored events, then live events going forward
  • Projection<TState> takes a Func<TState, T, TState> folder function — pure functional reduce
  • Snapshot: TakeSnapshot() stores current state + current sequence number
  • Use ReaderWriterLockSlim for concurrent read (many replay requests) + write (append events)
EventStore.cs
public sealed record StoredEvent<T>(long Sequence, T Payload, DateTimeOffset OccurredAt);

public class EventStore<T> : IDisposable
{
    private readonly ReaderWriterLockSlim _lock = new();
    private readonly List<StoredEvent<T>> _log = new();
    private readonly List<(long FromSeq, Action<StoredEvent<T>> Handler)> _subscribers = new();
    private long _sequence;

    public long Append(T payload)
    {
        StoredEvent<T> stored;
        List<(long, Action<StoredEvent<T>>)> subs;

        _lock.EnterWriteLock();
        try
        {
            stored = new StoredEvent<T>(++_sequence, payload, DateTimeOffset.UtcNow);
            _log.Add(stored);
            subs = new(_subscribers); // snapshot under write lock
        }
        finally { _lock.ExitWriteLock(); }

        foreach (var (_, handler) in subs) handler(stored);
        return stored.Sequence;
    }

    public IDisposable Subscribe(Action<StoredEvent<T>> handler, long fromSequence = 0)
    {
        List<StoredEvent<T>> replay;
        // Atomic: replay + register under SAME write lock to prevent event gaps
        _lock.EnterWriteLock();
        try
        {
            replay = _log.Where(e => e.Sequence > fromSequence).ToList();
            _subscribers.Add((fromSequence, handler));
        }
        finally { _lock.ExitWriteLock(); }

        // Replay historical events after registration (new live events will queue)
        foreach (var evt in replay) handler(evt);

        return new Subscription(() =>
        {
            _lock.EnterWriteLock();
            try { _subscribers.RemoveAll(s => s.Handler == handler); }
            finally { _lock.ExitWriteLock(); }
        });
    }

    public Projection<T, TState> CreateProjection<TState>(
        TState initial, Func<TState, T, TState> folder)
        => new(this, initial, folder);

    public void Dispose() => _lock.Dispose();

    private sealed class Subscription(Action cleanup) : IDisposable
    {
        private int _disposed;
        public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) cleanup(); }
    }
}

public class Projection<T, TState> : IDisposable
{
    private TState _state;
    private readonly IDisposable _subscription;
    private readonly object _stateLock = new();

    public Projection(EventStore<T> store, TState initial, Func<TState, T, TState> folder)
    {
        _state = initial;
        _subscription = store.Subscribe(evt =>
        {
            lock (_stateLock) { _state = folder(_state, evt.Payload); }
        });
    }

    public TState State { get { lock (_stateLock) return _state; } }
    public void Dispose() => _subscription.Dispose();
}
Section 19

Cheat Sheet

Everything you need in one scannable grid. Bookmark this — it's the fastest reference when you're mid-implementation and need a quick sanity check.

Pattern Structure
Subject (Publisher)
  → _observers: List
  → Subscribe(obs)
  → Unsubscribe(obs)
  → Notify() → loop

IObserver (interface)
  → OnNext(T value)
  → [OnError, OnCompleted]

ConcreteObserver
  → implements IObserver
  → reacts to OnNext
Key .NET Interfaces
IObservable<T>
  → Subscribe(IObserver<T>)
  → returns IDisposable

IObserver<T>
  → OnNext(T value)
  → OnError(Exception)
  → OnCompleted()

INotifyPropertyChanged
  → PropertyChanged event

IProgress<T>
  → Report(T value)
Common Implementations
// C# events (simplest)
event Action<T>? Changed;
Changed?.Invoke(value);

// List<IObserver> (full control)
_observers.ToList()
  .ForEach(o => o.OnNext(v));

// Rx Subject (composable)
var sub = new Subject<T>();
sub.OnNext(value);

// Channel (async + backpressure)
_channel.Writer.TryWrite(v);
.NET Built-insThese .NET types all implement the Observer pattern internally: event (MulticastDelegate observer list), INotifyPropertyChanged (MVVM property change), IProgress<T> (async progress reporting), IObservable<T> / IObserver<T> (Rx base interfaces), ObservableCollection<T> (collection change notifications via INotifyCollectionChanged).
event keyword
  → MulticastDelegate list

INotifyPropertyChanged
  → MVVM data binding

IProgress<T>
  → async progress reports

ObservableCollection<T>
  → INotifyCollectionChanged

IObservable / IObserver
  → Rx.NET base types
Thread Safety Checklist
✓ Copy-on-write for list
✓ Snapshot before notify
✓ lock only for list swap
✓ Iterate OUTSIDE lock
✓ Local var for event:
  var h = MyEvent;
  h?.Invoke(arg);
✗ Never lock during
  observer callback
Memory Management
ALWAYS pair += with -=
Return IDisposable token
  from Subscribe()
Use CompositeDisposable
  for multiple subs
Implement IDisposable
  on observer classes
Avoid static events
  with instance handlers
WeakReference: last resort
  (adds null-check overhead)
When to Use
Multiple consumers of
  the same state change
Consumers unknown at
  compile time
Real-time dashboards
Event-driven architecture
MVVM property binding
Domain events in DDD
Progress reporting
Reactive data pipelines
When NOT to Use
Only 1 consumer ever
  → use direct callback
Return value needed
  → use method call
Request-response flow
  → not fire-and-forget
Publisher must confirm
  consumer success
Debugging is priority
  → direct calls traceable
Simple inline lambda
  → Func<T> is enough
Related Patterns
Mediator
  → everyone via broker
  → no direct refs

Strategy
  → one algorithm chosen
  → not notification

Command + EventBus
  → command triggers event
  → Observer fans out

Event Sourcing
  → persisted Observer
  → replayable

Pub/Sub (distributed)
  → Observer + broker
  → cross-process
Section 20

Deep Dive

Reactive Extensions (Rx.NET) — taking Observer to the next level. Basic Observer lets you broadcast a change to listeners. Rx gives you a whole toolbox for working with streams of data — filter out noise, batch updates, add time delays, merge multiple streams, and more. Think of it as LINQ but for events that happen over time. If you're building anything real-time, Rx is worth knowing.

Observable.Create + Operators

Observable.Create is the Swiss Army knife — it wraps any async or event-based source into an IObservable<T>The subject role in Rx. Any source of values over time. Observable.Create() lets you implement the subscription logic directly — you decide what happens when someone subscribes, what values to push via observer.OnNext(), and how to clean up when they unsubscribe (return an IDisposable from the factory). This is the low-level building block — most production code uses higher-level operators. pipeline. Then operators transform the stream:

RxCreateAndOperators.cs
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects;

// ─── Observable.Create — wrap any source ───
IObservable<decimal> stockFeed = Observable.Create<decimal>(observer =>
{
    var timer = new Timer(_ => observer.OnNext(GetCurrentPrice()), null, 0, 1000);
    // Return IDisposable — called when subscriber unsubscribes
    return Disposable.Create(() =>
    {
        timer.Dispose();
        Console.WriteLine("Subscriber disconnected — timer stopped");
    });
});

// ─── Operators: composable, chainable ───
var processedFeed = stockFeed
    .Where(price => price > 0)                              // filter invalid
    .Select(price => Math.Round(price, 2))                   // project/transform
    .DistinctUntilChanged()                                  // skip repeated same value
    .Throttle(TimeSpan.FromMilliseconds(500))                // wait for 500ms quiet
    .Buffer(count: 10)                                       // batch into groups of 10
    .Select(batch => batch.Average());                       // average each batch

// Subscribe — clean IDisposable lifetime
using var subscription = processedFeed.Subscribe(
    onNext:      avg => Console.WriteLine($"5-min avg: {avg:C}"),
    onError:     ex  => Console.Error.WriteLine($"Error: {ex.Message}"),
    onCompleted: ()  => Console.WriteLine("Feed closed")
);

// ─── Real usage: search-as-you-type debounce ───
IObservable<string> searchInput = ...; // from TextChanged event

var results = searchInput
    .Where(text => text.Length >= 3)           // skip short queries
    .Throttle(TimeSpan.FromMilliseconds(300))   // wait for typing pause
    .DistinctUntilChanged()                     // skip if same as last search
    .SelectMany(query =>                        // flatten async results
        Observable.FromAsync(() => SearchApi.SearchAsync(query)));

results.Subscribe(r => UpdateSearchResults(r));

The killer feature: each operator is composable and returns a new IObservable<T>. You can test each piece in isolation, replace operators without touching consumers, and combine multiple streams with Merge, Zip, CombineLatest.

This is the concept that trips up most developers new to Rx. The difference fundamentally changes how you share observables:

HotVsCold.cs
// ─── COLD Observable ───
// Each subscriber gets its own independent execution
// Like a YouTube video — each viewer starts from the beginning
var cold = Observable.Create<int>(observer =>
{
    Console.WriteLine("New subscription started!"); // runs for EACH subscriber
    observer.OnNext(1);
    observer.OnNext(2);
    observer.OnNext(3);
    observer.OnCompleted();
    return Disposable.Empty;
});

cold.Subscribe(x => Console.WriteLine($"Sub1: {x}"));
// Output: "New subscription started!" then 1, 2, 3
cold.Subscribe(x => Console.WriteLine($"Sub2: {x}"));
// Output: "New subscription started!" again — independent stream!

// ─── HOT Observable ───
// All subscribers share the SAME stream — late subscribers miss past events
// Like live TV — tune in late and you miss what aired
var subject = new Subject<int>(); // Subject is always hot

subject.OnNext(1); // nobody subscribed — lost!
subject.Subscribe(x => Console.WriteLine($"Sub1: {x}"));
subject.OnNext(2); // Sub1 receives
subject.Subscribe(x => Console.WriteLine($"Sub2: {x}"));
subject.OnNext(3); // BOTH Sub1 and Sub2 receive

// ─── Turning Cold into Hot (Share/Publish) ───
// Prevent HTTP call from running once per subscriber
var shared = Observable
    .FromAsync(() => httpClient.GetStringAsync("/api/data"))
    .Replay(1)    // cache last result for late subscribers
    .RefCount();  // auto-connect when first sub arrives, auto-disconnect when last leaves

// All subscribers share ONE HTTP call
var sub1 = shared.Subscribe(data => ProcessForUser1(data));
var sub2 = shared.Subscribe(data => ProcessForUser2(data)); // no second HTTP call!

// ─── Special Subjects ───
// BehaviorSubject — hot, replays last value to new subscribers
var behavior = new BehaviorSubject<int>(initialValue: 0);
behavior.OnNext(42);
behavior.Subscribe(x => Console.WriteLine(x)); // immediately prints: 42

// ReplaySubject — hot, replays last N values
var replay = new ReplaySubject<int>(bufferSize: 3);
replay.OnNext(10); replay.OnNext(20); replay.OnNext(30); replay.OnNext(40);
replay.Subscribe(x => Console.WriteLine(x)); // immediately prints: 20, 30, 40
The One-Line Rule

Cold = each subscription starts fresh. Hot = all subscriptions share a running stream. Most event-based sources (button clicks, price feeds, sensor data) are naturally hot. Most Observable.FromAsyncCreates a cold observable from a Func<Task<T>>. Each subscriber triggers a new async operation. This means if 3 components subscribe, you get 3 separate HTTP requests. Use .Replay(1).RefCount() to share a single execution across all subscribers. calls are cold by default.

Schedulers control where and when your Rx code runs. This is how you solve the "wrong thread" problem cleanly:

RxSchedulers.cs
// ─── Built-in Schedulers ───
// TaskPoolScheduler  — ThreadPool (for CPU-bound work)
// NewThreadScheduler — dedicated thread (long-running)
// ImmediateScheduler — caller's thread (synchronous)
// CurrentThreadScheduler — queues on current thread
// (WPF/MAUI) DispatcherScheduler — UI thread

// ─── SubscribeOn vs ObserveOn ───
stockPriceFeed
    .SubscribeOn(TaskPoolScheduler.Default)  // subscription work on thread pool
    .ObserveOn(DispatcherScheduler.Current)  // OnNext callbacks on UI thread
    .Subscribe(price => PriceLabel.Content = price.ToString("C"));
// Pattern: heavy work on pool, UI updates on dispatcher

// ─── Time-based operators use schedulers ───
Observable.Interval(TimeSpan.FromSeconds(1), Scheduler.Default)
    .ObserveOn(RxApp.MainThreadScheduler) // ReactiveUI's scheduler
    .Subscribe(tick => UpdateClock(tick));

// ─── TestScheduler for unit testing time-based code ───
var scheduler = new TestScheduler();

var result = new List<long>();
Observable.Interval(TimeSpan.FromSeconds(1), scheduler)
    .Take(3)
    .Subscribe(result.Add);

scheduler.AdvanceByMs(3500); // simulate 3.5 seconds passing instantly
Assert.Equal(3, result.Count); // [0, 1, 2] — no real waiting in tests!

// ─── Production pattern: inject scheduler for testability ───
public class LiveStockService(IScheduler? scheduler = null)
{
    private IScheduler Scheduler => scheduler ?? TaskPoolScheduler.Default;

    public IObservable<decimal> GetPriceStream() =>
        Observable.Interval(TimeSpan.FromMilliseconds(100), Scheduler)
            .Select(_ => FetchCurrentPrice());
}

TestSchedulerA virtual time scheduler for unit testing. AdvanceBy(ticks) or AdvanceByMs(ms) simulates time passing without actual waiting. This means you can test a 1-hour Throttle operator in milliseconds. Always inject IScheduler as a dependency so tests can pass TestScheduler — never hardcode Scheduler.Default in production Rx code. is the secret weapon for testing time-based operators. Never hardcode Scheduler.Default — inject it so tests can substitute TestScheduler.

Error handling in Rx is more nuanced than try-catch — you can recover, retry, or fall back within the pipeline:

RxErrorHandling.cs
// ─── OnError terminates the stream — subscriber receives it ───
source.Subscribe(
    onNext:  v  => Process(v),
    onError: ex => logger.LogError(ex, "Stream failed") // stream ENDS here
);

// ─── Catch — replace errored stream with fallback ───
source
    .Catch<decimal, HttpRequestException>(ex =>
    {
        logger.LogWarning(ex, "HTTP failed — using cache");
        return _cachedPrices; // fallback observable
    })
    .Subscribe(price => UpdateUI(price));

// ─── Retry — resubscribe N times on error ───
Observable.FromAsync(() => httpClient.GetAsync("/api/prices"))
    .Retry(retryCount: 3) // retry up to 3 times immediately
    .Subscribe(/* ... */);

// ─── RetryWhen — retry with delay (exponential backoff) ───
source
    .RetryWhen(errors => errors
        .Zip(Observable.Range(1, 4), (error, attempt) => (error, attempt))
        .SelectMany(tuple =>
        {
            if (tuple.attempt > 3) throw tuple.error; // give up after 3
            var delay = TimeSpan.FromMilliseconds(100 * Math.Pow(2, tuple.attempt));
            return Observable.Timer(delay); // wait before retry
        }))
    .Subscribe(/* ... */);

// ─── OnErrorResumeNext — skip errored sequences ───
var s1 = source1.Select(v => v);
var s2 = source2.Select(v => v);
// If s1 fails, immediately continue with s2 — no gap in output
Observable.OnErrorResumeNext(s1, s2)
    .Subscribe(v => Process(v));

// ─── Materialize — convert errors to notifications (no stream termination) ───
source
    .Materialize() // wraps each event in Notification<T>
    .Subscribe(notification =>
    {
        if (notification.Kind == NotificationKind.OnError)
            logger.LogError(notification.Exception, "Handled error in stream");
        else if (notification.Kind == NotificationKind.OnNext)
            Process(notification.Value);
        // Stream keeps running — errors don't terminate it!
    });
Critical: OnError Terminates the Stream

Unlike exceptions in a loop, OnError is terminal — after an error, the subject will never call OnNext again. If you need resilience, use Catch, Retry, or Materialize to prevent termination. Most production Rx pipelines end with a .Catch(ex => Observable.Empty<T>()) or a retry policy so a single network hiccup doesn't kill the entire live feed.

Section 21

Mini-Project: Real-Time Stock Ticker Dashboard

Let's build a real-time stock dashboard — but we'll do it three times. The first attempt is simple (and broken). Each version fixes the problems of the last one. By the third attempt, you'll have production-quality Observer code and understand exactly why each improvement matters.

Attempt 1: Naive (Broken)

Problems: polling, tight coupling, no cleanup, no async
NaiveDashboard.cs
// Attempt 1: Polling loop — Observer pattern? Never heard of it.
public class NaiveDashboard
{
    private readonly StockApiClient _client = new(); // hardcoded dependency
    private readonly List<string> _tickers = ["AAPL", "MSFT", "GOOGL"];

    // Tight coupling: dashboard knows about EVERY display it needs to update
    private readonly PriceLabel _appleLabel = new();
    private readonly PriceLabel _msftLabel  = new();
    private readonly PriceLabel _googLabel  = new();
    private readonly AlertPanel _alerts     = new();
    private readonly LogFile    _log        = new();

    // Polling loop — blocks a thread, burns CPU, adds latency
    public async Task StartPollingAsync()
    {
        while (true)
        {
            foreach (var ticker in _tickers)
            {
                var price = await _client.GetPriceAsync(ticker); // HTTP on every tick

                // Hardcoded cascade: adding a new consumer = modify THIS method
                if (ticker == "AAPL") _appleLabel.Update(price);
                if (ticker == "MSFT") _msftLabel.Update(price);
                if (ticker == "GOOGL") _googLabel.Update(price);
                _alerts.Check(ticker, price);
                _log.Write(ticker, price);
            }

            await Task.Delay(1000); // arbitrary 1 second — not event-driven
        }
    }
    // Problems:
    // 1. Polling = latency + wasted requests (no data = still polls)
    // 2. Adding "PortfolioWidget" requires editing StartPollingAsync
    // 3. No unsubscription — runs forever, leaks indefinitely
    // 4. StockApiClient is new'd inside — untestable, no DI
    // 5. Thread-safety? What thread-safety?
}
Better: using events. But memory leaks, no async, no backpressure, no error handling.
EventsDashboard.cs
// Attempt 2: C# events — proper Observer, but gaps remain
public record PriceUpdate(string Ticker, decimal Price);

public class StockFeed(IStockApiClient client, string[] tickers) // DI injected — testable!
{
    public event Action<PriceUpdate>? PriceChanged;

    public async Task StartAsync(CancellationToken ct)
    {
        // Still polling, but now event-driven notification
        while (!ct.IsCancellationRequested)
        {
            foreach (var ticker in tickers)
            {
                var price = await client.GetPriceAsync(ticker, ct);
                PriceChanged?.Invoke(new PriceUpdate(ticker, price));
            }
            await Task.Delay(1000, ct);
        }
    }
}

// Consumers subscribe to the event — decoupled from StockFeed!
public class PriceDisplayWidget
{
    private readonly StockFeed _feed;

    public PriceDisplayWidget(StockFeed feed)
    {
        _feed = feed;
        _feed.PriceChanged += OnPriceChanged; // subscribe

        // BUG: never unsubscribes — memory leak if widget is disposed
    }

    private void OnPriceChanged(PriceUpdate update)
    {
        // BUG: may be on background thread — UI crash in WPF/MAUI
        PriceLabel.Text = $"{update.Ticker}: {update.Price:C}";
    }

    // Missing: Dispose() with PriceChanged -= OnPriceChanged
}

// Gaps remaining:
// 1. Memory leak — no Dispose() + unsubscribe on widgets
// 2. Thread issue — PriceChanged fires on StockFeed's thread
// 3. Async observer — can't await inside event handler
// 4. No backpressure — 1000 updates/sec? Widget handles all of them
// 5. No error propagation — exception in handler = unobserved
Production: IObservable<T>, async, DI, proper lifetime, backpressure, tests
StockFeed.cs
public record PriceUpdate(string Ticker, decimal Price, DateTimeOffset At);

public interface IStockFeed
{
    IObservable<PriceUpdate> GetPriceStream(string ticker);
    IObservable<PriceUpdate> GetAllPriceStream();
}

public sealed class StockFeed(
    IStockApiClient client,
    ILogger<StockFeed> logger,
    IScheduler? scheduler = null) : IStockFeed
{
    private IScheduler Scheduler => scheduler ?? TaskPoolScheduler.Default;

    // Cold observable — starts subscription per subscriber
    // .Publish().RefCount() makes it hot + shared (one WebSocket per ticker)
    public IObservable<PriceUpdate> GetPriceStream(string ticker)
        => Observable
            .Interval(TimeSpan.FromMilliseconds(500), Scheduler)
            .SelectMany(_ => Observable.FromAsync(ct => client.GetPriceAsync(ticker, ct)))
            .Select(price => new PriceUpdate(ticker, price, DateTimeOffset.UtcNow))
            .Do(_ => { }, ex => logger.LogError(ex, "Price feed error for {Ticker}", ticker))
            .Retry(3)
            .DistinctUntilChanged(u => u.Price)  // only emit on actual price change
            .Publish()                             // hot — all subscribers share one stream
            .RefCount();                           // connect when 1st subscriber, disconnect on last

    public IObservable<PriceUpdate> GetAllPriceStream()
    {
        var tickers = new[] { "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA" };
        return tickers
            .Select(GetPriceStream)
            .Merge();  // combine all ticker streams into one
    }
}
DashboardWidget.cs
public sealed class PriceDashboardViewModel : IDisposable
{
    private readonly CompositeDisposable _subscriptions = new();
    public ObservableCollection<PriceRow> Prices { get; } = new();
    public ObservableCollection<string> Alerts { get; } = new();

    public PriceDashboardViewModel(IStockFeed feed, IScheduler? uiScheduler = null)
    {
        var ui = uiScheduler ?? DispatcherScheduler.Current;

        // Main price grid — throttled, on UI thread
        _subscriptions.Add(
            feed.GetAllPriceStream()
                .Throttle(TimeSpan.FromMilliseconds(100))  // max 10 updates/sec per ticker
                .ObserveOn(ui)                              // marshal to UI thread
                .Subscribe(
                    update => UpsertPrice(update),
                    ex => Alerts.Add($"Feed error: {ex.Message}")
                )
        );

        // Alert stream — only big movers (>2% change)
        _subscriptions.Add(
            feed.GetAllPriceStream()
                .GroupBy(u => u.Ticker)
                .SelectMany(group => group
                    .Buffer(2, 1).Where(b => b.Count == 2) // consecutive pairs
                    .Where(b => Math.Abs((b[1].Price - b[0].Price)
                                   / b[0].Price) > 0.02m)
                    .Select(b => $"{b[1].Ticker}: {b[0].Price:C} → {b[1].Price:C}"))
                .ObserveOn(ui)
                .Subscribe(alert => Alerts.Insert(0, alert))
        );
    }

    private void UpsertPrice(PriceUpdate update)
    {
        var existing = Prices.FirstOrDefault(r => r.Ticker == update.Ticker);
        if (existing != null) existing.Update(update);
        else Prices.Add(new PriceRow(update));
    }

    public void Dispose() => _subscriptions.Dispose(); // all subscriptions cleaned up
}

public class PriceRow(PriceUpdate initial) : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    public string Ticker { get; } = initial.Ticker;
    private decimal _price = initial.Price;
    public decimal Price { get => _price; private set { _price = value; OnChanged(); } }

    public void Update(PriceUpdate u) => Price = u.Price;
    private void OnChanged() => PropertyChanged?.Invoke(this, new(nameof(Price)));
}
Program.cs
var builder = Host.CreateApplicationBuilder(args);

// Register infrastructure
builder.Services.AddHttpClient<IStockApiClient, AlphaVantageClient>(client =>
{
    client.BaseAddress = new Uri("https://www.alphavantage.co/");
    client.Timeout = TimeSpan.FromSeconds(5);
});

// Register the feed — singleton so all ViewModels share the same live stream
builder.Services.AddSingleton<IStockFeed, StockFeed>();

// Register ViewModel — transient (each window gets its own)
builder.Services.AddTransient<PriceDashboardViewModel>();

var app = builder.Build();

// DI creates everything — ViewModel gets IStockFeed injected
using var scope = app.Services.CreateScope();
var vm = scope.ServiceProvider.GetRequiredService<PriceDashboardViewModel>();

// In WPF: DataContext = vm
// In MAUI: BindingContext = vm
// In Blazor: @inject PriceDashboardViewModel

// Cleanup is automatic when scope disposes:
// scope.Dispose() → vm.Dispose() → CompositeDisposable.Dispose() → all Rx subs disposed
DashboardTests.cs
public class PriceDashboardViewModelTests
{
    [Fact]
    public void PriceUpdate_AppearsInPricesCollection()
    {
        // Arrange: use TestScheduler for deterministic timing
        var scheduler = new TestScheduler();
        var mockFeed = new Mock<IStockFeed>();
        var subject = new Subject<PriceUpdate>();
        mockFeed.Setup(f => f.GetAllPriceStream()).Returns(subject.AsObservable());

        using var vm = new PriceDashboardViewModel(mockFeed.Object, scheduler);

        // Act: push a price update
        subject.OnNext(new PriceUpdate("AAPL", 150m, DateTimeOffset.UtcNow));
        scheduler.AdvanceByMs(200); // advance past 100ms throttle

        // Assert
        Assert.Single(vm.Prices);
        Assert.Equal("AAPL", vm.Prices[0].Ticker);
        Assert.Equal(150m, vm.Prices[0].Price);
    }

    [Fact]
    public void BigMover_AppearsInAlerts()
    {
        var scheduler = new TestScheduler();
        var mockFeed = new Mock<IStockFeed>();
        var subject = new Subject<PriceUpdate>();
        mockFeed.Setup(f => f.GetAllPriceStream()).Returns(subject.AsObservable());

        using var vm = new PriceDashboardViewModel(mockFeed.Object, scheduler);

        subject.OnNext(new PriceUpdate("TSLA", 200m, DateTimeOffset.UtcNow));
        subject.OnNext(new PriceUpdate("TSLA", 210m, DateTimeOffset.UtcNow)); // 5% move
        scheduler.AdvanceByMs(200);

        Assert.Single(vm.Alerts);
        Assert.Contains("TSLA", vm.Alerts[0]);
    }

    [Fact]
    public void Dispose_StopsAllSubscriptions_NoFurtherUpdates()
    {
        var mockFeed = new Mock<IStockFeed>();
        var subject = new Subject<PriceUpdate>();
        mockFeed.Setup(f => f.GetAllPriceStream()).Returns(subject.AsObservable());

        var vm = new PriceDashboardViewModel(mockFeed.Object);
        subject.OnNext(new PriceUpdate("MSFT", 300m, DateTimeOffset.UtcNow));

        vm.Dispose(); // unsubscribe everything

        subject.OnNext(new PriceUpdate("MSFT", 350m, DateTimeOffset.UtcNow));
        // Price did NOT update after dispose
        Assert.Equal(300m, vm.Prices.FirstOrDefault(p => p.Ticker == "MSFT")?.Price ?? 300m);
    }
}
What Attempt 3 Gets Right
  • No memory leaks — CompositeDisposable owns all subscriptions; Dispose() cleans everything
  • Thread safety — ObserveOn(ui) marshals all UI updates to the correct thread
  • Backpressure — Throttle(100ms) caps UI update rate at 10/sec regardless of feed speed
  • Testable — TestScheduler makes time-based operators deterministic; Mock<IStockFeed> provides fake data
  • Composable — Alert stream and price stream are independent pipelines; adding a new feature = new subscription chain
  • Resilient — Retry(3) on the feed handles transient network failures without crashing the stream
Section 22

Migration Guide: Refactoring Tight Coupling to Observer

You're staring at a method that ends with 6 lines of "after state change, notify these things." Each line is a hardcoded dependency. Here's how to peel those apart — step by step — without breaking anything.

Starting Point — The "Notification Smell"
Before.cs
// The classic tight-coupling smell: state changes + hardcoded notification cascade
public class OrderService(
    IOrderRepository repo,
    IEmailService email,       // ← direct dependency
    IAuditLog audit,           // ← direct dependency
    IInventoryService inv,     // ← direct dependency
    IAnalyticsService analytics) // ← direct dependency
{
    public async Task PlaceOrderAsync(Order order)
    {
        await repo.SaveAsync(order);

        // Hardcoded notification cascade — every new consumer = modify this class
        await email.SendConfirmationAsync(order);    // violation of OCP
        await audit.LogAsync("OrderPlaced", order);  // adding a 5th consumer
        await inv.DeductStockAsync(order);           // = editing this method
        await analytics.TrackAsync("order_placed", order); // = testing everything again
    }
}

Step 1 Identify the Notification Smell

Look for these signs: method calls after a state change that aren't about the core operation, different teams owning different parts of the cascade, growing parameter list with unrelated services, and tests that require mocking 5+ dependencies. All 4 lines after repo.SaveAsync are notifications — none of them are "placing the order." That's the smell.

Identification Checklist
  • Does the method do one core thing + then call N other services?
  • Would adding a new "side effect" require modifying this class?
  • Do the post-action calls belong to different teams/bounded contexts?
  • Is the constructor growing with unrelated dependencies?

If 2+ boxes are checked: this is a candidate for Observer refactoring.

Step2-ExtractInterface.cs
// Step 2: Define the event and the handler interface
// Use a domain event record — immutable, descriptive
public record OrderPlacedEvent(
    Guid OrderId,
    string CustomerId,
    decimal Total,
    IReadOnlyList<OrderLine> Lines,
    DateTimeOffset PlacedAt);

// The Observer interface — one focused method
public interface IOrderEventHandler
{
    Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct = default);
}

// Now migrate each hardcoded call to a handler:
public sealed class OrderConfirmationEmailHandler(IEmailService email) : IOrderEventHandler
{
    public Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct)
        => email.SendConfirmationAsync(evt.CustomerId, evt.OrderId, ct);
}

public sealed class OrderAuditHandler(IAuditLog audit) : IOrderEventHandler
{
    public Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct)
        => audit.LogAsync("OrderPlaced", evt, ct);
}

public sealed class InventoryDeductionHandler(IInventoryService inv) : IOrderEventHandler
{
    public Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct)
        => inv.DeductStockAsync(evt.Lines, ct);
}

public sealed class AnalyticsTrackingHandler(IAnalyticsService analytics) : IOrderEventHandler
{
    public Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct)
        => analytics.TrackAsync("order_placed", evt.Total, ct);
}
Step3-Dispatcher.cs
// The dispatcher IS the Subject — auto-discovers handlers via DI
public class OrderEventDispatcher(IEnumerable<IOrderEventHandler> handlers)
{
    private readonly List<IOrderEventHandler> _handlers = handlers.ToList();

    public async Task DispatchAsync(OrderPlacedEvent evt, CancellationToken ct = default)
    {
        // Resilient loop — one failing handler doesn't affect others
        var tasks = _handlers.Select(async h =>
        {
            try { await h.HandleAsync(evt, ct); }
            catch (Exception ex)
            {
                // Log and continue — don't let email failure block analytics
                Console.Error.WriteLine($"Handler {h.GetType().Name} failed: {ex.Message}");
            }
        });
        await Task.WhenAll(tasks); // all handlers run in parallel
    }
}
Step4-After.cs
// Program.cs — DI wires everything
builder.Services.AddScoped<IOrderEventHandler, OrderConfirmationEmailHandler>();
builder.Services.AddScoped<IOrderEventHandler, OrderAuditHandler>();
builder.Services.AddScoped<IOrderEventHandler, InventoryDeductionHandler>();
builder.Services.AddScoped<IOrderEventHandler, AnalyticsTrackingHandler>();

// Dispatcher auto-discovers handlers via IEnumerable injection
builder.Services.AddScoped<OrderEventDispatcher>();

// ─── AFTER: OrderService is clean ───
public class OrderService(
    IOrderRepository repo,
    OrderEventDispatcher dispatcher)   // ← only TWO dependencies now
{
    public async Task PlaceOrderAsync(Order order, CancellationToken ct = default)
    {
        await repo.SaveAsync(order, ct);

        // Single notification call — open/closed: add consumers with zero changes here
        await dispatcher.DispatchAsync(
            new OrderPlacedEvent(order.Id, order.CustomerId, order.Total,
                                 order.Lines, DateTimeOffset.UtcNow), ct);
    }
}

// Adding a 5th consumer: create new handler class + one DI registration line.
// OrderService? Untouched. Tests? Unaffected. Other handlers? Unaffected.
Migration Complete — What Changed
BeforeAfter
5 constructor params in OrderService2 constructor params
Adding consumer = modify OrderServiceAdding consumer = new file + 1 DI line
Test requires mocking email, audit, inv, analyticsTest mocks only IOrderRepository + dispatcher
Email failure blocks analyticsAll handlers run in parallel, independently
Tight coupling to infrastructureOrderService knows nothing about email/inventory
Tip: Consider using MediatRMediatR is a popular .NET mediator library by Jimmy Bogard. Its INotification + INotificationHandler<T> pair is exactly this Observer pattern — baked into a NuGet package with DI integration, pipeline behaviors (middleware), and support for both sync and async handlers. It saves you from writing the dispatcher class yourself. for production apps — it provides exactly this Observer dispatcher as a battle-tested NuGet package. INotification is your domain event. INotificationHandler<T> is your IOrderEventHandler. IMediator.Publish() is your dispatcher. Zero boilerplate needed.
Section 23

Code Review Checklist

Run every Observer implementation through these 12 checks before it ships. Print this out. Tape it above your monitor. Your future self will thank you at 2am when production isn't leaking memory.

#CheckWhyRed Flag
1 Every subscribe has a matching unsubscribe Unmatched subscriptions = memory leaksThe subject holds a strong reference to the observer via the delegate/handler. If the observer never unsubscribes, it can never be garbage collected — even if all other references are gone. This is the #1 Observer bug in .NET apps. — subject holds a reference to every observer forever += in constructor with no IDisposable on the class
2 Observer list is thread-safe Concurrent subscribe/notify causes InvalidOperationException or lost observers Raw List<T> with no lock, no snapshot, no immutable swap
3 Notifications resilient to observer exceptions One throwing observer stops all subsequent observers from being notified No try-catch in the notification loop
4 Notify iterates a snapshot, not the live list Observer calling Unsubscribe() inside Update() modifies the collection mid-iteration foreach (var o in _observers) without .ToList() snapshot
5 Observers do minimal work in Update/OnNext Synchronous notification loop — one slow observer blocks all subsequent ones await db.InsertAsync() or .Wait() inside an event handler
6 No circular observer chains A observes B observes A = infinite recursion → StackOverflowException Observer's Update() calls a method that triggers re-notification
7 UI observers marshal to correct thread WPF/MAUI crash: InvalidOperationException on cross-thread UI access Label.Text = value directly in event handler without Dispatcher
8 Observer list is not publicly exposed External code can bypass validation, locking, and logging in Subscribe() public List<IObserver> Observers { get; }
9 Async observers use correct pattern async void swallows exceptions; .Wait() deadlocks on UI thread async void OnEvent() without try-catch, or .Result in handler
10 Event payloadThe data passed to observers with each notification. Use immutable records — observers can't accidentally mutate shared state. Include enough context so observers don't need to call back on the subject. Include a timestamp and correlation ID for tracing. is immutable Mutable payload + parallel observers = data race Passing a mutable entity directly instead of a record
11 Static events reviewed carefully Static events live for the app lifetime — instance subscribers leak forever Static event without documented lifetime + IDisposable requirement
12 Each observer has unit tests Observers are easy to forget in test coverage — "they're just called by events" 0% coverage on observer classes; no unsubscribe test
Automate with Roslyn Analyzers. Many of these checks can be caught at compile time. RoslynThe .NET compiler platform. Roslyn analyzers inspect your code at compile time and emit warnings/errors for patterns they detect. You can use built-in analyzers (CA rules) or write custom ones. They run in the IDE and in CI — catching bugs before they reach code review. analyzers like CA2000 (Dispose objects before losing scope) catch check #1. For Observer-specific checks, consider writing a custom analyzer that warns when += appears without a matching -= in the same class, or when IObservable.Subscribe() returns an IDisposable that isn't stored. Libraries like ErrorProne.NET and Roslynator cover many common Observer pitfalls out of the box.