BrandGhost
Iterator Pattern Best Practices in C#: Code Organization and Maintainability

Iterator Pattern Best Practices in C#: Code Organization and Maintainability

Iterator Pattern Best Practices in C#: Code Organization and Maintainability

Writing iterators in C# is deceptively simple. You slap yield return into a method, loop over the results with foreach, and move on. But real codebases demand more: iterators that clean up resources when abandoned, survive concurrent modifications, stream data asynchronously, and perform well under load. Iterator pattern best practices in C# cover the decisions that separate fragile iteration code from production-grade implementations -- covering yield return usage, proper disposal, collection modification safety, async streaming, performance optimization, and testing.

This guide walks through each of those areas with focused C# examples comparing naïve approaches to battle-tested ones. Whether you're building a custom collection, wrapping a database cursor, or streaming results through an API pipeline, these practices will keep your iterators predictable and maintainable. If you want a broader look at how design patterns complement each other in C# applications, the decorator pattern and template method pattern both pair naturally with iterator-based designs.

Prefer yield return Over Manual IEnumerator

The most impactful iterator pattern best practice in C# is choosing yield return over hand-rolled IEnumerator<T> implementations. The compiler generates the state machine for you, eliminating an entire category of bugs around tracking position, handling Reset(), and wiring up Dispose(). Less code means fewer places for defects to hide.

Here's a manual enumerator for filtering even numbers from a list:

using System;
using System.Collections;
using System.Collections.Generic;

// BAD: Manual IEnumerator -- verbose and error-prone
public sealed class EvenNumberEnumerator : IEnumerator<int>
{
    private readonly IReadOnlyList<int> _source;
    private int _index = -1;

    public EvenNumberEnumerator(IReadOnlyList<int> source)
    {
        _source = source
            ?? throw new ArgumentNullException(nameof(source));
    }

    public int Current => _source[_index];

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        while (++_index < _source.Count)
        {
            if (_source[_index] % 2 == 0)
            {
                return true;
            }
        }

        return false;
    }

    public void Reset() => _index = -1;

    public void Dispose() { }
}

That's roughly 40 lines for a filter. Compare it to the yield return equivalent:

using System.Collections.Generic;

// GOOD: yield return -- compiler handles the state machine
public static IEnumerable<int> GetEvenNumbers(
    IReadOnlyList<int> source)
{
    foreach (var number in source)
    {
        if (number % 2 == 0)
        {
            yield return number;
        }
    }
}

The compiler-generated version handles Dispose(), Current, and MoveNext() transitions without you writing a single line of state tracking. It's also lazy -- values are produced one at a time as the consumer iterates, which matters when the source collection is large or the filtering logic is expensive.

There is one legitimate reason to write a manual enumerator: performance-critical paths where you need a struct enumerator to avoid heap allocations. List<T> does this internally -- its GetEnumerator() returns a List<T>.Enumerator struct rather than a boxed IEnumerator<T>. We'll cover that optimization in the performance section below. For every other case, yield return should be your default.

Implement IDisposable Properly

IEnumerator<T> extends IDisposable. This means every iterator must handle cleanup, and every consumer should dispose the enumerator when iteration ends. The foreach statement does this automatically -- it calls Dispose() in a finally block even if the loop exits early via break, return, or an exception.

The iterator pattern best practice here is understanding how yield return interacts with try/finally. The compiler transforms your finally block into the enumerator's Dispose() method, so resource cleanup works correctly even when the consumer abandons iteration partway through:

using System;
using System.Collections.Generic;
using System.IO;

// GOOD: Resources are cleaned up even on early exit
public static IEnumerable<string> ReadLines(string filePath)
{
    using var reader = new StreamReader(filePath);

    while (!reader.EndOfStream)
    {
        var line = reader.ReadLine();
        if (line is not null)
        {
            yield return line;
        }
    }
}

When a consumer writes foreach (var line in ReadLines("data.csv")) and hits a break after three lines, the foreach calls Dispose() on the enumerator, which triggers the finally block generated from the using statement. The StreamReader closes cleanly.

