Iterator vs Observer Pattern in C#: Key Differences Explained
The iterator pattern and the observer pattern represent two fundamentally different philosophies for how consumers interact with data. One pulls. The other pushes. That distinction sounds simple, but it shapes everything from control flow to resource management to error handling in your applications. Understanding the iterator vs observer pattern in C# is critical for choosing the right data access model -- and avoiding designs that fight against the nature of your data source.
In this article, we'll break down each pattern individually with C# code examples, compare them side by side using a sensor data scenario, explore how .NET models this duality through IEnumerable<T> and IObservable<T>, and give you clear decision criteria for when to reach for each approach.
Iterator Pattern: Pull-Based Data Access
The iterator pattern gives the consumer full control. The consumer decides when to ask for the next item, how fast to consume, and when to stop. Data flows on the consumer's schedule -- not the producer's. This is what "pull-based" means: the consumer reaches in and pulls items out one at a time.
In C#, the iterator pattern is built into the language through IEnumerable<T> and IEnumerator<T>. Every time you write a foreach loop, you're using the iterator pattern. The enumerator exposes MoveNext() and Current -- the consumer calls MoveNext() to advance, reads Current to get the value, and can bail out of the loop at any point.
Here's a practical example -- a temperature sensor that yields readings on demand:
using System;
using System.Collections.Generic;
public class TemperatureSensor
{
private readonly Random _random = new(42);
public IEnumerable<double> GetReadings(int count)
{
for (int i = 0; i < count; i++)
{
// Simulate sensor read with
// some variance around 22°C
double reading = 20.0 + _random.NextDouble() * 4.0;
yield return Math.Round(reading, 2);
}
}
}
The consumer pulls readings at its own pace:
var sensor = new TemperatureSensor();
foreach (double temp in sensor.GetReadings(5))
{
Console.WriteLine($"Reading: {temp}°C");
if (temp > 23.0)
{
Console.WriteLine("Threshold exceeded!");
break;
}
}
There are several important characteristics to notice here. The consumer controls the pace entirely -- each call to MoveNext() behind the foreach triggers the next yield return. Lazy evaluation means readings aren't generated until the consumer asks for them, so if the consumer breaks early after three readings, the remaining two are never computed. The consumer also controls termination with break, and the enumerator's Dispose method cleans up the iterator state.
This pull model is ideal when the consumer knows what it wants and can process items at its own speed. It maps naturally to batch processing, file reading, and database queries where the consuming code drives the interaction.
Observer Pattern: Push-Based Data Access
The observer pattern flips the relationship. The producer decides when data is available and pushes it to all registered subscribers. Consumers don't ask for data -- they register interest and wait. When something happens, the producer notifies everyone who's listening.
This is push-based: the producer reaches out to consumers, not the other way around. Consumers react to data as it arrives rather than polling for it. The producer controls the pace, and consumers must be ready to handle whatever arrives.
In C#, you can implement the observer pattern through events, delegates, or the IObservable<T>/IObserver<T> interfaces. Here's the same temperature sensor concept, but push-based using IObservable<T>:
using System;
using System.Collections.Generic;
using System.Threading;
public class TemperatureSensorObservable
: IObservable<double>
{
private readonly List<IObserver<double>> _observers = new();
private readonly Random _random = new(42);
public IDisposable Subscribe(
IObserver<double> observer)
{
_observers.Add(observer);
return new Unsubscriber(_observers, observer);
}
public void StartEmitting(int count)
{
for (int i = 0; i < count; i++)
{
double reading = 20.0
+ _random.NextDouble() * 4.0;
reading = Math.Round(reading, 2);
foreach (var observer in _observers)
observer.OnNext(reading);
Thread.Sleep(500);
}
foreach (var observer in _observers)
observer.OnCompleted();
}
private sealed class Unsubscriber : IDisposable
{
private readonly List<IObserver<double>> _observers;
private readonly IObserver<double> _observer;
public Unsubscriber(
List<IObserver<double>> observers,
IObserver<double> observer)
{
_observers = observers;
_observer = observer;
}
public void Dispose() =>
_observers.Remove(_observer);
}
}
Consumers subscribe and react:
using System;
public class TemperatureDisplay : IObserver<double>
{
private readonly string _name;
public TemperatureDisplay(string name) =>
_name = name;
public void OnNext(double value) =>
Console.WriteLine(
$"[{_name}] Reading: {value}°C");
public void OnError(Exception error) =>
Console.WriteLine(
$"[{_name}] Error: {error.Message}");
public void OnCompleted() =>
Console.WriteLine(
$"[{_name}] Sensor stream completed");
}
Usage looks like this:
var sensor = new TemperatureSensorObservable();
var display1 = new TemperatureDisplay("Main");
var display2 = new TemperatureDisplay("Backup");
sensor.Subscribe(display1);
sensor.Subscribe(display2);
sensor.StartEmitting(5);
The producer calls StartEmitting and pushes readings to all subscribers without waiting for permission. Subscribers can't slow the producer down or skip ahead -- they process what arrives, when it arrives. This push model is the foundation of event-driven and reactive programming, and you can explore the full depth of the observer design pattern in C# for more implementation approaches.
Side-by-Side Comparison: Sensor Data Readings
The clearest way to understand the iterator vs observer pattern in C# is to see both patterns solving the same problem. Let's model a sensor monitoring system using both approaches.
Iterator Approach: Polling for Readings
With the iterator pattern, the monitoring system polls the sensor and processes each reading at its own pace:
using System;
using System.Collections.Generic;
public class SensorPoller
{
public IEnumerable<double> PollReadings(
TemperatureSensor sensor, int count)
{
foreach (double reading in
sensor.GetReadings(count))
{
yield return reading;
}
}
}
// Consumer drives the interaction
var sensor = new TemperatureSensor();
var poller = new SensorPoller();
foreach (double reading in
poller.PollReadings(sensor, 10))
{
Console.WriteLine($"Polled: {reading}°C");
// Consumer can pause, skip, or stop
if (reading > 23.5)
{
Console.WriteLine("Alert threshold hit");
break;
}
}
The consumer decides the timing. It can add delays between reads, skip items, or stop entirely. The sensor doesn't produce anything until asked. This is natural backpressure -- the consumer can never be overwhelmed because it controls the flow.
Observer Approach: Subscribing to Readings
With the observer pattern, the sensor pushes readings to all subscribers as they become available:
using System;
public class AlertMonitor : IObserver<double>
{
public void OnNext(double value)
{
if (value > 23.5)
{
Console.WriteLine(
$"ALERT: {value}°C exceeds threshold!");
}
else
{
Console.WriteLine(
$"Normal: {value}°C");
}
}
public void OnError(Exception error) =>
Console.WriteLine(
$"Sensor error: {error.Message}");
public void OnCompleted() =>
Console.WriteLine("Monitoring complete");
}
// Producer drives the interaction
var sensor = new TemperatureSensorObservable();
sensor.Subscribe(new AlertMonitor());
sensor.Subscribe(new TemperatureDisplay("Log"));
sensor.StartEmitting(10);
The sensor pushes readings to all subscribers simultaneously. The AlertMonitor and TemperatureDisplay both receive every reading without requesting them. The producer controls the pace, and subscribers react as data arrives.
Comparison Table
Here's a quick-reference comparison highlighting the iterator vs observer pattern in C# differences:
| Feature | Iterator (Pull) | Observer (Push) |
|---|---|---|
| Control flow | Consumer drives | Producer drives |
| Data delivery | On-demand, one at a time | Broadcast to all subscribers |
| Backpressure | Built-in -- consumer controls pace | None by default -- consumer must keep up |
| Resource usage | Lazy -- only produces what's consumed | Eager -- produces regardless of consumer readiness |
| Cancellation | Consumer breaks out of loop | Consumer unsubscribes via IDisposable |
| Multiple consumers | Each gets its own enumerator | All share the same stream |
| Error handling | Exceptions propagate to consumer | OnError callback to each subscriber |
| Completion signal | Enumerator returns false from MoveNext |
OnCompleted callback to each subscriber |
Both approaches solve the same data access problem but with different assumptions about who should be in charge.
IEnumerable vs IObservable: The .NET Duality
One of the most elegant aspects of .NET's type system is how it models the pull/push duality through mirror-image interfaces. This duality is at the heart of understanding the iterator vs observer pattern in C#.
IEnumerable and IEnumerator: Pull (Synchronous)
The pull side uses two interfaces. IEnumerable<T> represents a sequence you can iterate over. IEnumerator<T> represents the cursor that walks through it. The consumer calls MoveNext() to advance and reads Current to get the value. This is synchronous -- MoveNext() blocks until the next item is ready.
IEnumerable<double> readings = sensor.GetReadings(5);
// Explicit enumerator usage
using var enumerator = readings.GetEnumerator();
while (enumerator.MoveNext())
{
double value = enumerator.Current;
Console.WriteLine($"Pull: {value}°C");
}
IObservable and IObserver: Push (Asynchronous)
The push side mirrors this structure. IObservable<T> represents a source that pushes values. IObserver<T> represents the consumer that receives them. Instead of the consumer calling MoveNext(), the producer calls OnNext(). Instead of MoveNext() returning false, the producer calls OnCompleted(). Instead of exceptions propagating, the producer calls OnError().
This symmetry is deliberate. Erik Meijer, who designed Reactive Extensions, demonstrated that IObservable<T> is the mathematical dual of IEnumerable<T> -- every pull operation has a corresponding push operation with the arrows reversed.
IAsyncEnumerable: Bridging the Gap
C# 8.0 introduced IAsyncEnumerable<T>, which occupies interesting middle ground. It's pull-based like IEnumerable<T> -- the consumer controls the pace -- but asynchronous like IObservable<T> -- the producer can take time to generate each item without blocking the thread.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
public class AsyncTemperatureSensor
{
private readonly Random _random = new(42);
public async IAsyncEnumerable<double> GetReadingsAsync(
int count,
[EnumeratorCancellation]
CancellationToken cancellationToken = default)
{
for (int i = 0; i < count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
// Simulate async sensor read
await Task.Delay(500, cancellationToken);
double reading = 20.0
+ _random.NextDouble() * 4.0;
yield return Math.Round(reading, 2);
}
}
}
The consumer still drives the interaction -- but without blocking:
var asyncSensor = new AsyncTemperatureSensor();
using var cts = new CancellationTokenSource();
await foreach (double temp in
asyncSensor.GetReadingsAsync(10, cts.Token))
{
Console.WriteLine($"Async pull: {temp}°C");
if (temp > 23.5)
{
cts.Cancel();
break;
}
}
This bridges the gap between iterator and observer by providing pull semantics with non-blocking I/O. It's especially useful for scenarios where the data source is inherently asynchronous -- like HTTP streaming, gRPC server streaming, or reading from message queues -- but you still want consumer-controlled pacing.
When to Choose the Iterator Pattern
The iterator pattern is the right choice when the consumer should drive the data flow. Reach for it when these conditions apply.
Batch processing and data pipelines. When you're processing a collection of items from start to finish -- transforming, filtering, aggregating -- the iterator pattern integrates directly with LINQ. Each LINQ operator returns an IEnumerable<T>, creating composable pipelines with deferred execution. The consumer pulls items through the chain, and nothing computes until enumeration begins.
File I/O and database queries. Reading lines from a file or rows from a database result set maps naturally to pull semantics. The consumer reads one item at a time, processes it, and moves on. This keeps memory usage flat because you never need to load the entire dataset. Entity Framework and ADO.NET both expose query results as IEnumerable<T> or IAsyncEnumerable<T> for exactly this reason.
Consumer-controlled pacing. When the consumer needs to throttle, pause, or selectively process items, pull semantics make this trivial. The consumer simply stops calling MoveNext(). There's no need for buffering, flow control mechanisms, or coordination protocols -- the consumer's iteration pace is the flow control. This natural backpressure is one of the strongest arguments in the iterator vs observer pattern comparison for scenarios where the consumer might be slower than the producer.
When to Choose the Observer Pattern
The observer pattern fits when the data source should drive the flow. Reach for it when these conditions apply.
Real-time events and notifications. When data arrives unpredictably -- user clicks, WebSocket messages, hardware interrupts -- the observer pattern aligns with reality. The producer doesn't know when the next event will occur, and the consumer shouldn't waste resources polling. Events push themselves to interested parties the moment they happen. This is the core of how the observer design pattern operates in practice.
UI updates and data binding. GUI frameworks are inherently push-based. When a model changes, the UI needs to update. Polling the model for changes would be wasteful and laggy. The observer pattern -- through events, INotifyPropertyChanged, or reactive bindings -- pushes state changes to the UI layer the instant they happen. This is how WPF data binding, Blazor change detection, and most modern UI frameworks operate under the hood.
Multiple consumers for the same data. When several components need the same data simultaneously, push semantics avoid the overhead of each consumer maintaining its own enumerator. The producer broadcasts once, and all subscribers receive the update. This one-to-many broadcasting is something the iterator pattern doesn't support natively -- each foreach gets its own independent enumerator. If you're building systems where multiple consumers react to the same event stream, the observer pattern (or one of the related behavioral patterns like the command pattern) is the natural fit.
Combining Both Patterns
The iterator vs observer pattern choice isn't always binary. Many real-world systems use both patterns at different layers. A message queue consumer might use the observer pattern to receive messages as they arrive, then expose them as an IAsyncEnumerable<T> for downstream processing. A sensor system might push readings to a buffer using observer semantics, then let analytics code pull from that buffer using iterators.
The chain of responsibility pattern can also complement both approaches -- routing pulled or pushed data through a pipeline of handlers. The key is matching each layer's data flow model to the nature of its data source and consumer requirements.
Libraries like Reactive Extensions (Rx) provide operators for converting between push and pull models, bridging IObservable<T> to IEnumerable<T> and vice versa. System.Threading.Channels provides a producer-consumer bridge where writers push and readers pull asynchronously. These tools let you use push semantics where the source demands it and pull semantics where the consumer demands it.
Frequently Asked Questions
What is the main difference between iterator and observer patterns?
The main difference is the direction of control. The iterator pattern is pull-based -- the consumer requests each item and controls the pace. The observer pattern is push-based -- the producer sends data to subscribers and controls the pace. In the iterator vs observer pattern in C# comparison, this maps to IEnumerable<T> (pull) versus IObservable<T> (push). The consumer drives iteration; the producer drives observation.
Can I convert between IEnumerable and IObservable in C#?
Yes. Reactive Extensions provides ToObservable() to convert an IEnumerable<T> into a push-based stream, and ToEnumerable() to convert an IObservable<T> back into a pull-based sequence. The conversion isn't free -- ToEnumerable() buffers pushed items so the consumer can pull them, and ToObservable() pushes pulled items on a scheduler. But the conversions demonstrate the mathematical duality between the two interfaces.
Is IAsyncEnumerable a replacement for IObservable?
No. IAsyncEnumerable<T> is pull-based -- the consumer controls the pace, just asynchronously. IObservable<T> is push-based -- the producer controls the pace. They solve different problems. Use IAsyncEnumerable<T> when you want consumer-controlled async iteration (like reading from a gRPC stream). Use IObservable<T> when you want producer-driven event broadcasting (like reacting to real-time data feeds). The iterator vs observer pattern distinction applies even in async scenarios.
How does backpressure work with each pattern?
With the iterator pattern, backpressure is automatic. The consumer calls MoveNext() when it's ready, so the producer never overwhelms it. With the observer pattern, there's no built-in backpressure. If the producer pushes faster than the consumer can process, items queue up, get dropped, or cause memory pressure. Reactive Extensions provides operators like Buffer, Sample, and Throttle to manage this, but backpressure handling must be explicitly designed into observer-based systems.
When should I use events vs IObservable in C#?
C# events are a simpler implementation of the observer pattern -- great for straightforward notification scenarios within a single application layer. IObservable<T> is more powerful, supporting composition, filtering, transformation, and error handling through operators. If you need to combine multiple event streams, apply time-based operations, or compose complex reactive pipelines, IObservable<T> with Reactive Extensions is the better choice. For simple property-changed notifications or button clicks, standard C# events are sufficient.
How do iterator and observer patterns relate to the strategy pattern?
The strategy pattern is about selecting algorithms at runtime, while iterator and observer are about data access direction. However, they compose well together. You might use a strategy to determine how to process each item -- whether that item is pulled from an iterator or pushed by an observer. For example, a sensor monitoring system could use the observer pattern for data delivery and the strategy pattern for selecting which alert algorithm to apply to incoming readings.
Can I use both patterns in the same application?
Absolutely. Most non-trivial applications use both patterns at different architectural layers. A web API might use iterators for database query results and observers for WebSocket push notifications. A proxy layer might intercept push-based events and expose them as pull-based streams to downstream consumers. The iterator vs observer pattern choice is per-component, not per-application -- pick the model that matches each component's data flow requirements.
Wrapping Up Iterator vs Observer Pattern in C#
The iterator vs observer pattern in C# distinction comes down to one question: who should drive the data flow? If the consumer should control the pace -- pulling items when ready, stopping when done, processing at its own speed -- use the iterator pattern with IEnumerable<T> or IAsyncEnumerable<T>. If the producer should control the pace -- pushing events as they occur, broadcasting to multiple subscribers, reacting to real-time data -- use the observer pattern with events or IObservable<T>.
Both patterns reduce coupling between data producers and consumers. The iterator abstracts how a collection is traversed. The observer abstracts how notifications are delivered. Understanding this pull-versus-push duality helps you design systems that work with the natural flow of your data instead of fighting against it.

