TL;DR

  • Build custom collections in C# to encapsulate domain logic and enforce business rules.
  • Implement IEnumerable<T>, indexers, and range support for expressive, modern APIs.
  • Use List<T> or Array for simple storage; use custom collections for specialized behavior.
  • Avoid exposing internal collections directly; handle null safety and bounds consistently.
  • Custom collections improve maintainability, prevent misuse, and make your API clearer.

Building your own collection type sounds intimidating, but it’s often the cleanest way to create a focused API. Instead of exposing a raw List<T> and hoping consumers use it correctly, you can build something that encapsulates business logic and provides exactly the interface you need.

Why Build Custom Collections?

You’ll often want to control how data is accessed or add domain-specific behavior. A ProductCatalog that filters discontinued items automatically is cleaner than scattering .Where(p => p.IsActive) throughout your codebase.

Here’s a practical example, a MonthlyReportCollection that wraps monthly data with smart indexing:

public class MonthlyReportCollection : IEnumerable<MonthlyReport>
{
    private readonly MonthlyReport[] _reports = new MonthlyReport[12];

    public MonthlyReportCollection(IEnumerable<MonthlyReport> reports)
    {
        foreach (var report in reports)
        {
            _reports[report.Month - 1] = report; // Month 1-12 -> Index 0-11
        }
    }

    // Indexer for month-based access
    public MonthlyReport? this[int month]
    {
        get => month is >= 1 and <= 12 ? _reports[month - 1] : null;
        set
        {
            if (month is >= 1 and <= 12 && value is not null)
                _reports[month - 1] = value;
        }
    }

    // Range/Index support for modern C#
    public MonthlyReport?[] this[Range range] => _reports[range];

    // IEnumerable implementation using yield return
    public IEnumerator<MonthlyReport> GetEnumerator()
    {
        foreach (var report in _reports)
        {
            if (report is not null) // Only yield non-null reports
                yield return report;
        }
    }

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

    // Bonus: domain-specific methods
    public MonthlyReport? GetQuarterEnd(int quarter) => quarter switch
    {
        1 => this[3],   // March
        2 => this[6],   // June  
        3 => this[9],   // September
        4 => this[12],  // December
        _ => null
    };
}

Usage feels natural and expressive:

var reports = new MonthlyReportCollection(monthlyData);

// Index access - clean and readable
var januaryReport = reports[1];
var lastQuarter = reports[10..12]; // Oct-Dec using Range

// Works with foreach
foreach (var report in reports)
{
    Console.WriteLine($"Month {report.Month}: ${report.Revenue}");
}

// LINQ works automatically
var profitableMonths = reports.Where(r => r.Revenue > 10000).ToList();

// Domain-specific API
var q4Report = reports.GetQuarterEnd(4);

Key Implementation Details

The yield return approach in GetEnumerator() is cleaner than manually implementing IEnumerator<T>. It generates the state machine for you and handles disposal automatically.

Notice how the indexer uses month is >= 1 and <= 12 for bounds checking, this prevents array index exceptions while keeping the API month-based rather than zero-based.

Range support (this[Range range]) gives you slicing syntax for free, making reports[1..4] work naturally.

Collection Capabilities Comparison

FeatureCustom CollectionListArray
foreachYes (via IEnumerable)YesYes
LINQYes (via IEnumerable)YesYes
Index [n]Yes (custom logic)YesYes
Range [1..3]Yes (if implemented)YesYes
Domain LogicYes (encapsulated)NoNo
Bounds ControlYes (custom rules)No (Exception)No (Exception)

Common Pitfalls

Watch out for exposing internal collections directly. If your indexer returns a reference type, consumers can modify it. Consider returning defensive copies or immutable wrappers when needed.

Null safety matters, decide whether your collection allows null items and handle it consistently in both the enumerator and indexer.

Performance can be different from List<T>. The yield return approach creates an enumerator object per iteration, which might matter in tight loops.

When to Build Your Own

Build custom collections when you need domain-specific behavior, want to hide implementation details, or need to enforce business rules during access.

Stick with List<T> or Array when you just need basic storage without special logic. The overhead isn’t worth it for simple scenarios.

Custom collections shine when they make your API clearer and prevent misuse. A well-designed collection type can eliminate entire classes of bugs by making invalid operations impossible.

FAQ

Why build a custom collection type in C#?

Custom collections let you encapsulate domain logic, enforce business rules, and provide a focused API. This prevents misuse and makes your codebase more maintainable and expressive.

How do you implement an indexer in a custom collection?

Define an indexer property, such as public T this[int index], to allow array-like access. You can add custom bounds checking or domain-specific logic inside the getter and setter.

What is the benefit of supporting Range in a collection?

Supporting Range (e.g., collection[1..4]) enables slicing and makes your collection compatible with modern C# syntax. It improves usability and allows for expressive data access.

How does implementing IEnumerable<T> help custom collections?

Implementing IEnumerable<T> allows your collection to work with foreach, LINQ, and other .NET APIs. It makes your type interoperable and easy to use in standard C# workflows.

What are common pitfalls when designing custom collections?

Common pitfalls include exposing internal collections directly, not handling null safety, and inconsistent bounds checking. Always encapsulate internal data and consider returning defensive copies.

When should you use List<T> or Array instead of a custom collection?

Use List<T> or Array for basic storage needs without special logic. Custom collections are best when you need to enforce rules, add domain-specific methods, or hide implementation details.

How does yield return simplify enumerator implementation?

yield return automatically generates the state machine for enumerators, making your code cleaner and less error-prone compared to manually implementing IEnumerator<T>.

Can custom collections support LINQ queries?

Yes, as long as your collection implements IEnumerable<T>, it will work seamlessly with LINQ methods like Where, Select, and ToList.

How do you ensure null safety in custom collections?

Decide if your collection allows null items and handle them consistently in both the indexer and enumerator. Document your behavior and validate inputs as needed.

What is a domain-specific API in a collection?

A domain-specific API exposes methods tailored to your business logic, such as GetQuarterEnd(int quarter) in a financial report collection. This makes your collection more expressive and prevents invalid operations.

Related Posts