The danger appears when consumers bypass foreach and call GetEnumerator() directly without disposing:

// BAD: No disposal -- StreamReader leaks if iteration stops early
var enumerator = ReadLines("data.csv").GetEnumerator();
enumerator.MoveNext();
var firstLine = enumerator.Current;
// enumerator is never disposed -- file handle leaks

If you're consuming an iterator manually, always wrap it in a using statement or try/finally. And if you're authoring an iterator that acquires resources, place the acquisition inside the iterator method body -- not in a constructor or factory -- so the yield return state machine manages the lifecycle. This pattern pairs well with how the composite pattern handles recursive resource traversal, where each level of the tree might open resources that need orderly cleanup.

Guard Against Collection Modification

Modifying a collection while iterating over it throws InvalidOperationException in most .NET collection types. List<T>, Dictionary<TKey, TValue>, and HashSet<T> all use internal version counters that detect structural changes during enumeration. This is deliberate -- it prevents subtle bugs where an iterator skips or double-visits elements after the underlying data shifts.

The iterator pattern best practice for handling concurrent modification depends on your use case. If consumers need a stable view of the data, take a snapshot before iterating. If they need live data, use a concurrent collection or a copy-on-write strategy.

Here's the snapshot approach:

using System;
using System.Collections.Generic;
using System.Linq;

public sealed class EventLog
{
    private readonly List<string> _entries = new();

    public void Add(string entry)
    {
        ArgumentNullException.ThrowIfNull(entry);
        _entries.Add(entry);
    }

    // GOOD: Snapshot prevents modification exceptions
    public IEnumerable<string> GetEntries()
    {
        foreach (var entry in _entries.ToList())
        {
            yield return entry;
        }
    }
}

Calling _entries.ToList() creates a shallow copy, so new entries added during iteration don't affect the enumerator. The tradeoff is memory -- you're allocating a second list. For small collections, this is negligible. For collections with millions of elements, consider whether the consumer actually needs every item or whether paging or filtering could reduce the copy size.

The live-iteration alternative uses ConcurrentBag<T>, ConcurrentQueue<T>, or ConcurrentDictionary<TKey, TValue>, which allow modification during enumeration without throwing. Their enumerators provide a point-in-time snapshot internally, but the snapshot is taken lazily rather than upfront:

using System.Collections.Concurrent;
using System.Collections.Generic;

public sealed class ConcurrentEventLog
{
    private readonly ConcurrentQueue<string> _entries = new();

    public void Add(string entry)
    {
        _entries.Enqueue(entry);
    }

    // Safe: ConcurrentQueue handles concurrent access
    public IEnumerable<string> GetEntries()
    {
        foreach (var entry in _entries)
        {
            yield return entry;
        }
    }
}

Choose snapshots when you need a guaranteed consistent view. Choose concurrent collections when you need low-latency writes during iteration and can tolerate a slightly stale read. This is similar to how the flyweight pattern manages shared state -- the right caching strategy depends on whether your consumers prioritize consistency or throughput.

Use IAsyncEnumerable for Async Iteration

When your data source involves I/O -- database queries, HTTP calls, file reads -- blocking a thread while waiting for each element wastes resources. IAsyncEnumerable<T> solves this by letting you await each element individually without buffering the entire result set into memory first.

The syntax mirrors synchronous iteration closely. Instead of foreach, you write await foreach. Instead of yield return in a method returning IEnumerable<T>, you use yield return in an async method returning IAsyncEnumerable<T>:

using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;

// Async iterator with cancellation support
public static async IAsyncEnumerable<WeatherReading> StreamReadingsAsync(
    IWeatherSensor sensor,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        var reading = await sensor.GetNextReadingAsync(
            cancellationToken);
        yield return reading;
    }
}

The [EnumeratorCancellation] attribute is an iterator pattern best practice in C# that wires the cancellation token from await foreach into the iterator method. Without it, consumers would have no way to cancel an in-flight async iteration cleanly:

using var cts = new CancellationTokenSource(
    TimeSpan.FromSeconds(30));

await foreach (var reading in StreamReadingsAsync(
    sensor, cts.Token))
{
    ProcessReading(reading);
}

