← Back to Articles         
Collection Interfaces

Collection Interfaces

Published on 12 Oct 2026     12 min read C# Collections & Generics
Collection Interfaces

Collection interfaces define how collections can be accessed, enumerated, modified, and queried.

The most important interfaces are:

  • IEnumerable<T> – Supports iteration.

  • ICollection<T> – Supports iteration, counting, adding, and removing.

  • IList<T> – Supports indexed access and modification.

  • IQueryable<T> – Supports query expression construction, commonly for database queries.

  • IEnumerator<T> – Controls iteration through a collection.

  • IReadOnlyList<T> – Supports reading elements by index without exposing modification methods.

 

1. What is the difference between IEnumerable<T>, ICollection<T>, and IList<T>?

What are they?

These three interfaces provide different levels of functionality for working with collections.

1. IEnumerable<T>

  • Provides sequential access to elements.

  • Supports foreach.

  • Does not provide Add(), Remove(), or indexed access.

  • Useful when we only need to enumerate data.

2. ICollection<T>

  • Inherits from IEnumerable<T>.

  • Supports Add(), Remove(), Clear(), and Count.

  • Does not provide indexed access.

  • Useful when we need to manage a collection.

3. IList<T>

  • Inherits from ICollection<T>.

  • Supports indexed access using [index].

  • Supports Insert() and RemoveAt().

  • Useful when we need to access or modify elements by position.

Interface Hierarchy

          IEnumerable<T>
                |
                v
          ICollection<T>
                |
                v
             IList<T>
                |
                v
             List<T>

List<T> implements all three interfaces.

Example Program

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // IEnumerable - Read/Iterate
        IEnumerable<int> numbers1 = new List<int> { 10, 20, 30 };

        foreach (int number in numbers1)
        {
            Console.WriteLine(number);
        }

        // ICollection - Add/Remove/Count
        ICollection<int> numbers2 = new List<int> { 10, 20 };

        numbers2.Add(30);
        numbers2.Remove(10);

        Console.WriteLine(numbers2.Count);

        // IList - Index Access
        IList<int> numbers3 = new List<int> { 10, 20, 30 };

        numbers3[0] = 100;
        numbers3.Insert(1, 50);

        Console.WriteLine(numbers3[0]);
    }
}

Output:

10
20
30
2
100

Comparison

Feature

IEnumerable<T>

ICollection<T>

IList<T>

foreach

Yes

Yes

Yes

Count property

No

Yes

Yes

Add()

No

Yes

Yes

Remove()

No

Yes

Yes

Index [0]

No

No

Yes

Insert()

No

No

Yes

RemoveAt()

No

No

Yes

Key Points

  • IEnumerable<T> is suitable for iteration.

  • ICollection<T> is suitable for managing collections.

  • IList<T> supports indexed access.

  • These interfaces define available operations, not necessarily the underlying collection's mutability.

  • For example, some IList<T> implementations are read-only or fixed-size and may throw exceptions for unsupported modifications.

Interview Point: Choose the least powerful interface that satisfies your requirement. This reduces coupling and makes code more reusable.

 

2. What is the difference between IEnumerable<T> and IQueryable<T>?

What are they?

Both interfaces are used to query collections, but their execution models are different.

IEnumerable<T>

  • Commonly used with in-memory collections.

  • LINQ operators work with .NET delegates.

  • Filtering occurs in application code when using LINQ to Objects.

IQueryable<T>

  • Commonly used with Entity Framework Core.

  • Represents queries as expression trees.

  • A query provider can translate expressions into SQL or another query language.

  • Allows filtering to be performed by the database.

Diagram

IEnumerable<T>
      |
      v
 In-Memory Data
      |
      v
 LINQ to Objects
      |
      v
 Filter in Application
      |
      v
    Result


IQueryable<T>
      |
      v
 LINQ Expression
      |
      v
 EF Core Provider
      |
      v
 SQL Query
      |
      v
 Database Filtering
      |
      v
    Result

Example Program

Assume we have an EF Core AppDbContext containing an Employees table.

Using IEnumerable<T>:

IEnumerable<Employee> employees = await context.Employees
    .ToListAsync();

var result = employees
    .Where(e => e.Salary > 50000);

foreach (var employee in result)
{
    Console.WriteLine(employee.Name);
}

Explanation:

  1. ToListAsync() executes the database query.

  2. All employees are loaded into application memory.

  3. Where() filters the employees in memory.

  4. This can be inefficient for large tables.

Using IQueryable<T>:

IQueryable<Employee> employees = context.Employees;

var result = employees
    .Where(e => e.Salary > 50000);

