Question

Why Inheriting from List<T> Is Usually a Bad Idea in C#

csharp.netlistoopinheritance

Question

In C#, I might begin modeling a football team like this:

var footballTeam = new List<FootballPlayer>();

That works at first because a team contains players, and the list order can represent roster order.

Later, I may realize that a team also has other properties, such as:

  • a team name
  • a running total of scores
  • a budget
  • uniform colors

So it becomes tempting to create a custom type by inheriting from List<FootballPlayer>:

class FootballTeam : List<FootballPlayer>
{
    public string TeamName;
    public int RunningTotal;
}

However, I have read that inheriting from List<T> is generally discouraged. I want to understand why.

Specifically:

  • Why is inheriting from List<T> considered a bad idea?
  • What does it mean when people say List<T> is optimized and may not be safe to extend?
  • What problems can this cause for performance, correctness, or design?
  • What is a public API in this context, and why does it matter if my class inherits from List<T>?
  • If my project is small and internal, can I ignore this guideline?
  • Why would wrapping a List<T> inside another class be better than inheriting from it?
  • If a football team conceptually is a collection of players, what is the idiomatic C# way to model that?
  • When, if ever, is inheriting from List<T> acceptable?

I have also seen alternatives suggested, such as:

  • inheriting from Collection<T>
  • implementing IList<T>
  • wrapping a List<T> in a custom class

I would like a clear explanation of the trade-offs and the correct C# design approach for modeling a custom data structure that behaves like a list but also has extra domain-specific data and rules.

Short Answer

By the end of this page, you will understand why inheriting from List<T> is usually discouraged in C#, what design problems it creates, and why composition is often the safer and more flexible choice. You will also learn when Collection<T> makes sense, what a public API is, and how to model a domain type like FootballTeam in an idiomatic way.

Concept

In C#, the core issue is not whether a type contains many items, but whether it should behave exactly like a List<T>.

List<T> is a concrete implementation of a resizable array. It is designed to be a general-purpose data structure. When you inherit from it, you are saying:

  • my type can do everything a List<T> can do
  • all List<T> operations make sense for my type
  • future code may treat my type as a normal List<T>

That is often not true for domain models.

For example, a football team may have rules such as:

  • only FootballPlayer objects may be added
  • no duplicate jersey numbers
  • a maximum squad size
  • certain players must exist before others can be added
  • removing a captain may require extra logic

A plain List<T> knows nothing about those rules. If FootballTeam inherits from List<FootballPlayer>, all inherited methods become part of its public surface:

  • Add
  • Remove
  • Insert
  • RemoveAt
  • Clear
  • Sort
  • Reverse
  • AddRange
  • and more

That can expose operations that do not fit the meaning of a football team.

This is a design problem more than a syntax problem.

Why this matters

Inheritance should model an is-a relationship that holds behaviorally, not just structurally.

  • FootballTeam is not really a kind of List<FootballPlayer>
  • FootballTeam is a domain object that manages a collection of players

A useful rule is:

If your type has business meaning beyond being a container, prefer composition over inheriting from a concrete collection.

Public API

A public API is the set of public members other code can call.

For this class:

class FootballTeam : List<FootballPlayer>
{
    public string TeamName;
}

your public API includes not only TeamName, but every public member inherited from List<FootballPlayer>.

That matters because once other code depends on those inherited methods, changing the design later becomes harder. For example, if you later decide:

  • players should be stored in a HashSet<T> to prevent duplicates
  • roster order should be computed dynamically
  • additions must go through validation
  • the team should become read-only from the outside

then code that expects full List<T> behavior may break.

About optimization and performance

The usual warning is not mainly that subclassing List<T> makes it slow. The bigger issue is that List<T> was not designed as an extensibility base class.

Some methods are not virtual, so you cannot reliably intercept all mutations. That means if you want to enforce rules, inheritance from List<T> may fail because callers can use inherited methods that bypass your intended logic.

For example, even if you hide Add with your own method, other inherited operations may still modify the collection in ways you did not expect.

So the problem is usually correctness and maintainability, not just raw speed.

Why Collection<T> exists

Collection<T> is designed as a base class for custom collections. It exposes overridable methods like:

  • InsertItem
  • RemoveItem
  • SetItem
  • ClearItems

That makes it easier to enforce rules consistently.

It is intentionally smaller than List<T> because it is meant as a customization point, not as a feature-rich resizable array implementation.

The idiomatic approach

Most of the time, the best model is composition:

class FootballTeam
{
    private readonly List<FootballPlayer> _players = new();