Converting a synchronous iterator to an async one follows a predictable pattern. Replace IEnumerable<T> with IAsyncEnumerable<T>, replace blocking calls with their async counterparts, and add the [EnumeratorCancellation] attribute. The compiler generates an async state machine that yields control back to the caller between elements, keeping the thread free for other work.

One subtlety: IAsyncEnumerator<T> implements IAsyncDisposable, not IDisposable. The await foreach statement calls DisposeAsync() automatically, but if you're enumerating manually, use await using instead of using. This is especially relevant when your async iterator wraps a database connection or HTTP stream that requires async cleanup. The bridge pattern can be useful when you need to abstract synchronous and asynchronous iteration behind a single abstraction boundary.

Optimize for Performance

Most iterators don't need performance tuning. But when an iterator sits in a hot loop -- say, a game engine's update cycle or a data pipeline processing millions of records -- allocation pressure and virtual dispatch overhead add up. The key iterator pattern best practice for performance in C# is knowing which optimizations exist and when they're worth the complexity cost.

Struct enumerators eliminate the heap allocation that comes with boxing an IEnumerator<T>. List<T> uses this technique -- its GetEnumerator() method returns a List<T>.Enumerator struct. The foreach statement is smart enough to call GetEnumerator() by duck-typing rather than through the IEnumerable<T> interface, so the struct stays on the stack:

using System;
using System.Collections;
using System.Collections.Generic;

public sealed class FixedBuffer<T> : IEnumerable<T>
{
    private readonly T[] _items;
    private int _count;

    public FixedBuffer(int capacity)
    {
        _items = new T[capacity];
    }

    public void Add(T item)
    {
        if (_count >= _items.Length)
        {
            throw new InvalidOperationException(
                "Buffer is full.");
        }

        _items[_count++] = item;
    }

    // Returns struct enumerator -- no heap allocation
    public Enumerator GetEnumerator() => new(_items, _count);

    IEnumerator<T> IEnumerable<T>.GetEnumerator() =>
        new Enumerator(_items, _count);

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public struct Enumerator : IEnumerator<T>
    {
        private readonly T[] _items;
        private readonly int _count;
        private int _index;

        internal Enumerator(T[] items, int count)
        {
            _items = items;
            _count = count;
            _index = -1;
        }

        public readonly T Current => _items[_index];

        readonly object IEnumerator.Current => Current!;

        public bool MoveNext() => ++_index < _count;

        public void Reset() => _index = -1;

        public readonly void Dispose() { }
    }
}

The public GetEnumerator() returns the struct directly. The explicit IEnumerable<T>.GetEnumerator() implementation returns it boxed for LINQ compatibility. This dual-return pattern is exactly what List<T>, Span<T>.Enumerator, and ImmutableArray<T> use internally.

Avoiding LINQ allocations is another practical optimization. Chaining .Where().Select().ToList() creates intermediate iterators and delegate allocations. In hot paths, a hand-written foreach with an if check outperforms the equivalent LINQ chain. Profile before refactoring -- LINQ is perfectly fine for cold paths, and readability matters more than microseconds in most application code.

Span-based iteration avoids copying data entirely. If your source is an array or contiguous memory, ReadOnlySpan<T> or Memory<T> let you iterate without allocating an enumerator at all. This is the approach that high-performance serializers and parsers use, and it's worth considering for any code path that processes large buffers.

Testing Iterator Implementations

Iterators have specific failure modes that generic unit tests miss. Elements arrive in the wrong order. Empty sequences throw instead of returning gracefully. Early termination via break leaks resources. Disposal never fires. The iterator pattern best practice for testing in C# is writing targeted tests for each of these scenarios rather than relying on a single "happy path" assertion.

Test iteration order first -- it's the most common expectation and the most common source of bugs:

using System.Collections.Generic;
using System.Linq;

using Xunit;

public sealed class EvenNumberFilterTests
{
    [Fact]
    public void GetEvenNumbers_MixedInput_ReturnsEvensInOrder()
    {
        var source = new List<int> { 1, 2, 3, 4, 5, 6 };

        var result = GetEvenNumbers(source).ToList();

        Assert.Equal(new[] { 2, 4, 6 }, result);
    }

