Collection Return Types in C#: Interfaces, Views, and Snapshots
A method that returns IReadOnlyList<T> tells the caller it cannot write, but says nothing about whether the data is an independent copy, a live view or a fixed report. The article separates the declared capability from the ownership and lifetime contract, then evaluates each collection return shape against both layers.
Key points
- IReadOnlyList<T> grants counting and positional reads only, not an immutable source or immutable elements
- Returning a List<T> through a narrow interface creates no copy; other aliases still see mutations
- AsReadOnly() yields a live view where source changes and additions remain visible
- ToArray() is a shallow snapshot: the outer members are detached, element state is shared
- For a truly detached response, project the values into a new DTO with LINQ
- For large results, return a bounded page instead of silently materializing the whole dataset
Returning a collection in C# involves two layers: the declared type the compiler sees and the ownership the caller actually holds. Handing back a mutable List<T> typed as IReadOnlyList<T> only narrows the visible methods — another reference to the same list can still add items, so a "read-only" signature is not a promise of independent storage.
A wrapper from AsReadOnly() blocks writes through itself yet keeps tracking the source, which is right for a component that wants a live view and wrong for a caller expecting a fixed report. Copying with ToArray() separates the outer structure but not the element objects: a record's init-only properties do not make a nested list immutable. When detached values are needed, the reliable move is a LINQ projection into a fresh DTO. And if writers can touch the source during acquisition, coordinate reads and writes first — locking after the copy does not make it consistent.
Real-world use
- An orders API where a screen wants a live view while a background export needs values frozen for the duration of the write
- A cached reference list that becomes meaningless once the owning object's lifetime ends
- A reporting endpoint that needs guaranteed count and ordering, where a shallow copy falls short
- A background worker that must coordinate reads and writes to produce a consistent response while another thread mutates the source
Why it matters
The return type decides how long a caller can trust the data it received; a wrong assumption produces responses that quietly change underneath the caller or mix values from different moments.