Collection Interfaces and Choosing the Right Collection

The .NET collections library is built on a layered hierarchy of interfaces. Coding to the most abstract interface that satisfies your needs makes code more flexible, more testable, and communicates intent clearly. A method that accepts IEnumerable<T> works with any collection; one that requires List<T> needlessly restricts callers. In ASP.NET Core, the framework itself demonstrates this principle โ€” controller actions return IEnumerable<T>, EF Core queries return IQueryable<T>, and DI registrations use interfaces โ€” never concrete types.

The Collection Interface Hierarchy

// IEnumerable<T> โ€” the foundation: just supports foreach iteration
// Implemented by: List, Array, HashSet, Dictionary.Values, LINQ queries, ...
public IEnumerable<Post> GetPublished(IEnumerable<Post> all)
    => all.Where(p => p.IsPublished);   // lazily evaluated

// ICollection<T> โ€” adds Count, Add, Remove, Clear, Contains
// Implemented by: List, HashSet, LinkedList, ...

// IList<T> โ€” adds index access [i], Insert, RemoveAt
// Implemented by: List, Array (read-only)

// IReadOnlyList<T> โ€” read-only indexed view: Count + [i], no mutation
// Implemented by: List (implicitly), Array, ReadOnlyCollection
public IReadOnlyList<Post> GetPage(int page, int size)
{
    return _posts
        .Skip((page - 1) * size)
        .Take(size)
        .ToList();  // returns List which implements IReadOnlyList
}

// IReadOnlyDictionary<TKey, TValue> โ€” read-only dictionary view
public IReadOnlyDictionary<string, string> GetConfig()
    => new Dictionary<string, string> { ["key"] = "value" };

// IDictionary<TKey, TValue> โ€” mutable dictionary operations
Note: IEnumerable<T> supports lazy evaluation โ€” LINQ queries that return IEnumerable<T> do not execute until you iterate the result. Calling .ToList() forces immediate evaluation. This is important for EF Core: _db.Posts.Where(p => p.IsPublished) returns IQueryable<T> (a superset of IEnumerable<T>) and builds a SQL query. Call .ToListAsync() to execute the query and materialise results into a List<T>. Returning a raw IQueryable<T> from a repository is a common mistake that allows callers to accidentally add database-side conditions after the repository returns.
Tip: Use these interface return types on service and repository methods: IReadOnlyList<T> for paged results (callers need indexed access), IEnumerable<T> for streaming results (callers iterate once), and Task<T?> for single-item results that might not exist. Avoid returning List<T> directly from public APIs โ€” callers should not expect mutability, and you may want to return an array, a lazy sequence, or a read-only collection in the future without breaking callers.
Warning: ConcurrentDictionary<TKey, TValue> is thread-safe but has subtleties. Individual operations like TryAdd and TryGetValue are atomic, but compound operations like “get or add” are not automatically atomic unless you use GetOrAdd(key, valueFactory). The valueFactory may be called multiple times under high concurrency โ€” ensure it is idempotent (calling it multiple times produces the same result). For truly atomic check-then-act sequences, use AddOrUpdate() or GetOrAdd() rather than manually combining ContainsKey and TryAdd.

ConcurrentDictionary โ€” Thread-Safe Shared State

// ConcurrentDictionary โ€” safe for concurrent reads and writes in ASP.NET Core
// Registered as Singleton in DI โ€” shared across all requests
public class InMemoryPostCache
{
    private readonly ConcurrentDictionary<int, Post> _cache = new();

    public Post GetOrCreate(int id, Func<int, Post> factory)
        => _cache.GetOrAdd(id, factory);  // atomic: only calls factory if key missing

    public void Invalidate(int id)
        => _cache.TryRemove(id, out _);

    public void Update(int id, Post post)
        => _cache.AddOrUpdate(id, post, (_, __) => post);  // atomic add or replace
}

Choosing the Right Collection โ€” Decision Guide

Need Best Choice
Ordered, mutable, general purpose List<T>
Key-based O(1) lookup, mutable Dictionary<K,V>
Unique items, fast membership test HashSet<T>
FIFO processing Queue<T>
LIFO processing Stack<T>
Sorted unique items SortedSet<T>
Priority-ordered processing PriorityQueue<T,P>
Thread-safe shared dictionary ConcurrentDictionary<K,V>
Immutable read-only view IReadOnlyList<T> / IReadOnlyDictionary<K,V>
Fixed size, no mutation needed T[] (array)

Common Mistakes

Mistake 1 โ€” Accepting List<T> as a parameter instead of IEnumerable<T>

โŒ Wrong โ€” unnecessarily restrictive; callers with arrays or LINQ results must call .ToList() first:

public int SumScores(List<int> scores) => scores.Sum();

โœ… Correct โ€” accept the most abstract interface that meets your needs:

public int SumScores(IEnumerable<int> scores) => scores.Sum();

Mistake 2 โ€” Returning IQueryable<T> from a repository method

โŒ Wrong โ€” callers can add arbitrary database conditions after the repository, creating a leaky abstraction.

โœ… Correct โ€” materialise the query inside the repository: return await query.ToListAsync() as IReadOnlyList<T>.

🧠 Test Yourself

A service method should return a page of posts that callers can iterate but not add to. Which return type best communicates this intent?