    [Fact]
    public void GetEvenNumbers_EmptySource_ReturnsEmpty()
    {
        var source = new List<int>();

        var result = GetEvenNumbers(source).ToList();

        Assert.Empty(result);
    }

    [Fact]
    public void GetEvenNumbers_NoMatches_ReturnsEmpty()
    {
        var source = new List<int> { 1, 3, 5 };

        var result = GetEvenNumbers(source).ToList();

        Assert.Empty(result);
    }
}

These three tests cover the critical paths: correct filtering, empty input, and no-match input. Each follows a clean arrange-act-assert structure without coupling to implementation details.

Testing early termination and disposal requires a bit more setup. You need an iterator that tracks whether Dispose() was called:

using System;
using System.Collections.Generic;
using System.Linq;

using Xunit;

public sealed class DisposalTrackingTests
{
    [Fact]
    public void Iterator_EarlyBreak_DisposesEnumerator()
    {
        var disposed = false;

        IEnumerable<int> TrackedIterator()
        {
            try
            {
                yield return 1;
                yield return 2;
                yield return 3;
            }
            finally
            {
                disposed = true;
            }
        }

        foreach (var item in TrackedIterator())
        {
            if (item == 1)
            {
                break;
            }
        }

        Assert.True(disposed);
    }

    [Fact]
    public void Iterator_FullEnumeration_DisposesEnumerator()
    {
        var disposed = false;

        IEnumerable<int> TrackedIterator()
        {
            try
            {
                yield return 1;
                yield return 2;
            }
            finally
            {
                disposed = true;
            }
        }

        _ = TrackedIterator().ToList();

        Assert.True(disposed);
    }
}

The try/finally pattern inside the iterator lets you verify that disposal occurs both on early exit and on complete enumeration. This is the same mechanism your production iterators should use when managing resources, which makes it a natural test target. These testing strategies mirror the test-driven approach you'd use when working with dependency injection containers like IServiceCollection, where verifying lifecycle behavior is just as important as verifying return values.

Thread Safety Considerations

Iterators and concurrency don't mix well by default. IEnumerator<T> is inherently stateful -- MoveNext() advances internal position, and Current reads it. If two threads call MoveNext() on the same enumerator simultaneously, the behavior is undefined. The iterator pattern best practice for thread safety in C# is simple: don't share enumerator instances across threads.

Each thread should call GetEnumerator() to get its own independent enumerator instance. If the underlying collection needs thread-safe access, use one of the concurrent collection types from System.Collections.Concurrent or protect access with a lock at the collection level, not the enumerator level.

When you need parallel iteration over a shared data source, Parallel.ForEach or PLINQ's .AsParallel() handle partitioning and synchronization internally. They create separate enumerators for each partition, avoiding the shared-state problem entirely:

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

public static void ProcessInParallel(IEnumerable<WorkItem> items)
{
    Parallel.ForEach(items, item =>
    {
        item.Execute();
    });
}

Parallel.ForEach partitions the source sequence and gives each worker thread its own slice. You don't touch the enumerator directly, and the partitioner handles synchronization. This is the right abstraction for CPU-bound parallel work over an iterable source. The decorator pattern can layer thread-safety concerns onto an existing collection's iteration behavior without modifying the collection itself, following the open-closed principle.

Frequently Asked Questions

Should I return IEnumerable or IReadOnlyList from my methods?

Return IEnumerable<T> when the consumer only needs to iterate forward once and you want to support lazy evaluation. Return IReadOnlyList<T> when the consumer needs to index into the collection, check Count, or iterate multiple times without re-executing the query. If you return IEnumerable<T> from a method that materializes the entire result into memory anyway, you're hiding the true cost from callers -- consider IReadOnlyList<T> instead so the API communicates that the data is already materialized.

What happens if I enumerate an IEnumerable twice?