foreach (var employee in result)
{
    Console.WriteLine(employee.Name);
}

Explanation:

  1. context.Employees provides an IQueryable<Employee>.

  2. Where() adds a filter to the query expression.

  3. The query executes when the foreach enumerates it.

  4. EF Core translates the supported expression into SQL.

Conceptually, the generated SQL is:

SELECT *
FROM Employees
WHERE Salary > 50000;

Only matching employees are retrieved.

Comparison

Feature

IEnumerable<T>

IQueryable<T>

Namespace

System.Collections.Generic

System.Linq

Typical source

In-memory collection

Database query

LINQ representation

Delegates

Expression trees

Filtering

Application memory

Provider-dependent

SQL translation

No

Supported by EF Core

Deferred execution

Supported

Supported

Common use

List<T>, arrays

EF Core DbSet<T>

Important Clarification

IEnumerable<T> does not automatically mean all records have already been loaded. An EF Core query can also be enumerated through IEnumerable<T>.

The critical difference is whether additional LINQ operators are composed into the database query or executed locally.

Key Points

  • IEnumerable<T> uses LINQ to Objects for subsequent LINQ operations.

  • IQueryable<T> allows query providers to translate expressions.

  • Both can support deferred execution.

  • ToList() and ToListAsync() materialize the results.

  • For EF Core, apply filters before materialization whenever possible.

  • IQueryable<T> is useful in the data-access layer, but exposing it across architectural boundaries can create unwanted coupling.

Interview Point: For database filtering, use IQueryable<T> to allow EF Core to execute supported filters in SQL rather than retrieving unnecessary records.

 

3. What is the difference between IEnumerable<T> and List<T>?

What are they?

IEnumerable<T> is an interface, whereas List<T> is a concrete collection class.

IEnumerable<T>

  • Defines a contract for iteration.

  • Does not expose modification methods.

  • Can represent arrays, lists, and other enumerable sequences.

List<T>

  • Is a concrete, resizable collection.

  • Stores elements in an internal array.

  • Supports adding, removing, sorting, and indexed access.

  • Implements several collection interfaces, including IEnumerable<T>.

Example Program

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 10, 20, 30 };

        numbers.Add(40);
        numbers.Remove(10);

        IEnumerable<int> result = numbers;

        foreach (int number in result)
        {
            Console.WriteLine(number);
        }
    }
}

Output:

20
30
40

Explanation

  1. numbers is a List<int>.

  2. We can add or remove elements.

  3. The list is assigned to an IEnumerable<int> variable.

  4. Through result, we can enumerate elements.

  5. We cannot directly call result.Add() because IEnumerable<T> does not expose that method.

Diagram

          List<int>
              |
       +------+------+
       |             |
     Add()         Remove()
       |             |
       +------+------+
              |
              v
       IEnumerable<int>
              |
              v
           foreach
              |
              v
       20 -> 30 -> 40

Comparison

Feature

IEnumerable<T>

List<T>

Type

Interface

Class

Create with new

Not directly

Yes

foreach

Yes

Yes

Add()

No

Yes

Remove()

No

Yes

Index access

No

Yes

Count property

No

Yes

Data storage

Not specified

Internal array

Key Points

  • IEnumerable<T> defines iteration behavior.

  • List<T> is an actual collection implementation.

  • List<T> implements IEnumerable<T>.

  • An IEnumerable<T> may be lazy or backed by an existing collection.

  • Assigning a List<T> to IEnumerable<T> does not copy the data or make the list immutable.

Interview Point: Prefer IEnumerable<T> for methods that only need to enumerate elements. Use List<T> when list-specific functionality is required.

 

4. What is the difference between IEnumerable<T> and IEnumerator<T>?

What are they?

These interfaces work together to support collection iteration.

IEnumerable<T>

Represents a sequence that can provide an enumerator.

Its important method is:

IEnumerator<T> GetEnumerator();

IEnumerator<T>

Represents the object that moves through the sequence.

Important members:

MoveNext()
Current
Reset() // From the non-generic IEnumerator interface
Dispose()

IEnumerator<T> inherits from IEnumerator and IDisposable.

Diagram

        IEnumerable<int>
                |
                | GetEnumerator()
                v
        IEnumerator<int>
                |
                v
            MoveNext()
                |
                v
             Current
                |
                v
       +--------+--------+
       |        |        |
      10       20       30

Example Program

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        IEnumerable<int> numbers = new List<int>
        {
            10, 20, 30
        };

        using IEnumerator<int> enumerator =
            numbers.GetEnumerator();

        while (enumerator.MoveNext())
        {
            Console.WriteLine(enumerator.Current);
        }
    }
}

