1. Introduction
Imagine you have an object that does some work — for example, a button or our Worker. At the same time there are many other objects that need to react to those actions. If the Worker class hard-codes all possible "listeners", maintaining that code becomes a nightmare: any changes to the list of subscribers would require edits inside the Worker itself.
That breaks the Open/Closed Principle (OCP) and is considered bad architectural practice.
Observer pattern: the general idea
The "Observer" pattern (Observer) solves this problem. It allows a publisher object to notify any number of interested listener objects about changes without knowing anything about who those listeners are or what they do. The publisher just "broadcasts", and whoever cares reacts however they want.
Analogy: subscribing to a newsletter. The editorial team or channel (the publisher) sends a new message, and all subscribers (observers) receive it. The editorial team doesn't know who all those people are — and it doesn't need to.
Fun fact: The "Observer" is so popular it's one of the Gang of Four (GoF) design patterns.
Observer in C#: embodiment through events and delegates
In C# the "Observer" pattern is implemented "out of the box" via events and delegates. An event is an "extension point" that different handlers can subscribe to. Instead of manually maintaining a subscriber list the language event mechanism does it for you. Below we'll look at a "manual" implementation and then the events-based approach.
2. Classic Observer implementation without events
Let's see how this might look if the language had no events:
// Interface of an observer
public interface IObserver
{
void Update(string message);
}
// Publisher
public class Worker
{
private List<IObserver> observers = new List<IObserver>();
public void Subscribe(IObserver observer)
{
observers.Add(observer);
}
public void Unsubscribe(IObserver observer)
{
observers.Remove(observer);
}
public void DoWork()
{
Console.WriteLine("Worker is working...");
NotifyObservers("Work completed!");
}
private void NotifyObservers(string message)
{
foreach (var observer in observers)
{
observer.Update(message);
}
}
}
// Concrete observer
public class WorkListener : IObserver
{
public void Update(string message)
{
Console.WriteLine($"
WorkListener received message: {message}");
}
}
Initialization:
var worker = new Worker();
var listener = new WorkListener();
worker.Subscribe(listener);
worker.DoWork();
Note: Here the subscriber list (List<IObserver> observers) is managed manually, and subscribe/unsubscribe are explicit methods Subscribe/Unsubscribe.
3. Events and delegates — the "high-level" Observer implementation
We can implement the same thing more simply and elegantly using events. This is Observer in the C# style:
public class Worker
{
public event EventHandler<WorkCompletedEventArgs>? WorkCompleted;
public void DoWork()
{
Console.WriteLine("Worker is working...");
OnWorkCompleted("Work completed!");
}
protected virtual void OnWorkCompleted(string message)
{
WorkCompleted?.Invoke(this, new WorkCompletedEventArgs { Message = message });
}
}
public class WorkCompletedEventArgs : EventArgs
{
public string Message { get; set; }
}
public class WorkListener
{
public void OnWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine($"WorkListener received message: {e.Message}");
}
}
// Subscription:
var worker = new Worker();
var listener = new WorkListener();
worker.WorkCompleted += listener.OnWorkCompleted;
worker.DoWork();
Advantages of this approach:
- No need to manually maintain a subscriber list.
- You get all event capabilities: multiple subscriptions, unsubscription, lambdas.
- Security: only the publisher can raise the event.
- Loose coupling: the publisher knows nothing about the listeners.
4. How the "observer" fits into our application
Let's integrate the Observer pattern into our console app. Let Worker have any number of handlers that react to work completion differently: some write to console, some count completed jobs, some send the message "Boss! All done!".
Extending the code with examples
// Second listener-counter
public class WorkCounter
{
public int Count { get; private set; }
public void OnWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Count++;
Console.WriteLine($"Work counted. Total: {Count} completed.");
}
}
// Create objects
var worker = new Worker();
var listener = new WorkListener();
var counter = new WorkCounter();
// Both subscriptions
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted += counter.OnWorkCompleted;
// Simulate multiple works
worker.DoWork();
worker.DoWork();
// Output:
// Worker is working...
// WorkListener received message: Work completed!
// Work counted. Total: 1 completed.
// Worker is working...
// WorkListener received message: Work completed!
// Work counted. Total: 2 completed.
So you add "observers" as needed without changing a single line in the Worker code. The Worker class remains unchanged, and the system behavior is extended via subscribers.
5. Useful nuances
Real example: Observer in interfaces and GUI
The Observer pattern is the basis of all GUI frameworks. In Windows Forms or WPF a button click raises the Click event. You write handlers (observers) that react to that event — and neither your Button class nor the .NET library needs to know anything about your subscribers.
// In WPF or WinForms (roughly)
myButton.Click += (s, e) => MessageBox.Show("The user clicked the button!");
Observer in real projects
- User interface (reacting to clicks, changes, timers, etc.).
- Notification and event systems.
- Plugins for extensible systems (core raises events, extensions subscribe).
- Distributed systems and game engines (loosely-coupled reactive chains).
In short, if you need an extensible system where parts can react to changes in other parts — Observer must have!
7. Characteristics, common mistakes and how to prevent them
Potential difficulties when using Observer
Memory leaks. If a subscriber subscribes to an event but doesn't unsubscribe (especially on long-lived objects), the garbage collector can't free that object because the publisher still holds a reference to it via the event delegate. This is critical if the subscriber is no longer needed but the publisher keeps living.
Multiple subscription. If the same handler is subscribed twice, it will be called twice — you'll get duplicated actions and unexpected effects.
Exceptions in handlers. If one of the handlers throws an exception, the execution of subsequent subscribers may be interrupted. Think about handler resilience and, if needed, invoke them manually inside a try-catch so that other subscribers still run even if one fails.
Common leak pattern
flowchart LR
Publisher["Publisher
(Worker)"] -- event --> ObserverA["Listener A (Alive!)"]
Publisher -- event --> ObserverB["Listener B (Leak: forgot to unsubscribe)"]
GO TO FULL VERSION