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>
orArray
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
Feature | Custom Collection | List | Array |
---|---|---|---|
foreach | Yes (via IEnumerable) | Yes | Yes |
LINQ | Yes (via IEnumerable) | Yes | Yes |
Index [n] | Yes (custom logic) | Yes | Yes |
Range [1..3] | Yes (if implemented) | Yes | Yes |
Domain Logic | Yes (encapsulated) | No | No |
Bounds Control | Yes (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#?
How do you implement an indexer in a custom collection?
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?
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?
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?
When should you use List<T>
or Array
instead of a custom collection?
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?
IEnumerable<T>
, it will work seamlessly with LINQ methods like Where
, Select
, and ToList
.How do you ensure null safety in custom collections?
What is a domain-specific API in a collection?
GetQuarterEnd(int quarter)
in a financial report collection. This makes your collection more expressive and prevents invalid operations.Related Posts
- Encapsulation and Information Hiding in C#: Best Practices and Real-World Examples
- What Are the Risks of Exposing Public Fields or Collections in C#?
- Encapsulation Best Practices in C#: Controlled Setters vs Backing Fields
- When Inheritance Still Makes Sense in C#: Polymorphism Without Swapping
- Guard Clauses in C#: Cleaner Validation and Fail-Fast Code