    public string TeamName { get; set; }
    public int RunningTotal { get; set; }

    public IReadOnlyList<FootballPlayer> Players => _players;

    public void AddPlayer(FootballPlayer player)
    {
        _players.Add(player);
    }
}

This lets your type expose only the operations that make sense for the domain.

That is the key reason composition is preferred: you control the behavior instead of inheriting all behavior from a general-purpose collection.

Mental Model

Think of List<T> as a generic toolbox drawer.

If you inherit from it, you are saying your object is itself the drawer and should support every drawer operation.

But a football team is more like a sports club office that contains and manages a roster drawer.

  • The office has a name, budget, and score.
  • The office may allow adding players only under certain rules.
  • The office may not want outsiders rearranging or clearing the roster freely.

So the team is not literally the drawer. It owns one.

That is composition.

Another way to think about it:

  • Inheritance means: "I am a specialized version of this exact thing."
  • Composition means: "I use this thing internally to do my job."

A FootballTeam usually uses a List<FootballPlayer> internally rather than being one.

Take Quiz

Syntax and Examples

Inheriting from List<T>

public class FootballTeam : List<FootballPlayer>
{
    public string TeamName { get; set; } = "";
    public int RunningTotal { get; set; }
}

This compiles, but it exposes every List<T> method publicly.

Example usage:

var team = new FootballTeam { TeamName = "Falcons" };
team.Add(new FootballPlayer("Alex", 10));
team.Clear();
team.Sort();

The problem is that Clear() and Sort() may not make sense for your domain, but they are available anyway.

Preferred approach: composition

public class FootballTeam
{
    private readonly List<FootballPlayer> _players = new();

    public string TeamName { get; set; } = "";
    public int RunningTotal { get; set; }

    public IReadOnlyList<FootballPlayer> Players => _players;

    public void AddPlayer(FootballPlayer player)
    {
        _players.Add(player);
    }

    public bool RemovePlayer(int jerseyNumber)
    {
        var player = _players.FirstOrDefault(p => p.JerseyNumber == jerseyNumber);
        if (player == null)
        {
            return false;
        }

        _players.Remove(player);
        return true;
    }
}

public record FootballPlayer(string Name, int JerseyNumber);

This version gives you control.

  • You choose what operations exist.
  • You can validate before adding.
  • You can expose a read-only view.
  • You can change the internal storage later without breaking callers.

Using Collection<T> for a custom collection

If you truly want a custom collection type, Collection<T> is a better base class:

using System.Collections.ObjectModel;

public class PlayerCollection : Collection<FootballPlayer>
{
    protected override void InsertItem(int index, FootballPlayer item)
    {
        if (item is null)
        {
            throw new ArgumentNullException(nameof(item));
        }

        if (this.Any(p => p.JerseyNumber == item.JerseyNumber))
        {
            throw new InvalidOperationException("Duplicate jersey numbers are not allowed.");
        }

        base.InsertItem(index, item);
    }
}

Then your team can use that collection:

public class FootballTeam
{
    public string TeamName { get; set; } = "";
    public int RunningTotal { get; set; }
    public PlayerCollection Players { get; } = new();
}

This keeps the collection behavior separate from the team's other responsibilities.

Step by Step Execution

Consider this composition-based example:

public record FootballPlayer(string Name, int JerseyNumber);

public class FootballTeam
{
    private readonly List<FootballPlayer> _players = new();

    public string TeamName { get; }

    public FootballTeam(string teamName)
    {
        TeamName = teamName;
    }

    public IReadOnlyList<FootballPlayer> Players => _players;

    public void AddPlayer(FootballPlayer player)
    {
        if (_players.Any(p => p.JerseyNumber == player.JerseyNumber))
        {
            throw new InvalidOperationException("Duplicate jersey number.");
        }

        _players.Add(player);
    }
}

var team = new FootballTeam("Falcons");
team.AddPlayer(new FootballPlayer("Alex", 10));
team.AddPlayer(new FootballPlayer("Sam", 7));

Step by step

1. Create the team

var team = new FootballTeam("Falcons");
  • A new FootballTeam object is created.
  • Inside it, _players starts as an empty List<FootballPlayer>.
  • TeamName becomes "Falcons".

2. Add the first player

team.AddPlayer(new FootballPlayer("Alex", 10));
  • A FootballPlayer record is created with name Alex and jersey number 10.
  • AddPlayer checks whether any existing player has jersey number 10.
  • The list is empty, so no duplicate is found.
  • The player is added to _players.

