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
FootballPlayerobjects 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:
AddRemoveInsertRemoveAt
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.
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 List<FootballPlayer> _players = ();
TeamName { ; ; } = ;
RunningTotal { ; ; }
IReadOnlyList<FootballPlayer> Players => _players;
{
_players.Add(player);
}
{
player = _players.FirstOrDefault(p => p.JerseyNumber == jerseyNumber);
(player == )
{
;
}
_players.Remove(player);
;
}
}
;
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));
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 uniquenessDictionary<TKey, TValue>for fast lookup- a database-backed repository
- a sorted structure
If your public API promised behavior, such changes are harder.
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.
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
Comparisons
| Approach | What it means | Pros | Cons | Best use case |
|---|---|---|---|---|
class X : List<T> | Your type is a List<T> | Easy to write, full list API available | Exposes too much, hard to enforce rules, tightly coupled to implementation | Rarely appropriate for domain models |
class X with private List<T> | Your type owns a list internally | Strong encapsulation, flexible, domain-friendly | Slightly more code | Best default for domain objects |
class X : Collection<T> | Custom collection type with hooks | Better extensibility, overridable mutation methods | Fewer convenience methods than List<T> |
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 members.
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.
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
FootballPlayertype with a name and jersey number. - Create a
FootballTeamtype with a team name and running total. - Store players internally using a private
List<FootballPlayer>. - Expose the roster as
IReadOnlyList<FootballPlayer>. - Add an
AddPlayermethod that rejects duplicate jersey numbers. - Add a
RemovePlayermethod that removes a player by jersey number.
Keep learning
Related questions
AddTransient vs AddScoped vs AddSingleton in ASP.NET Core Dependency Injection
Learn the differences between AddTransient, AddScoped, and AddSingleton in ASP.NET Core DI with examples and practical usage.
C# Type Checking Explained: typeof vs GetType() vs is
Learn when to use typeof, GetType(), and is in C#. Understand exact type checks, inheritance, and safe type testing clearly.
C# Version Numbers Explained: C# vs .NET Framework and Why “C# 3.5” Is Incorrect
Learn the correct C# version numbers, how they map to .NET releases, and why terms like C# 3.5 are inaccurate and confusing.