It depends on the implementation. If the IEnumerable<T> is backed by a List<T> or array, enumerating twice is safe and returns the same elements. If it's backed by a yield return method, the method body re-executes from the beginning on each call to GetEnumerator(). This means any side effects -- database queries, file reads, API calls -- execute again. ReSharper and Rider flag this with a "Possible multiple enumeration" warning, which is worth paying attention to.

How do I make my iterator work with LINQ?

Any method or class that returns IEnumerable<T> or IAsyncEnumerable<T> works with LINQ out of the box. LINQ extension methods operate on the IEnumerable<T> interface, so yield return methods, custom collections, and manually implemented enumerators all compose with .Where(), .Select(), .Take(), and the rest. The only caveat is performance -- if your custom collection has a struct enumerator for zero-allocation iteration, LINQ will box it through the IEnumerable<T> interface and lose that benefit.

When should I use yield break?

Use yield break to exit an iterator method early without returning additional elements. It's the iterator equivalent of return in a regular method. Common use cases include stopping iteration when a condition is met, implementing take-while semantics, or short-circuiting when the source is exhausted. The compiler handles cleanup correctly -- finally blocks still execute after yield break, so resource disposal works as expected.

Can yield return be used inside try-catch blocks?

C# does not allow yield return inside a try block that has a catch clause. You can use yield return inside a try/finally block, which is how resource cleanup works in iterators. If you need exception handling around individual elements, extract the per-element logic into a separate method that handles exceptions internally, and yield return its result from the outer iterator method.

How do I cancel a long-running synchronous iterator?

Pass a CancellationToken as a parameter to your iterator method and check cancellationToken.IsCancellationRequested or call cancellationToken.ThrowIfCancellationRequested() at each iteration step. This is a cooperative pattern -- the iterator must check the token explicitly because there's no built-in mechanism for the runtime to interrupt a synchronous yield return method. For async iterators, use the [EnumeratorCancellation] attribute to wire the token from await foreach automatically.

Is the iterator pattern the same as IEnumerable in C#?

The iterator pattern is a behavioral design pattern that defines a way to access elements of a collection sequentially without exposing the underlying representation. IEnumerable<T> and IEnumerator<T> are C#'s built-in implementation of this pattern. When you implement IEnumerable<T> on a class or use yield return in a method, you're applying the iterator pattern. The language support makes the pattern nearly invisible compared to languages where you'd implement it from scratch.

Wrapping Up Iterator Pattern Best Practices

These iterator pattern best practices in C# address the real-world problems that surface once your iterators leave the tutorial sandbox. yield return eliminates state-machine bugs. Proper IDisposable handling prevents resource leaks on early exit. Snapshot or concurrent iteration guards against modification exceptions. IAsyncEnumerable<T> keeps threads free during I/O-bound streaming. Struct enumerators cut allocation pressure in hot paths. And targeted tests catch ordering, emptiness, and disposal bugs before they reach production.

The common thread across every section is intentional design. Choose yield return by default, and only drop to manual enumerators when profiling proves you need the optimization. Write try/finally blocks around resource acquisition in iterators. Pick your concurrency strategy -- snapshot, concurrent collection, or partitioned parallel iteration -- based on whether your consumers prioritize consistency or throughput.

Start with the simplest approach that solves your problem. A yield return method with a using statement covers most iterator use cases cleanly. Layer in async streaming, struct enumerators, and thread-safety mechanisms as your performance and concurrency requirements demand. The goal is iterators that are predictable, testable, and safe for every consumer that enumerates them.

Iterator Design Pattern in C#: Complete Guide with Examples

Master the iterator design pattern in C# with practical examples showing IEnumerable, IEnumerator, custom iterators, yield return, and real-world .NET implementations.

When to Use Iterator Pattern in C#: Decision Guide with Examples

Learn when to use the iterator pattern in C# with decision criteria and practical examples for custom collections, lazy evaluation, and streaming.

How to Implement Iterator Pattern in C#: Step-by-Step Guide

Learn how to implement the iterator pattern in C# with step-by-step examples covering IEnumerable, IEnumerator, yield return, and custom collection traversal.

An error has occurred. This application may no longer respond until reloaded. Reload