3. Add the second player

team.AddPlayer(new FootballPlayer("Sam", 7));
  • A new player is created.
  • AddPlayer checks the current list.
  • No one has jersey number 7, so the player is added.

4. Access players

var count = team.Players.Count;
  • Players exposes an IReadOnlyList<FootballPlayer>.
  • Callers can inspect the roster.
  • Callers cannot directly call methods like Add on that read-only view.

This is the main advantage: all mutations go through AddPlayer, where your rules live.

Real World Use Cases

1. Domain models with business rules

A shopping cart, football team, playlist, or order often contains items, but it is not just a raw list.

Examples:

  • a shopping cart may reject unavailable products
  • a playlist may preserve order but limit duplicates
  • an order may prevent item changes after payment

2. Safer APIs

In application code, you may want other parts of the system to read a collection without changing it directly.

Example:

public IReadOnlyList<OrderItem> Items => _items;

This lets callers inspect the data while your class keeps control over updates.

3. Validation before mutation

Many apps need validation when adding or removing items:

  • prevent duplicate email addresses
  • ensure file paths exist
  • reject null or invalid data
  • enforce maximum counts

Composition or Collection<T> makes it easier to centralize these checks.

4. Freedom to change internals later

You may start with a List<T>, then later switch to:

  • HashSet<T> for uniqueness
  • Dictionary<TKey, TValue> for fast lookup
  • a database-backed repository
  • a sorted structure

If your public API promised List<T> behavior, such changes are harder.

Take Quiz

Real Codebase Usage

In real codebases, developers usually avoid exposing concrete mutable collections directly unless the type is purely a data structure.

Common patterns include:

Guard clauses

public void AddPlayer(FootballPlayer player)
{
    if (player is null)
        throw new ArgumentNullException(nameof(player));

    if (_players.Any(p => p.JerseyNumber == player.JerseyNumber))
        throw new InvalidOperationException("Duplicate jersey number.");

    _players.Add(player);
}

Guard clauses keep invalid state out.

Read-only exposure

public IReadOnlyList<FootballPlayer> Players => _players;

This is a very common pattern. The class owns mutation; callers get visibility.

Intention-revealing methods

Instead of exposing generic list operations, codebases often use domain methods:

team.AddPlayer(player);
team.TransferPlayer(player);
team.AssignCaptain(player);

These say more than Add or RemoveAt.

Encapsulation

Encapsulation means hiding internal details and exposing only what is necessary.

A team may use a List<T> today and something else tomorrow. If callers only know about AddPlayer and Players, internal changes stay local.

Custom collections when the collection itself matters

Sometimes a collection is a reusable concept on its own, such as:

  • PlayerCollection
  • EmailAddressList
  • ValidationErrorCollection

In those cases, inheriting from Collection<T> can be appropriate because you are building a collection abstraction, not a domain entity with unrelated properties.

Common Mistakes

Mistake 1: Using inheritance just because the data looks similar

Broken idea:

public class FootballTeam : List<FootballPlayer>
{
    public string TeamName { get; set; } = "";
}

Why it is a problem:

  • it exposes too many operations
  • it ties your model to one storage type
  • it can violate domain rules

Better:

public class FootballTeam
{
    private readonly List<FootballPlayer> _players = new();
    public string TeamName { get; set; } = "";
    public IReadOnlyList<FootballPlayer> Players => _players;
}

Mistake 2: Exposing a mutable list directly

public List<FootballPlayer> Players { get; } = new();

Problem:

  • any caller can add, remove, clear, or reorder players freely
  • your class cannot enforce rules

Safer:

private readonly List<FootballPlayer> _players = new();
public IReadOnlyList<FootballPlayer> Players => _players;

Mistake 3: Confusing “is-a” with “contains-a”

A team contains players, but that does not mean a team should inherit from the same class used to store players.

Ask:

  • Does every operation of the base type make sense?
  • Should outside code treat this exactly like that base type?

If not, prefer composition.

Mistake 4: Trying to enforce rules by hiding one method

public class FootballTeam : List<FootballPlayer>
{
    public new void Add(FootballPlayer player)
    {
        // validation here
        base.Add(player);
    }
}

Problem:

  • other inherited methods may still bypass your logic
  • method hiding is not reliable design

Mistake 5: Overengineering small internal code

If a class is private and used in one tiny script, inheriting from List<T> may not explode immediately. But beginners often carry that pattern into larger systems where it causes trouble.

A good habit is to model domain objects with clear responsibilities from the start.

Comparisons