Output:

10
20
30

Explanation

  1. numbers represents an enumerable sequence.

  2. GetEnumerator() creates or obtains an enumerator.

  3. MoveNext() moves to the next element.

  4. Current returns the element at the current position.

  5. When MoveNext() returns false, iteration ends.

  6. using ensures the enumerator is disposed.

Comparison

Feature

IEnumerable<T>

IEnumerator<T>

Purpose

Represents enumerable sequence

Iterates through sequence

GetEnumerator()

Yes

No

MoveNext()

No

Yes

Current

No

Yes

Used by

foreach

foreach internally

Responsibility

Supplies enumerator

Maintains iteration position

Key Points

  • IEnumerable<T> provides the enumerator.

  • IEnumerator<T> performs iteration.

  • MoveNext() returns true when another element is available.

  • Current returns the current element.

  • An enumerator initially sits before the first element.

  • foreach handles these operations automatically.

Interview Point: IEnumerable<T> represents the sequence, while IEnumerator<T> is the cursor used to traverse it.

 

5. How does foreach work internally?

What is foreach?

foreach is a C# looping statement used to iterate over arrays, lists, and other enumerable collections.

Internally, for a typical IEnumerable<T> collection, it uses an enumerator to access elements sequentially.

Execution Flow

          foreach
              |
              v
       GetEnumerator()
              |
              v
          MoveNext()
              |
       +------+------+
       |             |
      true          false
       |             |
       v             v
     Current       End Loop
       |
       v
   Execute Body
       |
       +------------+
                    |
                    v
                MoveNext()

Example Program Using foreach

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        List<int> numbers = new() { 10, 20, 30 };

        foreach (int number in numbers)
        {
            Console.WriteLine(number);
        }
    }
}

Output:

10
20
30

Equivalent Enumerator-Based Code

Conceptually, the above foreach is similar to:

List<int> numbers = new() { 10, 20, 30 };

using (IEnumerator<int> enumerator =
       numbers.GetEnumerator())
{
    while (enumerator.MoveNext())
    {
        int number = enumerator.Current;

        Console.WriteLine(number);
    }
}

Step-by-Step Explanation

Step 1: Obtain enumerator

numbers.GetEnumerator();

The collection provides an enumerator.

Step 2: Move to the next element

enumerator.MoveNext();

This returns true when an element is available.

Step 3: Read current element

enumerator.Current;

This retrieves the current element.

Step 4: Execute loop body

Console.WriteLine(number);

The current element is processed.

Step 5: Repeat

MoveNext() is called again until it returns false.

Step 6: Dispose enumerator

The enumerator is disposed if applicable, including when the loop exits through an exception.

Can we modify a collection during foreach?

Normally, we should not structurally modify a List<T> while iterating over it.

List<int> numbers = new() { 10, 20, 30 };

foreach (int number in numbers)
{
    numbers.Remove(number); // Runtime exception
}

Typical exception:

InvalidOperationException:
Collection was modified; enumeration operation
may not execute.

A safer approach is:

List<int> numbers = new() { 10, 20, 30 };

numbers.RemoveAll(x => x > 10);

Key Points

  • foreach obtains an enumerator.

  • MoveNext() advances the iteration.

  • Current retrieves the current element.

  • The compiler handles enumerator cleanup.

  • Modifying a List<T> during enumeration generally invalidates its enumerator.

  • Arrays and some other collection types can use optimized iteration mechanisms.

  • foreach can also work with types following the enumerator pattern without implementing IEnumerable<T>.

Interview Point: For standard enumerable collections, foreach is compiled into enumerator-based iteration, with cleanup handled automatically.

 

6. What is the difference between IList<T> and IReadOnlyList<T>?

What are they?

Both interfaces support indexed access, but they expose different operations.

IList<T>

  • Supports reading elements.

  • Supports updating elements through an indexer.

  • Defines Add(), Remove(), Insert(), and RemoveAt().

  • Suitable for collections that need modification.

IReadOnlyList<T>

  • Supports reading elements using an index.

  • Exposes Count.

  • Does not expose modification methods.

  • Suitable when consumers should only read collection elements.

Example Program

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        IList<int> numbers1 = new List<int>
        {
            10, 20, 30
        };

        numbers1[0] = 100;
        numbers1.Add(40);

        Console.WriteLine(numbers1[0]);

        IReadOnlyList<int> numbers2 = new List<int>
        {
            10, 20, 30
        };

        Console.WriteLine(numbers2[0]);
        Console.WriteLine(numbers2.Count);

        // Compile-time error
        // numbers2.Add(40);
        // numbers2[0] = 100;
    }
}