ApproachWhat it meansProsConsBest use case
class X : List<T>Your type is a List<T>Easy to write, full list API availableExposes too much, hard to enforce rules, tightly coupled to implementationRarely appropriate for domain models
class X with private List<T>Your type owns a list internallyStrong encapsulation, flexible, domain-friendlySlightly more codeBest default for domain objects
class X : Collection<T>Custom collection type with hooksBetter extensibility, overridable mutation methodsFewer convenience methods than List<T>Good for reusable custom collections
Implement IList<T> manuallyFull custom list behaviorMaximum controlMore boilerplateOnly when you truly need a custom list implementation
Expose IReadOnlyList<T>Read-only view for callersSafe, simple, common in APIsCallers cannot mutate directlyGreat for encapsulated models

List<T> vs Collection<T>

FeatureList<T>Collection<T>
Main purposeFast general-purpose listBase class for custom collections
Rich convenience methodsYesFewer
Designed for inheritanceNot reallyYes
Overridable mutation hooksNo meaningful full set for customizationYes
Good for domain entitiesUsually noOnly if the entity itself is truly a collection

Rule of thumb

  • Use List<T> as an internal implementation detail.
  • Use composition for domain objects.
  • Use Collection<T> when building a custom collection abstraction.
Take Quiz

Cheat Sheet

Quick rules

  • Prefer composition over inheriting from List<T>.
  • Inherit from List<T> only when your type truly should behave exactly like a list.
  • Expose IReadOnlyList<T> when callers should read but not mutate.
  • Use Collection<T> if you need a custom collection base class with validation hooks.

Good default pattern

public class FootballTeam
{
    private readonly List<FootballPlayer> _players = new();

    public IReadOnlyList<FootballPlayer> Players => _players;

    public void AddPlayer(FootballPlayer player)
    {
        _players.Add(player);
    }
}

When inheritance is risky

  • your type has business rules
  • not all List<T> methods make sense
  • you may change internal storage later
  • you want controlled mutation

Public API means

The public members other code depends on.

If you inherit from List<T>, your API includes all public List<T> members.

Collection<T> customization points

  • InsertItem
  • RemoveItem
  • SetItem
  • ClearItems

Common interfaces

  • IEnumerable<T>: can be iterated
  • ICollection<T>: basic collection operations
  • IList<T>: index-based mutable list
  • IReadOnlyList<T>: index-based read-only access

Simple decision guide

  • Need a normal list internally? Use List<T>.
  • Need a domain entity with items? Use composition.
  • Need a reusable custom collection type? Consider Collection<T>.
  • Need total control over list behavior? Implement an interface manually.

FAQ

Is inheriting from List<T> always wrong in C#?

No, but it is usually the wrong choice for domain models. It can be acceptable for quick internal code or when your type truly should expose full list behavior.

Why is composition better than inheriting from List<T>?

Composition lets you control which operations are allowed, enforce validation, and change the internal storage later without breaking callers.

What is a public API in simple terms?

A public API is everything public that other code can use. If your class inherits from List<T>, all public List<T> methods become part of that API.

Why not just expose public List<T> Players?

Because any caller can mutate it directly, bypassing your rules. That makes the object harder to keep valid.

When should I use Collection<T> instead of List<T>?

Use Collection<T> when you are building a custom collection type and need overridable hooks for add, remove, set, or clear operations.

Is IReadOnlyList<T> actually read-only?

It is read-only from the caller's point of view. The underlying collection can still change internally.

If my project is small, can I ignore the guideline?

Sometimes, yes. But using composition is usually still a better habit because it scales better as the code grows.

Why didn't Microsoft make List<T> sealed?

Because inheritance is sometimes useful, and .NET often leaves room for advanced scenarios. But not being sealed does not mean it is the best design choice in most application code.

Take Quiz

Mini Project

Description

Build a small FootballTeam model that stores players, exposes the roster safely, and enforces one business rule: jersey numbers must be unique. This demonstrates how composition gives you better control than inheriting from List<FootballPlayer>.

Goal

Create a FootballTeam class with team metadata and a controlled player roster that prevents duplicate jersey numbers.

Requirements

  • Create a FootballPlayer type with a name and jersey number.
  • Create a FootballTeam type with a team name and running total.
  • Store players internally using a private List<FootballPlayer>.
  • Expose the roster as IReadOnlyList<FootballPlayer>.
  • Add an AddPlayer method that rejects duplicate jersey numbers.
  • Add a RemovePlayer method that removes a player by jersey number.
Take Quiz

Keep learning

Related questions

View more