Output:

100
10
3

Explanation

Using IList<T>:

numbers1[0] = 100;
numbers1.Add(40);

The interface exposes methods for modifying the collection.

Using IReadOnlyList<T>:

Console.WriteLine(numbers2[0]);
Console.WriteLine(numbers2.Count);

The interface exposes only reading operations.

The following are not allowed through IReadOnlyList<T>:

numbers2.Add(40);
numbers2.Remove(10);
numbers2[0] = 100;

Diagram

               List<int>
                   |
          +--------+--------+
          |                 |
          v                 v
       IList<T>       IReadOnlyList<T>
          |                 |
       Read/Write        Read Only
          |                 |
       Add()             Count
       Remove()          Index [0]
       Insert()          foreach
       Index [0]         

Comparison

Feature

IList<T>

IReadOnlyList<T>

foreach

Yes

Yes

Count

Yes

Yes

Index access

Yes

Yes

Index assignment

Defined

No

Add()

Defined

No

Remove()

Defined

No

Insert()

Defined

No

Guarantees immutability

No

No

Important: Read-Only Does Not Mean Immutable

Consider:

List<int> numbers = new() { 10, 20, 30 };

IReadOnlyList<int> readOnlyNumbers = numbers;

numbers.Add(40);

Console.WriteLine(readOnlyNumbers.Count);

Output:

4

Although readOnlyNumbers is an IReadOnlyList<int>, it still references the same underlying List<int>.

Changes made through numbers are visible through readOnlyNumbers.

For stronger immutability guarantees, consider immutable collections such as ImmutableArray<T>.

Key Points

  • IList<T> exposes collection modification operations.

  • IReadOnlyList<T> exposes only read operations.

  • Both support indexed access.

  • IReadOnlyList<T> does not guarantee that the underlying data cannot change.

  • IList<T> methods may throw NotSupportedException for implementations that do not permit modification.

  • Use IReadOnlyList<T> when a method needs indexed reading without modification capabilities.

 

Quick Revision – Collection Interfaces

Interface

Main Purpose

Important Members

IEnumerable<T>

Iterate elements

GetEnumerator()

IEnumerator<T>

Control iteration

MoveNext(), Current

ICollection<T>

Manage collection

Add(), Remove(), Count

IList<T>

Indexed access and modification

[index], Insert(), RemoveAt()

IReadOnlyList<T>

Indexed read access

[index], Count

IQueryable<T>

Provider-based query composition

Expression, Provider

List<T>

Concrete resizable collection

Add(), Remove(), Sort()

Real-Time Scenario: ASP.NET Core Web API with EF Core

Consider an Employee API that retrieves employees from SQL Server.

public class EmployeeService
{
    private readonly AppDbContext _context;

    public EmployeeService(AppDbContext context)
    {
        _context = context;
    }

    public async Task<IReadOnlyList<Employee>> GetEmployeesAsync()
    {
        IQueryable<Employee> query = _context.Employees
            .AsNoTracking()
            .Where(e => e.IsActive);

        List<Employee> employees = await query.ToListAsync();

        return employees;
    }
}

Execution Flow

       EmployeeService
              |
              v
      IQueryable<Employee>
              |
              v
      Apply Where Filter
              |
              v
        EF Core Provider
              |
              v
          SQL Server
              |
              v
          ToListAsync()
              |
              v
        List<Employee>
              |
              v
     IReadOnlyList<Employee>
              |
              v
          API Layer

Why use these interfaces?

  • IQueryable<Employee>: Builds the database query and applies filtering before retrieval.

  • List<Employee>: Stores the materialized results in memory.

  • IReadOnlyList<Employee>: Exposes indexed, read-only access to the returned collection without exposing modification methods.

The returned IReadOnlyList<Employee> does not make the Employee objects themselves immutable.

Most Important Interview Takeaways

  1. IEnumerable<T> is for iteration, while ICollection<T> adds collection management.

  2. IList<T> adds indexed access and positional operations.

  3. IQueryable<T> supports provider-based query translation, making it important in EF Core.

  4. IEnumerable<T> is an interface; List<T> is a concrete implementation.

  5. IEnumerable<T> supplies an enumerator; IEnumerator<T> controls traversal.

  6. foreach typically uses GetEnumerator(), MoveNext(), and Current.

  7. IReadOnlyList<T> restricts modification through the interface but does not guarantee immutability.

For .NET Tech Lead interviews: Pay particular attention to IEnumerable<T> vs. IQueryable<T>, deferred execution, database query performance, and choosing appropriate collection interfaces for service contracts.

← Back to Articles